diff --git a/.dockerignore b/.dockerignore index c2df9ef..3f48519 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,5 +7,10 @@ README.md LICENSE dist .DS_Store +.env +.env.* +!.env.example +.httpworkbench +data packages/*/node_modules packages/*/dist diff --git a/Caddyfile b/Caddyfile index 3174b75..25adaa7 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,6 +1,13 @@ { admin off auto_https disable_redirects + servers { + # Only Cloudflare's published egress ranges may supply the client IP. + # Direct requests ignore CF-Connecting-IP and use the socket peer instead. + trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32 + trusted_proxies_strict + client_ip_headers CF-Connecting-IP + } log { level ERROR } @@ -13,10 +20,18 @@ http://{$DOMAIN}, https://{$DOMAIN} { } encode gzip zstd + header { + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + X-Frame-Options "DENY" + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + } header /assets/* Cache-Control "public, max-age=31536000, immutable" handle /api/* { reverse_proxy backend:8081 { + header_up X-Internal-Real-IP {client_ip} + header_up -CF-Connecting-IP header_up -X-Forwarded-For header_up -X-Forwarded-Host header_up -X-Forwarded-Proto @@ -57,7 +72,8 @@ http://*.{$INSTANCES_DOMAIN}, https://*.{$INSTANCES_DOMAIN} { } reverse_proxy backend:8082 { - header_up X-Internal-Real-IP {remote_host} + header_up X-Internal-Real-IP {client_ip} + header_up -CF-Connecting-IP header_up -X-Forwarded-For header_up -X-Forwarded-Host header_up -X-Forwarded-Proto diff --git a/README.md b/README.md index 163f704..219067d 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,11 @@ curl https://httpworkbench.com/api/instances \ The full OpenAPI specification is served at `/api/openapi.json` ```bash -# Create a static instance +# Create an instance curl -X POST https://httpworkbench.com/api/instances \ -H "Authorization: Bearer hwb__" \ -H "Content-Type: application/json" \ - -d '{"kind":"static","raw":"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello"}' + -d '{"raw":"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello"}' # Read its logs (paginate with nextCursor) curl "https://httpworkbench.com/api/instances//logs?limit=50" \ @@ -143,6 +143,15 @@ Cloudflare proxy settings: - `ns2.yourdomain.com`: `DNS only` - `NS` records are just `NS` records; there is no proxy toggle for them +Caddy trusts `CF-Connecting-IP` only when the TCP connection comes from one of +Cloudflare's [published proxy ranges](https://www.cloudflare.com/ips/). A +request sent directly to the origin uses its socket address instead, even if it +supplies a spoofed `CF-Connecting-IP` or forwarding header. The ranges are kept +in `Caddyfile`; compare them with Cloudflare's current +[IPv4](https://www.cloudflare.com/ips-v4/) and +[IPv6](https://www.cloudflare.com/ips-v6/) lists when upgrading a self-hosted +deployment. + The wizard can start the stack for you, or you can run the compose command yourself at the end. The first startup may take a few minutes to build images and provision SSL certificates. ### Interaction Hostnames @@ -168,6 +177,11 @@ docker compose -f docker-compose.yml -f docker-compose.dns.yml up -d --build The main app certificate still uses Cloudflare DNS challenge. Instance subdomains under the delegated interaction zone are issued directly with on-demand TLS by Caddy the first time they are requested, stored in Caddy's persistent data volume, and renewed automatically. +Because the delegated interaction zone is `DNS only`, HTTP interactions reach +Caddy directly and are logged and rate-limited by the connection's source +address. Do not proxy this zone through Cloudflare: doing so changes protocol +behavior and makes every interaction originate from a Cloudflare proxy. + ### Email (SMTP) interaction logging HTTP Workbench can also receive email sent to any instance hostname and record it as an `SMTP` interaction alongside HTTP and DNS hits. A message sent to `anything@abc.instances.yourdomain.com` is logged against instance `abc`. The server only accepts and logs mail — it never relays or delivers it anywhere. @@ -189,6 +203,52 @@ docker compose -f docker-compose.yml -f docker-compose.dns.yml -f docker-compose No extra DNS records are required. Sending mail servers fall back to a host's `A` record when it has no `MX` record (RFC 5321 "implicit MX"), and instance hostnames already resolve to your server. The only requirements are that instance `A` records point at the real origin IP (a Cloudflare-proxied/orange-cloud record will not work for SMTP) and that public TCP port `25` reaches the backend. The server accepts plaintext on port 25 and does not advertise STARTTLS, so senders deliver over plaintext. +### Database migration backups + +Before applying new migrations to an existing database, the backend creates a +consistent SQLite snapshot in the persistent data volume: + +```text +${DATA_DIR}/db.sqlite.pre-migration--to-.backup +``` + +The deterministic file is retained and reused if startup is retried. No backup +is created for a fresh database or when every migration is already applied. + +If an upgrade fails, stop the stack and list the available snapshots (do not +use `docker compose down -v`, which deletes the data volume): + +```bash +docker compose down +docker compose run --rm --no-deps --entrypoint sh backend \ + -c 'ls -1 /app/data/*.backup' +``` + +Then substitute the selected filename below. This preserves the failed database +for diagnosis, restores the snapshot, and removes SQLite sidecars that belong to +the failed database: + +```bash +docker compose run --rm --no-deps --entrypoint sh backend -ec ' + backup=/app/data/REPLACE_WITH_BACKUP_FILENAME + failed="/app/data/db.sqlite.failed-$(date -u +%Y%m%dT%H%M%SZ)" + cp /app/data/db.sqlite "$failed" + test ! -f /app/data/db.sqlite-wal || cp /app/data/db.sqlite-wal "$failed-wal" + test ! -f /app/data/db.sqlite-shm || cp /app/data/db.sqlite-shm "$failed-shm" + cp "$backup" /app/data/db.sqlite + rm -f /app/data/db.sqlite-wal /app/data/db.sqlite-shm +' +``` + +Finally, check out the previous application version and rebuild the stack before +starting it again. A database restored to a pre-migration snapshot must not be +started with the failed/new application version. + +```bash +git checkout PREVIOUS_GIT_REF +docker compose up -d --build +``` + ## Your Data HTTP Workbench stores just the basics in a local SQLite database: your Google ID for login (not the email), when you created your account, your PoC pages, webhooks, API keys, and the HTTP/DNS interaction logs (including IPs, headers, DNS questions, and request details). This is all needed for the app to work properly. @@ -197,7 +257,9 @@ Full transparency: I never tried to access any data of users on ssrf.cvssadvisor ## Contributing -Contributions are welcome! Please feel free to submit a Pull Request. +Contributions are welcome! Please feel free to submit a Pull Request or a Issue. + +Thanks a lot to [Sim4n6](https://github.com/Sim4n6) for multiple feature ideas! ## License diff --git a/bun.lock b/bun.lock index a5d660e..00321a5 100644 --- a/bun.lock +++ b/bun.lock @@ -30,18 +30,18 @@ "name": "backend", "dependencies": { "@elysiajs/openapi": "^1.4.15", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "^2.0.0", "arctic": "^3.7.0", "dns-packet": "^5.6.1", "drizzle-orm": "^0.45.2", "elysia": "^1.4.28", - "http-z": "^8.1.1", "jose": "^6.2.3", "shared": "workspace:*", "urlguard": "^1.0.0", "zod": "^4.3.6", }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/bun": "latest", "@types/dns-packet": "^5.6.5", "drizzle-kit": "^0.31.10", @@ -54,7 +54,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { - "@ai-sdk/vue": "^3.0.168", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/openai": "^4.0.42", + "@ai-sdk/vue": "^4.0.66", + "@ai-sdk/xai": "^4.0.39", "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", @@ -65,20 +68,19 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "6.38.8", "@lezer/highlight": "^1.2.3", - "@openrouter/ai-sdk-provider": "^2.8.0", + "@openrouter/ai-sdk-provider": "^3.0.0", "@primeuix/themes": "^2.0.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/vue-query": "^5.92.9", "@tanstack/vue-virtual": "^3.13.29", - "@types/dompurify": "^3.2.0", "@types/markdown-it": "^14.1.2", "@vueuse/core": "^14.2.1", - "ai": "^6.0.168", + "ai": "^7.0.66", "codemirror": "^6.0.2", "compare-versions": "^6.1.1", "dayjs": "^1.11.19", - "dompurify": "^3.3.1", - "markdown-it": "^14.1.1", + "dompurify": "^3.4.11", + "markdown-it": "^14.3.0", "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.5.4", @@ -88,7 +90,6 @@ "urlguard": "^1.0.0", "vue": "^3.5.29", "vue-codemirror": "^6.1.1", - "vue-command-palette": "^0.2.3", "vue-router": "^4.6.4", "zod": "^4.3.6", }, @@ -112,33 +113,44 @@ "@codemirror/language": "6.12.2", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.8", - "vite": "npm:rolldown-vite@7.1.12", + "fast-uri": "3.1.3", + "hono": "4.12.28", + "js-yaml": "4.2.0", + "postcss": "8.5.16", + "qs": "6.15.3", + "vite": "npm:rolldown-vite@7.3.1", "zod": "4.3.6", }, "packages": { - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-JAMGtYeEuaBzqbsPO4fkho6vQyNoVhsHASM4o59wmJRU6Vh7prjOp490Kmc7YQTY+ioU1/xYzXvWOtxZBup0Xw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SXUM8jzzuTUJRq+EOgPd5to6DSx0EKslVn+IVZHbUEX6k/3vCPNrvjckbK26HnNxHU/STxm+zTSJteqrO+7Z0w=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.42", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZxDca6jJalYuXrIGVrw6dnkpz1Io9AWy+/b/wVWIbjigHCbd+zWLpPi8NnK0OFU+U3YCpP+KWfUvEnG5pFhltA=="], - "@ai-sdk/vue": ["@ai-sdk/vue@3.0.168", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.23", "ai": "6.0.168", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-HO9s+ufO6h7aDpayAFNkokeLlipUn2zr5UkTojwwy8pdJqh7JYZ56GK6IirHUGzZIr87gbdGIuegQIf5U/XHEQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@ai-sdk/vue": ["@ai-sdk/vue@4.0.66", "", { "dependencies": { "@ai-sdk/provider-utils": "5.0.27", "ai": "7.0.66", "swrv": "^1.2.0" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-FS+Ir4GUzyAV1fFVwUe1HrrEIsB8XKZO71IrV5ONhXv86PH2DUpFWKOxngssVcAWHtZeEwL8u/7L2KHtNChKpg=="], - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@ai-sdk/xai": ["@ai-sdk/xai@4.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aKRm183J0Jnz93MFW+71wfls+aejuYKQCA63vxGYElIrnlv7ziAdT4uX+ZmQxJMAtjoszMuZKAPLd4AApKhOlg=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], - "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], - "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], - "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="], "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], @@ -152,7 +164,7 @@ "@codemirror/language": ["@codemirror/language@6.12.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg=="], - "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.2", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q=="], + "@codemirror/legacy-modes": ["@codemirror/legacy-modes@6.5.3", "", { "dependencies": { "@codemirror/language": "^6.0.0" } }, "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg=="], "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], @@ -168,11 +180,11 @@ "@elysiajs/openapi": ["@elysiajs/openapi@1.4.15", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-HDtGIrWfFJk6UJS7qbgRmjifbSqnnRGDM3CsU/GVJkxLbEVEkNuQDf+Quh9fGbytSrJ8g4+tX9eVjshYhCH29w=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], @@ -248,8 +260,6 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -286,11 +296,15 @@ "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-oDDW/0KMqz4suHVloB9sNv0YyKLGNYf1FTevXH6adDkid5dsmbbcYuiEsbIhpZSZtHa6o5AVjK1jEAfePOLxww=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@3.0.0", "", { "peerDependencies": { "ai": "^7.0.0", "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m9XTSWoODH2RM5OsZpaGiN7QRR8cdP5paBWq699Tu3JVmGPBKT8xF8XwV0ZBVVsjikD/JgWfak4VSsTR4wAVbg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -304,87 +318,87 @@ "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="], - "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.133.0", "", { "os": "android", "cpu": "arm" }, "sha512-l/44caGse+VpnY9gx0yvvc5QnnG3yG1FO3KZgYvNL1GZrfK86zIwAOgGEVlxDyRymzrU/KHiblPFpevKOmJmUA=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.137.0", "", { "os": "android", "cpu": "arm" }, "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA=="], - "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.133.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KUHmPMziLBp4u+zbrLdB7iWS7KshuZe+RAp7ELnY9SI9nNXBZ+dp8fiBqWOxhXqn+FQg3a4UcQhwmsJOKV8Jjg=="], + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.137.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw=="], - "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.133.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q8dWmnU/8ea2tga9w2f1PinQ5rcMPDUGkF64T189b65YMjUomET4oy5oRldOr4AwOQkneOG/Zttnz1Dvrc62wg=="], + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.137.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA=="], - "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.133.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOKeIELIB2bJnCKwqx4Rdj+1Lss/U6uCbLxRySZrhyOOQa1flKhwZFjEHRHxk8fU1NKmhK5OnTdPQ4CpjuFuVw=="], + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.137.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg=="], - "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.133.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-OpaSv4pW3KgFrMYQxTaS0aOE4T1DQF3qZE/4B6uqqv1KgPWWd4UQhJALi8PJPX1RRV5K7ThKXRfF7qGg2+3l1A=="], + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.137.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw=="], - "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JGK1wlGrGwxBIlVSF7KWTX1/ru6BEtf28fRROztDRkLfiW+Kxa4onnriezMIiogfn9hVw2KzYcKiLjkLR2ns8A=="], + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q=="], - "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.133.0", "", { "os": "linux", "cpu": "arm" }, "sha512-yuZO533Ftonxn/iyoqQzURzLQHMspvsIyfiCSNi1t/ER4eIQaR0SsmUOUm5b/lmSig7IWIUa5/BrbEkAPwcilQ=="], + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.137.0", "", { "os": "linux", "cpu": "arm" }, "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ=="], - "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvpbqT5pN2rR+3+xtWeizwfR/aZ0vGceg6TqYMl+ToxMpk9/tmnX7kSvQnfEUkoua8mhogzvIKsAkn0wxgblBA=="], + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w=="], - "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.133.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wJQGamIosQBoJHW9+S5XxrtKRo3eyJxsnS1XCPrqN0LHi8uw1pTqqTfn3t/NVuvbBg7Pumn4ez9Eidgcn0xbEg=="], + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.137.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ=="], - "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.133.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Koaz32/O5+abIfrNGdyndgRvdOZ9jEf5/z3Ep9h3h2QWpdDiUQpVwgH0OcMXCs+l9aXxPLtkupqyVig9W6FDKw=="], + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.137.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ=="], - "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-R4vOjWzxhnNWHnVLeiB6jNuIifdy9vcMXZGPc7StXcxBovI+U2zg1QhZ9o8OjV80oGivs1lX5NfPLzk4IPqlRA=="], + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw=="], - "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.133.0", "", { "os": "linux", "cpu": "none" }, "sha512-iwgBNUTHiMdxARLYuM0SBlnYeb19iw1Ea5M+4ERZupCsBMLArti6FyZ6UfFjJxIiTDr2oW2DGQFxlQVQ/dW9rA=="], + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.137.0", "", { "os": "linux", "cpu": "none" }, "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g=="], - "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.133.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZwZNo8FZmB/gVfboQl+wXilBigGl+6nQQs+nITOeAP/HcAOjiHl6XZJL9F/KXNEspODQcbjAiyjUbeCJd9a0fA=="], + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.137.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA=="], - "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-govCvWx1dBlED3uu4qXctxpRcouu9I8Kn+DBktGCl760JtlGJzc9l/OmPJKlYWSbrRqKkMZehNeZ/4Wfma7uSA=="], + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q=="], - "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.133.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ssTlpXD5Mq9uCssDJPzlRWqBt4Y7Zzd9i+XZhWmK/9Y6KUIuAxVYTYiI8lxcGWi0+3/Cz4A8q9UrD4NK9Y2j7g=="], + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.137.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg=="], - "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.133.0", "", { "os": "none", "cpu": "arm64" }, "sha512-51aByfXhPtLEdWG4a2Ihdw6cPWV1ei1AarALpFdDP8MLWDLE2NuUMgbo3DERR2Kt8fT/ok1GUvBiLxVGke9uUQ=="], + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.137.0", "", { "os": "none", "cpu": "arm64" }, "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA=="], - "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.133.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-2e16tkKp+wDO2GTAmXfxbBcCmGEaFPIJEIRBBmVKNVXSc8/fJsSIaBGyFTPHM9ST5GNWgJcYIt94rDTks+PLwA=="], + "@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.137.0", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA=="], - "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.133.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KPTNDKbxH1cglrqTyVeXHb4Pk4oksz8EcE1/v8zqU7N4UXbiHfA/IwtXZ2U77fnRAWBbgVkl/lZbL7o3hRdejg=="], + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.137.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w=="], - "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.133.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Una1bNYv9zCavQrfnDR9wuZVB3itLjCEH4Oz7i6CwAJN/Xq9b+zbbcxmvdkKvvJt4Ngc/MBmIYlbLo3zS4TQ0A=="], + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.137.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA=="], - "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.133.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kjBhCiOGSYTwDJQuuZa7a94JbP8htWu7J0X1KwH74kV2K5eYf6eyJRYmkpCDvr0XEL8tMxYI4WU1VekblFCLgg=="], + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.137.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.90.0", "", {}, "sha512-TfWn2tT97Weq1/1kTc+6ZeQ3TTj8350HoovtWaUYkX1nie7ONBqeMvudpluj4rmt2jc+l1QsBV/U70Oqsv1S4A=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.101.0", "", {}, "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], - "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.21.3", "", { "os": "android", "cpu": "arm" }, "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg=="], - "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.20.0", "", { "os": "android", "cpu": "arm64" }, "sha512-QqslZAuFQG8Q9xm7JuIn8JUbvywhSBMVhuQHtYW+auirZJloS41oxUUaBXk7uUhZJgp44c5zQLeVvmFaDQB+2Q=="], + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.21.3", "", { "os": "android", "cpu": "arm64" }, "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw=="], - "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.20.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MUcavykj2ewlR+kc5arpg4tC2RvzJkUxWtNv74pf7lcNk00GpIpN43vXMj+j6r4eMmfZhlb8hueKoIb8e9kAGQ=="], + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.21.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg=="], - "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.20.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-BGB16nRUK5Etiv//ihPyzj8Lj1px0mhh4YIfe0FDf045ywknfSm0GEbiRESpr6Q4K82AvnyaRIhhluHByvS4bg=="], + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.21.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg=="], - "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.20.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JZgtePaqj3qmD5XFHJaSLWzHRxQu0LaPkdoM1KJXYADvAaa83ijXHclV3ej3CueeW0wxfIAbGCZVP45J0CA7uQ=="], + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.21.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw=="], - "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-hOQ/p3ry3v3SchUBXicrrnszaI/UmYzM4wtS4RGfwgVUX7a+HbyQSzJ5aOzu+o6XZkFkS3ZXN4PZAzhOb77OSg=="], + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ=="], - "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.20.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2ArPksaw0AqeuGBfoS715VF+JvJQAhD2niWgjE5hVO+L+nAfikVQopvngCMX9x4BD8itWoQ3dnikrQyl5Ho5Jg=="], + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.21.3", "", { "os": "linux", "cpu": "arm" }, "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ=="], - "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0bJnmYFp62JdZ4nVMDUZ/C58BCZOCcqgKtnUlp7L9Ojf/czIN+3j72YlLPeWLkzlr6SlYvIQA4SGV/HyO0d+qg=="], + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ=="], - "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.20.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wKHHzPKZo7Ufhv/Bt6yxT7FOgnIgW4gwXcJUipkShGp68W3wGVqvr1Sr0fY65lN0Oy6y41+g2kIDvkgZaMMUkw=="], + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.21.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw=="], - "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.20.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RN8goF7Ie0B79L4i4G6OeBocTgSC56vJbQ65VJje+oXnldVpLnOU7j/AQ/dP94TcCS+Yh6WG8u3Qt4ETteXFNQ=="], + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.21.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A=="], - "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-5l1yU6/xQEqLZRzxqmMxJfWPslpwCmBsdDGaBvABPehxquCXDC7dd7oraNdKSJUMDXSM7VvVj8H2D2FTjU7oWw=="], + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og=="], - "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.20.0", "", { "os": "linux", "cpu": "none" }, "sha512-xHEvkbgz6UC+A3JOyDQy76LkUaxsNSfIr3/GV8slwZsnuooJiIB34gzJfsyvR4JdCYNUUPsRJc/w/oWkODu+hg=="], + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.21.3", "", { "os": "linux", "cpu": "none" }, "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw=="], - "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.20.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-aWPDUUmSeyHvlW+SoEUd+JIJsQhVhu6a5tBpDRMu058naPAchTgAVGCFy35zjbnFlt0i8hLWziff6HX0D3LU4g=="], + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.21.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q=="], - "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-x2YeSimvhJjKLVD8KSu8f/rqU1potcdEMkApIPJqjZWN7c2Fpt4g2X32WDg1p+XDAmyT7nuQGe0vnhvXeLbH+g=="], + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q=="], - "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.20.0", "", { "os": "linux", "cpu": "x64" }, "sha512-kcRLEIxpZefeYfLChjpgFf3ilBzRDZ+yobMrpRsQlSrxuFGtm3U6PMU7AaEpMqo3NfDGVyJJseAjnRLzMFHjwQ=="], + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.21.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA=="], - "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.20.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HHcfnApSZGtKhTiHqe8OZruOZe5XuFQH5/E0Yhj3u8fnFvzkM4/k6WjacUf4SvA0SPEAbfbgYmVPuo0VX/fIBQ=="], + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.21.3", "", { "os": "none", "cpu": "arm64" }, "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg=="], - "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.20.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-Tn0y1XOFYHNfK1wp1Z5QK8Rcld/bsOwRISQXfqAZ5IBpv8Gz1IvV39fUWNprqNdRizgcvFhOzWwFun2zkJsyBg=="], + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.21.3", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA=="], - "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.20.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPi25YNPe4YenS8MgsQU2+bIFHxxpLx1LVna2444cEHqNPhNjvWf9zqj4aWE43H9LpAsTmkkAlA3eL5ElBU3mA=="], + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.21.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg=="], - "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.20.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Wb14jWEW8huH6It9F6sXd9vrYmIS7pMrgkU6sxpLxkP+9z+wRgs71hUEhRpcn8FOXAFa27FVWfY2tRpbfTzfLw=="], + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.21.3", "", { "os": "win32", "cpu": "x64" }, "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q=="], "@pkgr/core": ["@pkgr/core@0.3.6", "", {}, "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA=="], @@ -400,85 +414,83 @@ "@primevue/icons": ["@primevue/icons@4.5.5", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5" } }, "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.39", "", { "os": "android", "cpu": "arm64" }, "sha512-mjraAJQ3VRLPb3BUgVigHvmAYhiBpEeSM0dhvaO6XHtJ0k1o9Ng1Z6Qvlp4/1wDiUf7a10L5c3yleoGZ2r0Maw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-beta.53", "", { "os": "android", "cpu": "arm64" }, "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.39", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tnuiLq9vd08KsZeFkFgzCXVKsTgSZGn+YBQjHSEiUvXJy5pfUf82X/YyLCG8P6I+WDd2cgrcLilMBQPZgaNwkg=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.39", "", { "os": "darwin", "cpu": "x64" }, "sha512-wLFoB3ZM4AoeBlsP0eVbPzWfkEgvmnibMQEKUgWRfJnKhUWiSxl0kGdSw1fNYdX3KAqIeA5gPJNvSJmf6g5S3Q=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.53", "", { "os": "darwin", "cpu": "x64" }, "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.39", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wzFZlixF9VMbyi++rHCU4Cy72SH11aBNnkadmvwTAbokwjYHi8NqxQ3/Lx00c700N6kwwuiTsbcGt5DEA9aROw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.53", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm" }, "sha512-eVnZcwGbje1uwdFjeQZQ6918RHgGIK7iTC+AoDsgetgAXQmQpnuWYQ9OWa5oTHNQyCkZbMfiHKgpkUPpceMecw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm" }, "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm64" }, "sha512-Td96iRQA0nmRZM6kJ3+LDDKWLh4bl0zqeR+IYxXwPZBw4iXSREzXrcZ3QqgFHqnXPgryIJEW1U1Ebh2xf+b2UA=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.39", "", { "os": "linux", "cpu": "arm64" }, "sha512-bcSIh1TFUoPcexJH+gO1sE6wpSR0j3UpWBnjAwyM1PRKfjtqN4R9Du90ofH5KsR/A35FT3eP4mdnhMDTd5Yt+A=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "arm64" }, "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.39", "", { "os": "linux", "cpu": "x64" }, "sha512-tYEcZdVGovEemh7ELr+VUoezGkuBgRZYvDHHW/HVIw9LQW5HKLtBIGLzFlOfu/Lq5b9FlDKl+lrY6weviaNnKw=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.39", "", { "os": "linux", "cpu": "x64" }, "sha512-xf9QdMC+qwQxtFAty/9RxgCLFdp9pFl09g86hxGPzlzCtHUjd+BmeUnUTXvVC8CHJLWECLQbFP6/233XHG0blA=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.53", "", { "os": "linux", "cpu": "x64" }, "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.39", "", { "os": "none", "cpu": "arm64" }, "sha512-QCvN02VpE6zFYry0zAU+29D5+O9tJELNt+OjuCubilZdD/S8xFdho7qBJaa3YhFYyA9cReOMVH8Z8b3yWb4hcA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-beta.53", "", { "os": "none", "cpu": "arm64" }, "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.39", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.0.5" }, "cpu": "none" }, "sha512-LFgshxApyBNiBHFVpun7tPrIQ4TvxW0f/endC5C4RzEHu7mxexBCQEkO5XrZ42Cr5DUY+ERNbkfNTUv+vVCaxQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.53", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mykirawg+s1e0uzVSEFhUBTShvXrOghPnyuLYkCfw8gzy8bMYiJuxsAfcopzZIIAVOHeSblJoiA/e7gYFjg8HA=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "arm64" }, "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw=="], - "@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "ia32" }, "sha512-4PQJfWx7mdzXbAa4y+3OSSo911BZyJ/Is4pJKiwcGUqtvY66MX7BqlNWMr9QAozArAGE2knDubLqCQwZpK631w=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.53", "", { "os": "win32", "cpu": "x64" }, "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.39", "", { "os": "win32", "cpu": "x64" }, "sha512-0zmmPOWbFfp1g9ofieimHwhuclZMcib0HL52Q+JTRpOHChI2f83TtH3duKWtAaxqhLUndTr/Z5sxzb+G2FNL9g=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.2" } }, "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.2", "@tailwindcss/oxide-darwin-arm64": "4.3.2", "@tailwindcss/oxide-darwin-x64": "4.3.2", "@tailwindcss/oxide-freebsd-x64": "4.3.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", "@tailwindcss/oxide-linux-x64-musl": "4.3.2", "@tailwindcss/oxide-wasm32-wasi": "4.3.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.2", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.2", "", { "dependencies": { "@tailwindcss/node": "4.3.2", "@tailwindcss/oxide": "4.3.2", "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA=="], "@tanstack/match-sorter-utils": ["@tanstack/match-sorter-utils@8.19.4", "", { "dependencies": { "remove-accents": "0.5.0" } }, "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg=="], - "@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.1", "", {}, "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], - "@tanstack/vue-query": ["@tanstack/vue-query@5.99.0", "", { "dependencies": { "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/query-core": "5.99.0", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-okrHNkouasL5cjo7O8hUxul22DiIL335KUQus/cfoAdCOUytF/OIv3BpNDevfqE6zHqqJ651Fb9OkUJPUCL0ZQ=="], + "@tanstack/vue-query": ["@tanstack/vue-query@5.101.2", "", { "dependencies": { "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/query-core": "5.101.2", "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "@vue/composition-api": "^1.1.2", "vue": "^2.6.0 || ^3.3.0" }, "optionalPeers": ["@vue/composition-api"] }, "sha512-nFXKNK4dI/PnKNejyIkQmmobBsJWWXTPcpjPvkgFLkpFPz9DcmqIiKIAF2Pa7MZCipH62/+bt2sxEIA0wNEeOg=="], - "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.29", "", { "dependencies": { "@tanstack/virtual-core": "3.17.1" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ=="], + "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.31", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -488,8 +500,6 @@ "@types/dns-packet": ["@types/dns-packet@5.6.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q=="], - "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -506,25 +516,25 @@ "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/utils": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.63.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/type-utils": "8.63.0", "@typescript-eslint/utils": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.63.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.63.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.63.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.63.0", "@typescript-eslint/types": "^8.63.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1" } }, "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0" } }, "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.63.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0", "@typescript-eslint/utils": "8.63.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.63.0", "", {}, "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.63.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.63.0", "@typescript-eslint/tsconfig-utils": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/visitor-keys": "8.63.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.63.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.63.0", "@typescript-eslint/types": "8.63.0", "@typescript-eslint/typescript-estree": "8.63.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.63.0", "", { "dependencies": { "@typescript-eslint/types": "8.63.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw=="], "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260425.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260425.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260425.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260425.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260425.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260425.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260425.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260425.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-qhSVDT9DsoKPBeEm777eUUkiCDjBFlF7wwjfMvcPctZFVHfD6b1O1icpfCdQHPqzjrSXWu2YaNiY0DXbljTmgw=="], @@ -544,21 +554,21 @@ "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], - "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg=="], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.7", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], - "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], - "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], @@ -566,13 +576,13 @@ "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.39", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.39", "", { "dependencies": { "@vue/compiler-core": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg=="], - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", "@vue/compiler-dom": "3.5.32", "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.39", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.39", "@vue/compiler-dom": "3.5.39", "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw=="], "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], @@ -580,44 +590,40 @@ "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], - "@vue/language-core": ["@vue/language-core@3.2.6", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg=="], + "@vue/language-core": ["@vue/language-core@3.3.7", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw=="], - "@vue/reactivity": ["@vue/reactivity@3.5.32", "", { "dependencies": { "@vue/shared": "3.5.32" } }, "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ=="], + "@vue/reactivity": ["@vue/reactivity@3.5.39", "", { "dependencies": { "@vue/shared": "3.5.39" } }, "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog=="], - "@vue/runtime-core": ["@vue/runtime-core@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ=="], + "@vue/runtime-core": ["@vue/runtime-core@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/shared": "3.5.39" } }, "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw=="], - "@vue/runtime-dom": ["@vue/runtime-dom@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/runtime-core": "3.5.32", "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ=="], + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.39", "", { "dependencies": { "@vue/reactivity": "3.5.39", "@vue/runtime-core": "3.5.39", "@vue/shared": "3.5.39", "csstype": "^3.2.3" } }, "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.32", "", { "dependencies": { "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "vue": "3.5.32" } }, "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.39", "", { "dependencies": { "@vue/compiler-ssr": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "vue": "3.5.39" } }, "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw=="], - "@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="], + "@vue/shared": ["@vue/shared@3.5.39", "", {}, "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="], "@vue/tsconfig": ["@vue/tsconfig@0.9.1", "", { "peerDependencies": { "typescript": ">= 5.8", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w=="], - "@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="], + "@vueuse/core": ["@vueuse/core@14.3.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.3.0", "@vueuse/shared": "14.3.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw=="], - "@vueuse/metadata": ["@vueuse/metadata@14.2.1", "", {}, "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw=="], + "@vueuse/metadata": ["@vueuse/metadata@14.3.0", "", {}, "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw=="], - "@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="], + "@vueuse/shared": ["@vueuse/shared@14.3.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], + "ai": ["ai@7.0.66", "", { "dependencies": { "@ai-sdk/gateway": "4.0.52", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wBUyoCYF3GVr+62nelBgR8YbpTSsMZrzFyOOjiwijylNSM2TFCW35C+Pml2vc59/WLMpyhS/LWZ55M+B9DAcSg=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="], + "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], - "arctic": ["arctic@3.7.0", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -630,22 +636,14 @@ "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -662,20 +660,12 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -684,48 +674,32 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="], - "dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], + "dompurify": ["dompurify@3.4.11", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw=="], "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + "elysia": ["elysia@1.4.29", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-GwMRGGwSdjfPt+w3LA0fqTuYJtS8uVRJicvoar98/HrO5qdFKDc9CwjIb6Kja+v39lkY+58hr2JvdR9jQzlUuA=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + "enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], @@ -752,20 +726,14 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.4.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], - "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -780,8 +748,6 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -792,8 +758,6 @@ "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -802,48 +766,22 @@ "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "frontend": ["frontend@workspace:packages/frontend"], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "fuse.js": ["fuse.js@6.6.2", "", {}, "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], - - "hono": ["hono@4.12.15", "", {}, "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg=="], - "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "http-z": ["http-z@8.1.1", "", {}, "sha512-4rEIu4SljSAs+lgCzzskyNdYllteGIHdnMBsu9MqafivyPAofSmCsrRjHQgxLs0BoPkUJBa7Ld6rXP32SPI8Fg=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -852,18 +790,10 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -872,7 +802,7 @@ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -880,13 +810,11 @@ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - "knip": ["knip@6.16.1", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.133.0", "oxc-resolver": "^11.20.0", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.16", "unbash": "^3.0.0", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-TKMn1rxgH6h9vXR9Y0B+Cq7AdPTr9EI02IwoT65NzqYUkvoDQAaJ/aPybiFpAhZ1px6cNYYwXf86iHkBgzCo9w=="], + "knip": ["knip@6.25.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-Q3n41VjOOB/aqsbxb8kallAcFKrUz3b2S5fD5pTODljVpP01t+rvAgy2x3j0Cq8yEpRRHNdar1vHuqFfGuIakQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -914,7 +842,7 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -922,22 +850,12 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "markdown-it": ["markdown-it@14.3.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw=="], "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], @@ -946,31 +864,21 @@ "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], - "nanoid": ["nanoid@4.0.2", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "oxc-parser": ["oxc-parser@0.133.0", "", { "dependencies": { "@oxc-project/types": "^0.133.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.133.0", "@oxc-parser/binding-android-arm64": "0.133.0", "@oxc-parser/binding-darwin-arm64": "0.133.0", "@oxc-parser/binding-darwin-x64": "0.133.0", "@oxc-parser/binding-freebsd-x64": "0.133.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.133.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.133.0", "@oxc-parser/binding-linux-arm64-gnu": "0.133.0", "@oxc-parser/binding-linux-arm64-musl": "0.133.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.133.0", "@oxc-parser/binding-linux-riscv64-musl": "0.133.0", "@oxc-parser/binding-linux-s390x-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-gnu": "0.133.0", "@oxc-parser/binding-linux-x64-musl": "0.133.0", "@oxc-parser/binding-openharmony-arm64": "0.133.0", "@oxc-parser/binding-wasm32-wasi": "0.133.0", "@oxc-parser/binding-win32-arm64-msvc": "0.133.0", "@oxc-parser/binding-win32-ia32-msvc": "0.133.0", "@oxc-parser/binding-win32-x64-msvc": "0.133.0" } }, "sha512-661RSx+ZcjBmjBYid+Fpp/2F5EbtildpeoZh5HdgnGs+jZ03nqQEQW8yGkt4BGyOC3OMPDQQRl8M5kqD2/g6jw=="], + "oxc-parser": ["oxc-parser@0.137.0", "", { "dependencies": { "@oxc-project/types": "^0.137.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.137.0", "@oxc-parser/binding-android-arm64": "0.137.0", "@oxc-parser/binding-darwin-arm64": "0.137.0", "@oxc-parser/binding-darwin-x64": "0.137.0", "@oxc-parser/binding-freebsd-x64": "0.137.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0", "@oxc-parser/binding-linux-arm64-gnu": "0.137.0", "@oxc-parser/binding-linux-arm64-musl": "0.137.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0", "@oxc-parser/binding-linux-riscv64-musl": "0.137.0", "@oxc-parser/binding-linux-s390x-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-gnu": "0.137.0", "@oxc-parser/binding-linux-x64-musl": "0.137.0", "@oxc-parser/binding-openharmony-arm64": "0.137.0", "@oxc-parser/binding-wasm32-wasi": "0.137.0", "@oxc-parser/binding-win32-arm64-msvc": "0.137.0", "@oxc-parser/binding-win32-ia32-msvc": "0.137.0", "@oxc-parser/binding-win32-x64-msvc": "0.137.0" } }, "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg=="], - "oxc-resolver": ["oxc-resolver@11.20.0", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.20.0", "@oxc-resolver/binding-android-arm64": "11.20.0", "@oxc-resolver/binding-darwin-arm64": "11.20.0", "@oxc-resolver/binding-darwin-x64": "11.20.0", "@oxc-resolver/binding-freebsd-x64": "11.20.0", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.20.0", "@oxc-resolver/binding-linux-arm-musleabihf": "11.20.0", "@oxc-resolver/binding-linux-arm64-gnu": "11.20.0", "@oxc-resolver/binding-linux-arm64-musl": "11.20.0", "@oxc-resolver/binding-linux-ppc64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-gnu": "11.20.0", "@oxc-resolver/binding-linux-riscv64-musl": "11.20.0", "@oxc-resolver/binding-linux-s390x-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-gnu": "11.20.0", "@oxc-resolver/binding-linux-x64-musl": "11.20.0", "@oxc-resolver/binding-openharmony-arm64": "11.20.0", "@oxc-resolver/binding-wasm32-wasi": "11.20.0", "@oxc-resolver/binding-win32-arm64-msvc": "11.20.0", "@oxc-resolver/binding-win32-x64-msvc": "11.20.0" } }, "sha512-CblytBiV/a/ZXY34dsVU2NxhIOxMXst8CvDCtyBelVITgd7PLrKzbEbA6oKLdPjvDKDzCiW48qzmzZ+mYaqn+g=="], + "oxc-resolver": ["oxc-resolver@11.21.3", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.21.3", "@oxc-resolver/binding-android-arm64": "11.21.3", "@oxc-resolver/binding-darwin-arm64": "11.21.3", "@oxc-resolver/binding-darwin-x64": "11.21.3", "@oxc-resolver/binding-freebsd-x64": "11.21.3", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", "@oxc-resolver/binding-linux-x64-musl": "11.21.3", "@oxc-resolver/binding-openharmony-arm64": "11.21.3", "@oxc-resolver/binding-wasm32-wasi": "11.21.3", "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" } }, "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -978,16 +886,12 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], @@ -1000,13 +904,13 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="], @@ -1014,56 +918,28 @@ "primevue": ["primevue@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.3", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5", "@primevue/icons": "4.5.5" } }, "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], - "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "remove-accents": ["remove-accents@0.5.0", "", {}, "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], - "rolldown": ["rolldown@1.0.0-beta.39", "", { "dependencies": { "@oxc-project/types": "=0.90.0", "@rolldown/pluginutils": "1.0.0-beta.39", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.39", "@rolldown/binding-darwin-arm64": "1.0.0-beta.39", "@rolldown/binding-darwin-x64": "1.0.0-beta.39", "@rolldown/binding-freebsd-x64": "1.0.0-beta.39", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.39", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.39", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.39", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.39", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.39", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.39", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.39", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.39", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.39", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.39" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-05bTT0CJU9dvCRC0Uc4zwB79W5N9MV9OG/Inyx8KNE2pSrrApJoWxEEArW6rmjx113HIx5IreCoTjzLfgvXTdg=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "rolldown": ["rolldown@1.0.0-beta.53", "", { "dependencies": { "@oxc-project/types": "=0.101.0", "@rolldown/pluginutils": "1.0.0-beta.53" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-arm64": "1.0.0-beta.53", "@rolldown/binding-darwin-x64": "1.0.0-beta.53", "@rolldown/binding-freebsd-x64": "1.0.0-beta.53", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.53", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.53", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.53", "@rolldown/binding-openharmony-arm64": "1.0.0-beta.53", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.53", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.53", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.53" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shared": ["shared@workspace:packages/shared"], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1080,8 +956,6 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], @@ -1098,22 +972,20 @@ "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], - "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + "tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="], "tailwindcss-primeui": ["tailwindcss-primeui@0.6.1", "", { "peerDependencies": { "tailwindcss": ">=3.1.0" } }, "sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A=="], - "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -1124,19 +996,17 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="], + "unbash": ["unbash@4.0.2", "", {}, "sha512-8gwNZ29+0/3zmXw7ToIHZtg6wK37xnniRUdBt7B27xZxaxfgR5tGMaGHT0t0dLtBV9fXE7zurh0s6Z1DHVjfWg=="], - "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -1144,27 +1014,23 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "vite": ["rolldown-vite@7.1.12", "", { "dependencies": { "@oxc-project/runtime": "0.90.0", "fdir": "^6.5.0", "lightningcss": "^1.30.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.39", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.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" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-JREtUS+Lpa3s5Ha3ajf2F4LMS4BFxlVjpGz0k0ZR8rV3ZO3tzk5hukqyi9yRBcrvnTUg/BEForyCDahALFYAZA=="], + "vite": ["rolldown-vite@7.3.1", "", { "dependencies": { "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-beta.53", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.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" }, "optionalPeers": ["@types/node", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w=="], - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="], + "vue": ["vue@3.5.39", "", { "dependencies": { "@vue/compiler-dom": "3.5.39", "@vue/compiler-sfc": "3.5.39", "@vue/runtime-dom": "3.5.39", "@vue/server-renderer": "3.5.39", "@vue/shared": "3.5.39" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA=="], "vue-codemirror": ["vue-codemirror@6.1.1", "", { "dependencies": { "@codemirror/commands": "6.x", "@codemirror/language": "6.x", "@codemirror/state": "6.x", "@codemirror/view": "6.x" }, "peerDependencies": { "codemirror": "6.x", "vue": "3.x" } }, "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg=="], - "vue-command-palette": ["vue-command-palette@0.2.3", "", { "dependencies": { "fuse.js": "^6.6.2", "mitt": "^3.0.0", "nanoid": "^4.0.0" } }, "sha512-5ibmwN0BPscAhPPZLDD5KbbxfuNgdR9E/tkjGnw3JDwMGkL1dBcCqVC3rGrcWgK4QanLq6/Xh0a/RtdsZyU+Hg=="], - "vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], "vue-eslint-parser": ["vue-eslint-parser@10.4.1", "", { "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", "eslint-visitor-keys": "^4.2.0 || ^5.0.0", "espree": "^10.3.0 || ^11.0.0", "esquery": "^1.6.0", "semver": "^7.6.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" } }, "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA=="], "vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], - "vue-tsc": ["vue-tsc@3.2.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q=="], + "vue-tsc": ["vue-tsc@3.3.7", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.7" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], @@ -1176,8 +1042,6 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "xml-name-validator": ["xml-name-validator@4.0.0", "", {}, "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -1186,7 +1050,11 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@codemirror/lang-css/@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/lang-html/@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/lang-javascript/@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -1200,28 +1068,30 @@ "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], - "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], - "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], - "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + "@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -1230,26 +1100,24 @@ "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "knip/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "pinia/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "rolldown/@oxc-project/types": ["@oxc-project/types@0.90.0", "", {}, "sha512-fWvaufWUcLtm/OBKcNmxUkR0kQW5ZKAF0t03BXPqdzpxmnVCmSKzvUDRCOKnSagSfNzG/3ZdKpComH3GMy881g=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.101.0", "", {}, "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.39", "", {}, "sha512-GkTtNCV8ObWbq3LrJStPBv9jkRPct8WlwotVjx3aU0RwfH3LyheixWK9Zhaj22C4EQj/TJxYyetoX+uOn/MWKw=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "tsx/get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + "vitest/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "vue-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -1296,11 +1164,7 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/knip.config.ts b/knip.config.ts index 2d43588..8fb4c01 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -16,12 +16,7 @@ const config: KnipConfig = { vue: { enabled: true, }, - ignore: ["**/*.d.ts"], - ignoreDependencies: [ - "@types/dompurify", - "tailwindcss", - "tailwindcss-primeui", - ], + ignoreDependencies: ["tailwindcss", "tailwindcss-primeui"], }; export default config; diff --git a/package.json b/package.json index 8fe4359..86d2e05 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,15 @@ "picocolors": "^1.1.1" }, "overrides": { - "vite": "npm:rolldown-vite@7.1.12", + "vite": "npm:rolldown-vite@7.3.1", "@codemirror/language": "6.12.2", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.8", + "fast-uri": "3.1.3", + "hono": "4.12.28", + "js-yaml": "4.2.0", + "postcss": "8.5.16", + "qs": "6.15.3", "zod": "4.3.6" } } diff --git a/packages/backend/drizzle/0003_omniscient_maximus.sql b/packages/backend/drizzle/0003_omniscient_maximus.sql new file mode 100644 index 0000000..c527bbc --- /dev/null +++ b/packages/backend/drizzle/0003_omniscient_maximus.sql @@ -0,0 +1,20 @@ +DELETE FROM `instanceWebhooks` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `kind` = 'dynamic' OR `raw` IS NULL);--> statement-breakpoint +DELETE FROM `logs` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `kind` = 'dynamic' OR `raw` IS NULL);--> statement-breakpoint +DELETE FROM `instanceModerations` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `kind` = 'dynamic' OR `raw` IS NULL);--> statement-breakpoint +DELETE FROM `instances` WHERE `kind` = 'dynamic' OR `raw` IS NULL;--> statement-breakpoint +CREATE TABLE `__new_instances` ( + `id` text PRIMARY KEY NOT NULL, + `ownerId` text NOT NULL, + `createdAt` integer NOT NULL, + `expiresAt` integer, + `label` text, + `isPublic` integer NOT NULL, + `isLocked` integer NOT NULL, + `raw` text NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_instances`("id", "ownerId", "createdAt", "expiresAt", "label", "isPublic", "isLocked", "raw") SELECT "id", "ownerId", "createdAt", "expiresAt", "label", "isPublic", "isLocked", "raw" FROM `instances`;--> statement-breakpoint +DROP TABLE `instances`;--> statement-breakpoint +ALTER TABLE `__new_instances` RENAME TO `instances`;--> statement-breakpoint +CREATE INDEX `instances_by_owner` ON `instances` (`ownerId`);--> statement-breakpoint +CREATE INDEX `instances_by_expiration` ON `instances` (`expiresAt`); diff --git a/packages/backend/drizzle/0004_common_retro_girl.sql b/packages/backend/drizzle/0004_common_retro_girl.sql new file mode 100644 index 0000000..be3bf68 --- /dev/null +++ b/packages/backend/drizzle/0004_common_retro_girl.sql @@ -0,0 +1 @@ +DROP INDEX `instanceWebhooks_by_instance`; diff --git a/packages/backend/drizzle/0005_brown_aqueduct.sql b/packages/backend/drizzle/0005_brown_aqueduct.sql new file mode 100644 index 0000000..5397ce3 --- /dev/null +++ b/packages/backend/drizzle/0005_brown_aqueduct.sql @@ -0,0 +1,9 @@ +DELETE FROM `instanceWebhooks` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `logs` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `instanceModerations` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `instances` WHERE `ownerId` = 'guest';--> statement-breakpoint +CREATE TABLE `guestInstanceCredentials` ( + `instanceId` text PRIMARY KEY NOT NULL, + `tokenHash` text NOT NULL, + FOREIGN KEY (`instanceId`) REFERENCES `instances`(`id`) ON UPDATE no action ON DELETE cascade +); diff --git a/packages/backend/drizzle/0006_groovy_the_liberteens.sql b/packages/backend/drizzle/0006_groovy_the_liberteens.sql new file mode 100644 index 0000000..f1a7046 --- /dev/null +++ b/packages/backend/drizzle/0006_groovy_the_liberteens.sql @@ -0,0 +1,5 @@ +DELETE FROM `instanceWebhooks` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `logs` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `instanceModerations` WHERE `instanceId` IN (SELECT `id` FROM `instances` WHERE `ownerId` = 'guest');--> statement-breakpoint +DELETE FROM `instances` WHERE `ownerId` = 'guest';--> statement-breakpoint +DROP TABLE `guestInstanceCredentials`; diff --git a/packages/backend/drizzle/meta/0003_snapshot.json b/packages/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..3c69a5d --- /dev/null +++ b/packages/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,583 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "658a85e6-4c05-4e5a-bbe2-a38300ddb275", + "prevId": "d041d703-56d8-4495-905b-c83c6b7e2a2c", + "tables": { + "apiKeys": { + "name": "apiKeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretHash": { + "name": "secretHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopesJson": { + "name": "scopesJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apiKeys_prefix_unique": { + "name": "apiKeys_prefix_unique", + "columns": ["prefix"], + "isUnique": true + }, + "apiKeys_by_user_createdAt": { + "name": "apiKeys_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "apiKeys_userId_users_id_fk": { + "name": "apiKeys_userId_users_id_fk", + "tableFrom": "apiKeys", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceModerations": { + "name": "instanceModerations", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window5mStartMs": { + "name": "window5mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow5m": { + "name": "requestsInWindow5m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeCommittedForWindow": { + "name": "strikeCommittedForWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeTimestampsJson": { + "name": "strikeTimestampsJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discordMutedUntilMs": { + "name": "discordMutedUntilMs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window15mStartMs": { + "name": "window15mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow15m": { + "name": "requestsInWindow15m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastMinuteBucketStartMs": { + "name": "lastMinuteBucketStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInCurrentMinute": { + "name": "requestsInCurrentMinute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "instanceModerations_instanceId_instances_id_fk": { + "name": "instanceModerations_instanceId_instances_id_fk", + "tableFrom": "instanceModerations", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceWebhooks": { + "name": "instanceWebhooks", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhookId": { + "name": "webhookId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instanceWebhooks_instance_webhook_unique": { + "name": "instanceWebhooks_instance_webhook_unique", + "columns": ["instanceId", "webhookId"], + "isUnique": true + }, + "instanceWebhooks_by_instance": { + "name": "instanceWebhooks_by_instance", + "columns": ["instanceId", "position"], + "isUnique": false + } + }, + "foreignKeys": { + "instanceWebhooks_instanceId_instances_id_fk": { + "name": "instanceWebhooks_instanceId_instances_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "instanceWebhooks_webhookId_webhooks_id_fk": { + "name": "instanceWebhooks_webhookId_webhooks_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "webhooks", + "columnsFrom": ["webhookId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "instanceWebhooks_instanceId_position_pk": { + "columns": ["instanceId", "position"], + "name": "instanceWebhooks_instanceId_position_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instances": { + "name": "instances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isPublic": { + "name": "isPublic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isLocked": { + "name": "isLocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instances_by_owner": { + "name": "instances_by_owner", + "columns": ["ownerId"], + "isUnique": false + }, + "instances_by_expiration": { + "name": "instances_by_expiration", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "logs": { + "name": "logs", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "logs_id_unique": { + "name": "logs_id_unique", + "columns": ["id"], + "isUnique": true + }, + "logs_by_instance_timestamp_id": { + "name": "logs_by_instance_timestamp_id", + "columns": ["instanceId", "timestamp", "id"], + "isUnique": false + }, + "logs_by_instance_seq": { + "name": "logs_by_instance_seq", + "columns": ["instanceId", "seq"], + "isUnique": false + } + }, + "foreignKeys": { + "logs_instanceId_instances_id_fk": { + "name": "logs_instanceId_instances_id_fk", + "tableFrom": "logs", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userNotices": { + "name": "userNotices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acknowledgedAt": { + "name": "acknowledgedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userNotices_by_user_createdAt": { + "name": "userNotices_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_googleId_unique": { + "name": "users_googleId_unique", + "columns": ["googleId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webhooks_by_owner": { + "name": "webhooks_by_owner", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/backend/drizzle/meta/0004_snapshot.json b/packages/backend/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..f17dbd4 --- /dev/null +++ b/packages/backend/drizzle/meta/0004_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6fab688c-fb78-422d-b2fe-4a2dd14c656b", + "prevId": "658a85e6-4c05-4e5a-bbe2-a38300ddb275", + "tables": { + "apiKeys": { + "name": "apiKeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretHash": { + "name": "secretHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopesJson": { + "name": "scopesJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apiKeys_prefix_unique": { + "name": "apiKeys_prefix_unique", + "columns": ["prefix"], + "isUnique": true + }, + "apiKeys_by_user_createdAt": { + "name": "apiKeys_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "apiKeys_userId_users_id_fk": { + "name": "apiKeys_userId_users_id_fk", + "tableFrom": "apiKeys", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceModerations": { + "name": "instanceModerations", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window5mStartMs": { + "name": "window5mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow5m": { + "name": "requestsInWindow5m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeCommittedForWindow": { + "name": "strikeCommittedForWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeTimestampsJson": { + "name": "strikeTimestampsJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discordMutedUntilMs": { + "name": "discordMutedUntilMs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window15mStartMs": { + "name": "window15mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow15m": { + "name": "requestsInWindow15m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastMinuteBucketStartMs": { + "name": "lastMinuteBucketStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInCurrentMinute": { + "name": "requestsInCurrentMinute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "instanceModerations_instanceId_instances_id_fk": { + "name": "instanceModerations_instanceId_instances_id_fk", + "tableFrom": "instanceModerations", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceWebhooks": { + "name": "instanceWebhooks", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhookId": { + "name": "webhookId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instanceWebhooks_instance_webhook_unique": { + "name": "instanceWebhooks_instance_webhook_unique", + "columns": ["instanceId", "webhookId"], + "isUnique": true + } + }, + "foreignKeys": { + "instanceWebhooks_instanceId_instances_id_fk": { + "name": "instanceWebhooks_instanceId_instances_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "instanceWebhooks_webhookId_webhooks_id_fk": { + "name": "instanceWebhooks_webhookId_webhooks_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "webhooks", + "columnsFrom": ["webhookId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "instanceWebhooks_instanceId_position_pk": { + "columns": ["instanceId", "position"], + "name": "instanceWebhooks_instanceId_position_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instances": { + "name": "instances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isPublic": { + "name": "isPublic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isLocked": { + "name": "isLocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instances_by_owner": { + "name": "instances_by_owner", + "columns": ["ownerId"], + "isUnique": false + }, + "instances_by_expiration": { + "name": "instances_by_expiration", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "logs": { + "name": "logs", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "logs_id_unique": { + "name": "logs_id_unique", + "columns": ["id"], + "isUnique": true + }, + "logs_by_instance_timestamp_id": { + "name": "logs_by_instance_timestamp_id", + "columns": ["instanceId", "timestamp", "id"], + "isUnique": false + }, + "logs_by_instance_seq": { + "name": "logs_by_instance_seq", + "columns": ["instanceId", "seq"], + "isUnique": false + } + }, + "foreignKeys": { + "logs_instanceId_instances_id_fk": { + "name": "logs_instanceId_instances_id_fk", + "tableFrom": "logs", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userNotices": { + "name": "userNotices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acknowledgedAt": { + "name": "acknowledgedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userNotices_by_user_createdAt": { + "name": "userNotices_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_googleId_unique": { + "name": "users_googleId_unique", + "columns": ["googleId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webhooks_by_owner": { + "name": "webhooks_by_owner", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/backend/drizzle/meta/0005_snapshot.json b/packages/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..898966d --- /dev/null +++ b/packages/backend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,612 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3e04c607-b965-45cf-abd2-7c51162a5917", + "prevId": "6fab688c-fb78-422d-b2fe-4a2dd14c656b", + "tables": { + "apiKeys": { + "name": "apiKeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretHash": { + "name": "secretHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopesJson": { + "name": "scopesJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apiKeys_prefix_unique": { + "name": "apiKeys_prefix_unique", + "columns": ["prefix"], + "isUnique": true + }, + "apiKeys_by_user_createdAt": { + "name": "apiKeys_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "apiKeys_userId_users_id_fk": { + "name": "apiKeys_userId_users_id_fk", + "tableFrom": "apiKeys", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "guestInstanceCredentials": { + "name": "guestInstanceCredentials", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "guestInstanceCredentials_instanceId_instances_id_fk": { + "name": "guestInstanceCredentials_instanceId_instances_id_fk", + "tableFrom": "guestInstanceCredentials", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceModerations": { + "name": "instanceModerations", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window5mStartMs": { + "name": "window5mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow5m": { + "name": "requestsInWindow5m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeCommittedForWindow": { + "name": "strikeCommittedForWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeTimestampsJson": { + "name": "strikeTimestampsJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discordMutedUntilMs": { + "name": "discordMutedUntilMs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window15mStartMs": { + "name": "window15mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow15m": { + "name": "requestsInWindow15m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastMinuteBucketStartMs": { + "name": "lastMinuteBucketStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInCurrentMinute": { + "name": "requestsInCurrentMinute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "instanceModerations_instanceId_instances_id_fk": { + "name": "instanceModerations_instanceId_instances_id_fk", + "tableFrom": "instanceModerations", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceWebhooks": { + "name": "instanceWebhooks", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhookId": { + "name": "webhookId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instanceWebhooks_instance_webhook_unique": { + "name": "instanceWebhooks_instance_webhook_unique", + "columns": ["instanceId", "webhookId"], + "isUnique": true + } + }, + "foreignKeys": { + "instanceWebhooks_instanceId_instances_id_fk": { + "name": "instanceWebhooks_instanceId_instances_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "instanceWebhooks_webhookId_webhooks_id_fk": { + "name": "instanceWebhooks_webhookId_webhooks_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "webhooks", + "columnsFrom": ["webhookId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "instanceWebhooks_instanceId_position_pk": { + "columns": ["instanceId", "position"], + "name": "instanceWebhooks_instanceId_position_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instances": { + "name": "instances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isPublic": { + "name": "isPublic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isLocked": { + "name": "isLocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instances_by_owner": { + "name": "instances_by_owner", + "columns": ["ownerId"], + "isUnique": false + }, + "instances_by_expiration": { + "name": "instances_by_expiration", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "logs": { + "name": "logs", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "logs_id_unique": { + "name": "logs_id_unique", + "columns": ["id"], + "isUnique": true + }, + "logs_by_instance_timestamp_id": { + "name": "logs_by_instance_timestamp_id", + "columns": ["instanceId", "timestamp", "id"], + "isUnique": false + }, + "logs_by_instance_seq": { + "name": "logs_by_instance_seq", + "columns": ["instanceId", "seq"], + "isUnique": false + } + }, + "foreignKeys": { + "logs_instanceId_instances_id_fk": { + "name": "logs_instanceId_instances_id_fk", + "tableFrom": "logs", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userNotices": { + "name": "userNotices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acknowledgedAt": { + "name": "acknowledgedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userNotices_by_user_createdAt": { + "name": "userNotices_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_googleId_unique": { + "name": "users_googleId_unique", + "columns": ["googleId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webhooks_by_owner": { + "name": "webhooks_by_owner", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/backend/drizzle/meta/0006_snapshot.json b/packages/backend/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..02be933 --- /dev/null +++ b/packages/backend/drizzle/meta/0006_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "89b595b5-5d15-40c8-aae9-f36149395574", + "prevId": "3e04c607-b965-45cf-abd2-7c51162a5917", + "tables": { + "apiKeys": { + "name": "apiKeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secretHash": { + "name": "secretHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopesJson": { + "name": "scopesJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "apiKeys_prefix_unique": { + "name": "apiKeys_prefix_unique", + "columns": ["prefix"], + "isUnique": true + }, + "apiKeys_by_user_createdAt": { + "name": "apiKeys_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": { + "apiKeys_userId_users_id_fk": { + "name": "apiKeys_userId_users_id_fk", + "tableFrom": "apiKeys", + "tableTo": "users", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceModerations": { + "name": "instanceModerations", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window5mStartMs": { + "name": "window5mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow5m": { + "name": "requestsInWindow5m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeCommittedForWindow": { + "name": "strikeCommittedForWindow", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "strikeTimestampsJson": { + "name": "strikeTimestampsJson", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "discordMutedUntilMs": { + "name": "discordMutedUntilMs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "window15mStartMs": { + "name": "window15mStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInWindow15m": { + "name": "requestsInWindow15m", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lastMinuteBucketStartMs": { + "name": "lastMinuteBucketStartMs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requestsInCurrentMinute": { + "name": "requestsInCurrentMinute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "instanceModerations_instanceId_instances_id_fk": { + "name": "instanceModerations_instanceId_instances_id_fk", + "tableFrom": "instanceModerations", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instanceWebhooks": { + "name": "instanceWebhooks", + "columns": { + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhookId": { + "name": "webhookId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instanceWebhooks_instance_webhook_unique": { + "name": "instanceWebhooks_instance_webhook_unique", + "columns": ["instanceId", "webhookId"], + "isUnique": true + } + }, + "foreignKeys": { + "instanceWebhooks_instanceId_instances_id_fk": { + "name": "instanceWebhooks_instanceId_instances_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "instanceWebhooks_webhookId_webhooks_id_fk": { + "name": "instanceWebhooks_webhookId_webhooks_id_fk", + "tableFrom": "instanceWebhooks", + "tableTo": "webhooks", + "columnsFrom": ["webhookId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "instanceWebhooks_instanceId_position_pk": { + "columns": ["instanceId", "position"], + "name": "instanceWebhooks_instanceId_position_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instances": { + "name": "instances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isPublic": { + "name": "isPublic", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isLocked": { + "name": "isLocked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instances_by_owner": { + "name": "instances_by_owner", + "columns": ["ownerId"], + "isUnique": false + }, + "instances_by_expiration": { + "name": "instances_by_expiration", + "columns": ["expiresAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "logs": { + "name": "logs", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw": { + "name": "raw", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "logs_id_unique": { + "name": "logs_id_unique", + "columns": ["id"], + "isUnique": true + }, + "logs_by_instance_timestamp_id": { + "name": "logs_by_instance_timestamp_id", + "columns": ["instanceId", "timestamp", "id"], + "isUnique": false + }, + "logs_by_instance_seq": { + "name": "logs_by_instance_seq", + "columns": ["instanceId", "seq"], + "isUnique": false + } + }, + "foreignKeys": { + "logs_instanceId_instances_id_fk": { + "name": "logs_instanceId_instances_id_fk", + "tableFrom": "logs", + "tableTo": "instances", + "columnsFrom": ["instanceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "userNotices": { + "name": "userNotices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instanceId": { + "name": "instanceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "acknowledgedAt": { + "name": "acknowledgedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userNotices_by_user_createdAt": { + "name": "userNotices_by_user_createdAt", + "columns": ["userId", "createdAt"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "googleId": { + "name": "googleId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_googleId_unique": { + "name": "users_googleId_unique", + "columns": ["googleId"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webhooks_by_owner": { + "name": "webhooks_by_owner", + "columns": ["ownerId"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json index 04a33ff..b2319b8 100644 --- a/packages/backend/drizzle/meta/_journal.json +++ b/packages/backend/drizzle/meta/_journal.json @@ -22,6 +22,34 @@ "when": 1777119692214, "tag": "0002_rapid_nomad", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1783639493291, + "tag": "0003_omniscient_maximus", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1783642398653, + "tag": "0004_common_retro_girl", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1783644024115, + "tag": "0005_brown_aqueduct", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1786815009282, + "tag": "0006_groovy_the_liberteens", + "breakpoints": true } ] } diff --git a/packages/backend/package.json b/packages/backend/package.json index 0ff3c2f..671ef02 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,6 +4,7 @@ "type": "module", "private": true, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/bun": "latest", "@types/dns-packet": "^5.6.5", "drizzle-kit": "^0.31.10" @@ -14,17 +15,15 @@ "scripts": { "dev": "bun --env-file=../../.env src/index.ts", "typecheck": "tsgo --noEmit", - "drizzle:generate": "drizzle-kit generate --config drizzle.config.ts", - "migrate:sqlite": "bun --env-file=../../.env src/storage/importLegacyDb.ts" + "drizzle:generate": "drizzle-kit generate --config drizzle.config.ts" }, "dependencies": { "@elysiajs/openapi": "^1.4.15", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "^2.0.0", "arctic": "^3.7.0", "dns-packet": "^5.6.1", "drizzle-orm": "^0.45.2", "elysia": "^1.4.28", - "http-z": "^8.1.1", "jose": "^6.2.3", "shared": "workspace:*", "urlguard": "^1.0.0", diff --git a/packages/backend/src/config/abuse.ts b/packages/backend/src/config/abuse.ts index 46e799e..9774247 100644 --- a/packages/backend/src/config/abuse.ts +++ b/packages/backend/src/config/abuse.ts @@ -2,6 +2,7 @@ const minuteMs = 60_000; export const abusePolicy = { discordMuteThresholdPerMinute: 20, + discordWebhookDebounceMs: 3_000, discordWebhookLimitPerMinutePerInstance: 5, discordMuteDurationMs: 5 * minuteMs, strikeRequestThreshold5m: 130, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 5c49a36..2962a6e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,18 +1,10 @@ import { initServer } from "./server"; -import { closeDb, initDb, maybeAutoImportLegacyDb } from "./storage"; +import { closeDb, initDb } from "./storage"; import { version } from "./version"; async function init() { console.log("init", version); - const importResult = await maybeAutoImportLegacyDb(); - if (importResult.kind === "imported") { - console.log( - "Imported legacy JSON database into SQLite", - importResult.result, - ); - } - const result = initDb(); if (result.kind === "error") { console.error("Failed to initialize database", result.error); diff --git a/packages/backend/src/server/ai/chatGpt.spec.ts b/packages/backend/src/server/ai/chatGpt.spec.ts new file mode 100644 index 0000000..3dc17fb --- /dev/null +++ b/packages/backend/src/server/ai/chatGpt.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, mock, test } from "bun:test"; +import { ChatGptProxyService, normalizeChatGptRequest } from "./chatGpt"; + +describe("ChatGPT Codex adapter", () => { + test("normalizes the AI SDK Responses payload for ChatGPT", () => { + expect( + normalizeChatGptRequest({ + model: "gpt-5.6-sol", + input: [], + include: ["web_search_call.action.sources"], + max_output_tokens: 4096, + store: true, + }), + ).toEqual({ + model: "gpt-5.6-sol", + input: [], + instructions: "You are a helpful assistant.", + include: [ + "web_search_call.action.sources", + "reasoning.encrypted_content", + ], + store: false, + stream: true, + }); + }); + + test("forwards credentials and streams the upstream response", async () => { + let request: + { url: string; init?: Parameters[1] } | undefined; + const fetchImpl = mock( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + request = { + url: input instanceof Request ? input.url : input.toString(), + init, + }; + return Promise.resolve( + new Response("data: done\n\n", { + headers: { "content-type": "text/event-stream" }, + }), + ); + }, + ) as unknown as typeof fetch; + const service = new ChatGptProxyService(fetchImpl); + + const response = await service.forward({ + body: { model: "gpt-5.6-sol", input: [], instructions: "Help" }, + accessToken: "access-1", + accountId: "account-1", + sessionId: "session-1", + }); + + expect(request?.url).toBe( + "https://chatgpt.com/backend-api/codex/responses", + ); + const headers = new Headers(request?.init?.headers); + expect(headers.get("authorization")).toBe("Bearer access-1"); + expect(headers.get("chatgpt-account-id")).toBe("account-1"); + expect(headers.get("session-id")).toBe("session-1"); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(await response.text()).toBe("data: done\n\n"); + }); +}); diff --git a/packages/backend/src/server/ai/chatGpt.ts b/packages/backend/src/server/ai/chatGpt.ts new file mode 100644 index 0000000..f065351 --- /dev/null +++ b/packages/backend/src/server/ai/chatGpt.ts @@ -0,0 +1,88 @@ +const CHATGPT_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; + +type JsonRecord = Record; + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const normalizeChatGptRequest = (body: unknown) => { + if (!isRecord(body)) { + throw new Error("Invalid ChatGPT request body"); + } + + const include = Array.isArray(body.include) + ? body.include.filter((value): value is string => typeof value === "string") + : []; + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + + const normalized: JsonRecord = { + ...body, + instructions: + typeof body.instructions === "string" && body.instructions.trim() !== "" + ? body.instructions + : "You are a helpful assistant.", + include, + store: false, + stream: true, + }; + delete normalized.max_output_tokens; + return normalized; +}; + +export class ChatGptProxyService { + constructor(private readonly fetchImpl: typeof globalThis.fetch = fetch) {} + + async forward({ + body, + accessToken, + accountId, + sessionId, + signal, + }: { + body: unknown; + accessToken: string; + accountId: string; + sessionId?: string; + signal?: AbortSignal; + }) { + const headers = new Headers({ + Accept: "text/event-stream", + Authorization: `Bearer ${accessToken}`, + "ChatGPT-Account-Id": accountId, + "Content-Type": "application/json", + "OpenAI-Beta": "responses=experimental", + originator: "httpworkbench", + "User-Agent": "httpworkbench", + }); + if (sessionId !== undefined && sessionId !== "") { + headers.set("session-id", sessionId); + } + + const response = await this.fetchImpl(CHATGPT_RESPONSES_URL, { + method: "POST", + headers, + body: JSON.stringify(normalizeChatGptRequest(body)), + signal, + }); + + const responseHeaders = new Headers(); + for (const name of [ + "content-type", + "openai-processing-ms", + "x-request-id", + ]) { + const value = response.headers.get(name); + if (value !== null) { + responseHeaders.set(name, value); + } + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } +} diff --git a/packages/backend/src/server/ai/oauth.spec.ts b/packages/backend/src/server/ai/oauth.spec.ts new file mode 100644 index 0000000..53910f0 --- /dev/null +++ b/packages/backend/src/server/ai/oauth.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, mock, test } from "bun:test"; +import { AiOAuthService } from "./oauth"; + +const json = (body: unknown, status = 200) => Response.json(body, { status }); + +const jwt = (claims: Record) => + [ + Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"), + Buffer.from(JSON.stringify(claims)).toString("base64url"), + "signature", + ].join("."); + +const queuedFetch = (responses: Response[]) => { + const requests: Array<{ + url: string; + init?: Parameters[1]; + }> = []; + const fetchImpl = mock( + ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push({ + url: input instanceof Request ? input.url : input.toString(), + init, + }); + const response = responses.shift(); + if (response === undefined) throw new Error("Unexpected request"); + return Promise.resolve(response); + }, + ) as unknown as typeof fetch; + return { fetchImpl, requests }; +}; + +describe("AiOAuthService", () => { + test("exchanges a ChatGPT device authorization and extracts its account", async () => { + const accessToken = jwt({ + email: "user@example.com", + "https://api.openai.com/auth": { + chatgpt_account_id: "account-1", + chatgpt_plan_type: "plus", + }, + }); + const { fetchImpl, requests } = queuedFetch([ + json({ authorization_code: "code-1", code_verifier: "verifier-1" }), + json({ + access_token: accessToken, + refresh_token: "refresh-1", + expires_in: 3600, + }), + ]); + const service = new AiOAuthService(fetchImpl); + + const result = await service.pollDeviceAuthorization({ + kind: "chatgpt", + deviceAuthId: "device-1", + userCode: "ABCD-EFGH", + }); + + expect(result).toMatchObject({ + kind: "connected", + credentials: { + accessToken, + refreshToken: "refresh-1", + accountId: "account-1", + email: "user@example.com", + plan: "plus", + }, + }); + expect(requests[0]?.init?.method).toBe("POST"); + expect(requests[0]?.init?.body).toBe( + JSON.stringify({ + device_auth_id: "device-1", + user_code: "ABCD-EFGH", + }), + ); + expect(requests[1]?.url).toBe("https://auth.openai.com/oauth/token"); + expect(String(requests[1]?.init?.body)).toContain( + "grant_type=authorization_code", + ); + expect(requests[1]?.init?.headers).toMatchObject({ + "Content-Type": "application/x-www-form-urlencoded", + }); + }); + + test("uses xAI's minimal device scope and reports slow down", async () => { + const { fetchImpl, requests } = queuedFetch([ + json({ + device_code: "device-1", + user_code: "WXYZ", + verification_uri: "https://auth.x.ai/device", + expires_in: 300, + interval: 5, + }), + json({ error: "slow_down", interval: 12 }, 400), + ]); + const service = new AiOAuthService(fetchImpl); + + const device = await service.requestDeviceAuthorization("xai"); + expect(device).toMatchObject({ + kind: "xai", + deviceCode: "device-1", + interval: 5, + }); + expect(String(requests[0]?.init?.body)).toContain( + "scope=openid+profile+email+offline_access+grok-cli%3Aaccess+api%3Aaccess", + ); + + await expect( + service.pollDeviceAuthorization({ kind: "xai", deviceCode: "device-1" }), + ).resolves.toEqual({ kind: "pending", interval: 12, slowDown: true }); + }); + + test("preserves a rotating xAI refresh token when the response omits it", async () => { + const { fetchImpl, requests } = queuedFetch([ + json({ access_token: "new-access", expires_in: 3600 }), + ]); + const service = new AiOAuthService(fetchImpl); + + const credentials = await service.refresh("xai", "current-refresh"); + + expect(credentials.refreshToken).toBe("current-refresh"); + expect(String(requests[0]?.init?.body)).toContain( + "refresh_token=current-refresh", + ); + }); +}); diff --git a/packages/backend/src/server/ai/oauth.ts b/packages/backend/src/server/ai/oauth.ts new file mode 100644 index 0000000..e9e312b --- /dev/null +++ b/packages/backend/src/server/ai/oauth.ts @@ -0,0 +1,381 @@ +import { z } from "zod"; +import type { + AiDeviceAuthorization as DeviceAuthorization, + AiDeviceAuthorizationPoll as DeviceAuthorizationPoll, + AiDeviceAuthorizationResult as DeviceAuthorizationResult, + AiSubscriptionCredentials as SubscriptionCredentials, + AiSubscriptionProvider, +} from "shared"; + +const CHATGPT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; +const CHATGPT_ISSUER = "https://auth.openai.com"; +const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_ISSUER = "https://auth.x.ai"; +const XAI_SCOPES = + "openid profile email offline_access grok-cli:access api:access"; + +const nonEmptyString = z.string().min(1); +const positiveNumber = z.coerce.number().positive().finite(); +const oauthErrorSchema = z.object({ + error: nonEmptyString.optional(), + error_description: nonEmptyString.optional(), + message: nonEmptyString.optional(), + detail: nonEmptyString.optional(), + interval: positiveNumber.optional(), +}); +const tokenResponseSchema = z.object({ + access_token: nonEmptyString, + refresh_token: nonEmptyString.optional(), + id_token: nonEmptyString.optional(), + expires_in: positiveNumber.optional(), +}); +const chatGptDeviceSchema = z.object({ + device_auth_id: nonEmptyString, + user_code: nonEmptyString.optional(), + usercode: nonEmptyString.optional(), + interval: positiveNumber, +}); +const chatGptAuthorizationSchema = z.object({ + authorization_code: nonEmptyString, + code_verifier: nonEmptyString, +}); +const xaiDeviceSchema = z.object({ + device_code: nonEmptyString, + user_code: nonEmptyString, + verification_uri: z.url(), + verification_uri_complete: z.url().optional(), + expires_in: positiveNumber, + interval: positiveNumber.optional(), +}); +const jwtClaimsSchema = z + .object({ + exp: z.number().finite().optional(), + email: nonEmptyString.optional(), + chatgpt_account_id: nonEmptyString.optional(), + organizations: z + .array(z.object({ id: nonEmptyString }).passthrough()) + .optional(), + "https://api.openai.com/auth": z + .object({ + chatgpt_account_id: nonEmptyString.optional(), + chatgpt_plan_type: nonEmptyString.optional(), + chatgpt_account_is_fedramp: z.boolean().optional(), + }) + .optional(), + "https://api.openai.com/profile": z + .object({ email: nonEmptyString.optional() }) + .optional(), + }) + .passthrough(); + +type TokenResponse = z.infer; +type JwtClaims = z.infer; + +export class AiOAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "AiOAuthError"; + } +} + +const decodeJwtClaims = (token: string | undefined) => { + const payload = token?.split(".")[1]; + if (payload === undefined) return undefined; + try { + const result = jwtClaimsSchema.safeParse( + JSON.parse(Buffer.from(payload, "base64url").toString("utf8")), + ); + return result.success ? result.data : undefined; + } catch { + return undefined; + } +}; + +const readChatGptProfile = (tokens: TokenResponse) => { + const claims = [tokens.id_token, tokens.access_token] + .map(decodeJwtClaims) + .filter((value) => value !== undefined); + const find = (read: (claim: JwtClaims) => Value | undefined) => + claims.map(read).find((value) => value !== undefined); + + return { + accountId: find( + (claim) => + claim.chatgpt_account_id ?? + claim["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claim.organizations?.[0]?.id, + ), + email: find( + (claim) => claim.email ?? claim["https://api.openai.com/profile"]?.email, + ), + plan: find( + (claim) => claim["https://api.openai.com/auth"]?.chatgpt_plan_type, + ), + isFedRamp: find( + (claim) => + claim["https://api.openai.com/auth"]?.chatgpt_account_is_fedramp, + ), + }; +}; + +const tokenExpiry = (tokens: TokenResponse) => { + if (tokens.expires_in !== undefined) { + return Date.now() + tokens.expires_in * 1000; + } + const exp = decodeJwtClaims(tokens.access_token)?.exp; + return exp === undefined ? Date.now() + 60 * 60 * 1000 : exp * 1000; +}; + +const readErrorBody = async (response: Response) => { + const text = (await response.text()).slice(0, 500); + try { + const result = oauthErrorSchema.safeParse(JSON.parse(text)); + return result.success ? result.data : undefined; + } catch { + return text.startsWith("<") || text === "" ? undefined : { detail: text }; + } +}; + +const responseError = async (response: Response, action: string) => { + const error = await readErrorBody(response); + const detail = + error?.error_description ?? error?.message ?? error?.detail ?? error?.error; + return new AiOAuthError( + detail === undefined + ? `${action} failed with status ${response.status}` + : `${action} failed: ${detail}`, + ); +}; + +const parseResponse = async ( + response: Response, + schema: Schema, + action: string, +): Promise> => { + if (!response.ok) throw await responseError(response, action); + const result = schema.safeParse(await response.json()); + if (!result.success) { + throw new AiOAuthError(`${action} returned an invalid response`); + } + return result.data; +}; + +const credentialsFromTokens = ( + tokens: TokenResponse, + provider: AiSubscriptionProvider, + previousRefreshToken?: string, + requireChatGptAccount = false, +): SubscriptionCredentials => { + const profile = + provider === "chatgpt" + ? readChatGptProfile(tokens) + : { + email: + decodeJwtClaims(tokens.id_token)?.email ?? + decodeJwtClaims(tokens.access_token)?.email, + accountId: undefined, + plan: undefined, + isFedRamp: undefined, + }; + if ( + provider === "chatgpt" && + requireChatGptAccount && + profile.accountId === undefined + ) { + throw new AiOAuthError("ChatGPT sign in did not include an account ID"); + } + const refreshToken = tokens.refresh_token ?? previousRefreshToken; + if (refreshToken === undefined) { + throw new AiOAuthError("OAuth response did not include a refresh token"); + } + return { + accessToken: tokens.access_token, + refreshToken, + expiresAt: tokenExpiry(tokens), + ...profile, + }; +}; + +const formHeaders = { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", +}; + +export class AiOAuthService { + constructor( + private readonly fetchImpl: typeof globalThis.fetch = globalThis.fetch, + ) {} + + async requestDeviceAuthorization( + provider: AiSubscriptionProvider, + ): Promise { + if (provider === "chatgpt") { + const response = await this.fetchImpl( + `${CHATGPT_ISSUER}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }), + }, + ); + const device = await parseResponse( + response, + chatGptDeviceSchema, + "ChatGPT device authorization", + ); + const userCode = device.user_code ?? device.usercode; + if (userCode === undefined) { + throw new AiOAuthError( + "ChatGPT device authorization returned an invalid response", + ); + } + return { + kind: "chatgpt", + deviceAuthId: device.device_auth_id, + userCode, + interval: device.interval, + verificationUri: `${CHATGPT_ISSUER}/codex/device`, + expiresAt: Date.now() + 15 * 60 * 1000, + }; + } + + const response = await this.fetchImpl(`${XAI_ISSUER}/oauth2/device/code`, { + method: "POST", + headers: formHeaders, + body: new URLSearchParams({ + client_id: XAI_CLIENT_ID, + scope: XAI_SCOPES, + referrer: "httpworkbench", + }), + }); + const device = await parseResponse( + response, + xaiDeviceSchema, + "xAI device authorization", + ); + const verificationUri = + device.verification_uri_complete ?? device.verification_uri; + if (new URL(verificationUri).protocol !== "https:") { + throw new AiOAuthError( + "xAI device authorization returned an insecure verification URL", + ); + } + return { + kind: "xai", + deviceCode: device.device_code, + userCode: device.user_code, + verificationUri, + interval: device.interval ?? 5, + expiresAt: Date.now() + device.expires_in * 1000, + }; + } + + async pollDeviceAuthorization( + poll: DeviceAuthorizationPoll, + ): Promise { + if (poll.kind === "chatgpt") { + const response = await this.fetchImpl( + `${CHATGPT_ISSUER}/api/accounts/deviceauth/token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: poll.deviceAuthId, + user_code: poll.userCode, + }), + }, + ); + if (!response.ok) { + if (response.status === 403 || response.status === 404) { + return { kind: "pending" }; + } + throw await responseError(response, "ChatGPT sign in"); + } + const authorization = await parseResponse( + response, + chatGptAuthorizationSchema, + "ChatGPT device authorization", + ); + const tokenResponse = await this.fetchImpl( + `${CHATGPT_ISSUER}/oauth/token`, + { + method: "POST", + headers: formHeaders, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: authorization.authorization_code, + redirect_uri: `${CHATGPT_ISSUER}/deviceauth/callback`, + client_id: CHATGPT_CLIENT_ID, + code_verifier: authorization.code_verifier, + }), + }, + ); + const tokens = await parseResponse( + tokenResponse, + tokenResponseSchema, + "ChatGPT sign in", + ); + return { + kind: "connected", + credentials: credentialsFromTokens(tokens, "chatgpt", undefined, true), + }; + } + + const response = await this.fetchImpl(`${XAI_ISSUER}/oauth2/token`, { + method: "POST", + headers: formHeaders, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: poll.deviceCode, + client_id: XAI_CLIENT_ID, + }), + }); + if (!response.ok) { + const error = await readErrorBody(response); + if (error?.error === "authorization_pending") { + return { kind: "pending" }; + } + if (error?.error === "slow_down") { + return { kind: "pending", interval: error.interval, slowDown: true }; + } + const detail = error?.error_description ?? error?.error; + throw new AiOAuthError( + detail === undefined + ? `xAI sign in failed with status ${response.status}` + : `xAI sign in failed: ${detail}`, + ); + } + const tokens = await parseResponse( + response, + tokenResponseSchema, + "xAI sign in", + ); + return { + kind: "connected", + credentials: credentialsFromTokens(tokens, "xai"), + }; + } + + async refresh( + provider: AiSubscriptionProvider, + refreshToken: string, + ): Promise { + const isChatGpt = provider === "chatgpt"; + const issuer = isChatGpt ? CHATGPT_ISSUER : XAI_ISSUER; + const response = await this.fetchImpl(`${issuer}/oauth/token`, { + method: "POST", + headers: formHeaders, + body: new URLSearchParams({ + client_id: isChatGpt ? CHATGPT_CLIENT_ID : XAI_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + const tokens = await parseResponse( + response, + tokenResponseSchema, + `${isChatGpt ? "ChatGPT" : "xAI"} token refresh`, + ); + return credentialsFromTokens(tokens, provider, refreshToken); + } +} diff --git a/packages/backend/src/server/api/ai.spec.ts b/packages/backend/src/server/api/ai.spec.ts new file mode 100644 index 0000000..3c4d128 --- /dev/null +++ b/packages/backend/src/server/api/ai.spec.ts @@ -0,0 +1,140 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Elysia } from "elysia"; +import { addUser, closeDb, initDb } from "../../storage"; + +process.env.JWT_SECRET = "ai-routes-test-secret"; +process.env.FRONTEND_URL = "http://localhost"; +process.env.GOOGLE_CLIENT_ID ??= "test-client"; +process.env.GOOGLE_CLIENT_SECRET ??= "test-secret"; + +const originalFetch = globalThis.fetch; +const upstreamRequests: Array<{ + url: string; + init?: Parameters[1]; +}> = []; + +globalThis.fetch = mock( + (input: Parameters[0], init?: Parameters[1]) => { + const url = input instanceof Request ? input.url : input.toString(); + upstreamRequests.push({ url, init }); + if (url === "https://auth.x.ai/oauth2/device/code") { + return Promise.resolve( + Response.json({ + device_code: "device-1", + user_code: "CODE-1", + verification_uri: "https://auth.x.ai/device", + expires_in: 300, + interval: 5, + }), + ); + } + if (url === "https://chatgpt.com/backend-api/codex/responses") { + return Promise.resolve( + new Response("data: done\n\n", { + headers: { "content-type": "text/event-stream" }, + }), + ); + } + throw new Error(`Unexpected upstream request: ${url}`); + }, +) as unknown as typeof fetch; + +const { issueAuthToken } = await import("../auth"); +const { aiRoutes } = await import("./ai"); +const app = new Elysia().use(aiRoutes); +let dataDir = ""; +let token = ""; + +const post = ( + url: string, + body: unknown, + headers: Record = {}, +) => + app.handle( + new Request(`http://localhost${url}`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + origin: "http://localhost", + ...headers, + }, + body: JSON.stringify(body), + }), + ); + +describe("AI routes", () => { + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + beforeEach(async () => { + upstreamRequests.length = 0; + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-ai-route-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") throw result.error; + addUser({ id: "owner-1", googleId: "google-1", createdAt: 1 }); + token = await issueAuthToken("owner-1"); + }); + + afterEach(() => { + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("starts device authorization only for an allowed browser origin", async () => { + const response = await post("/api/ai/oauth/device", { provider: "xai" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + kind: "xai", + deviceCode: "device-1", + userCode: "CODE-1", + }); + expect(upstreamRequests).toHaveLength(1); + + const rejected = await post( + "/api/ai/oauth/device", + { provider: "xai" }, + { origin: "https://attacker.example" }, + ); + expect(rejected.status).toBe(403); + expect(upstreamRequests).toHaveLength(1); + }); + + test("streams ChatGPT responses through the authenticated adapter", async () => { + const response = await post( + "/api/ai/chatgpt/responses", + { + model: "gpt-5.6-sol", + input: [], + store: true, + max_output_tokens: 1024, + }, + { + "x-ai-provider-token": "provider-token", + "x-ai-provider-account-id": "account-1", + }, + ); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("data: done\n\n"); + const upstream = upstreamRequests[0]; + const body = JSON.parse(String(upstream?.init?.body)); + expect(body).toMatchObject({ store: false, stream: true }); + expect(body.max_output_tokens).toBeUndefined(); + const headers = new Headers(upstream?.init?.headers); + expect(headers.get("authorization")).toBe("Bearer provider-token"); + expect(headers.get("chatgpt-account-id")).toBe("account-1"); + }); +}); diff --git a/packages/backend/src/server/api/ai.ts b/packages/backend/src/server/api/ai.ts new file mode 100644 index 0000000..049433d --- /dev/null +++ b/packages/backend/src/server/api/ai.ts @@ -0,0 +1,115 @@ +import { Elysia, status, t } from "elysia"; +import { ChatGptProxyService } from "../ai/chatGpt"; +import { AiOAuthError, AiOAuthService } from "../ai/oauth"; +import { authPlugin } from "../auth"; +import { createFixedWindowRateLimiter } from "../rateLimit"; +import { isAllowedBrowserOrigin } from "../webSocketOrigin"; + +const oauthService = new AiOAuthService(); +const chatGptProxyService = new ChatGptProxyService(); +const aiRateLimiter = createFixedWindowRateLimiter({ + maxRequests: 300, + windowMs: 60_000, +}); + +const providerSchema = t.Union([t.Literal("chatgpt"), t.Literal("xai")]); + +const pollSchema = t.Union([ + t.Object({ + kind: t.Literal("chatgpt"), + deviceAuthId: t.String({ minLength: 1, maxLength: 1024 }), + userCode: t.String({ minLength: 1, maxLength: 256 }), + }), + t.Object({ + kind: t.Literal("xai"), + deviceCode: t.String({ minLength: 1, maxLength: 4096 }), + }), +]); + +const ensureAiRequestAllowed = (request: Request, userId: string) => { + if (!isAllowedBrowserOrigin(request)) { + return status(403, { error: "Invalid origin" }); + } + if (!aiRateLimiter.check(userId)) { + return status(429, { error: "AI request rate limit exceeded" }); + } +}; + +const oauthError = (error: unknown) => { + if (error instanceof AiOAuthError) { + return status(502, { error: error.message }); + } + throw error; +}; + +export const aiRoutes = new Elysia({ name: "routes/ai" }) + .use(authPlugin) + .guard({ detail: { hide: true } }) + .post( + "/api/ai/oauth/device", + async ({ body, request, user }) => { + const rejected = ensureAiRequestAllowed(request, user.id); + if (rejected !== undefined) return rejected; + try { + return await oauthService.requestDeviceAuthorization(body.provider); + } catch (error) { + return oauthError(error); + } + }, + { + session: true, + body: t.Object({ provider: providerSchema }), + }, + ) + .post( + "/api/ai/oauth/poll", + async ({ body, request, user }) => { + const rejected = ensureAiRequestAllowed(request, user.id); + if (rejected !== undefined) return rejected; + try { + return await oauthService.pollDeviceAuthorization(body); + } catch (error) { + return oauthError(error); + } + }, + { session: true, body: pollSchema }, + ) + .post( + "/api/ai/oauth/refresh", + async ({ body, request, user }) => { + const rejected = ensureAiRequestAllowed(request, user.id); + if (rejected !== undefined) return rejected; + try { + return await oauthService.refresh(body.provider, body.refreshToken); + } catch (error) { + return oauthError(error); + } + }, + { + session: true, + body: t.Object({ + provider: providerSchema, + refreshToken: t.String({ minLength: 1, maxLength: 8192 }), + }), + }, + ) + .post( + "/api/ai/chatgpt/responses", + async ({ body, request, user }) => { + const rejected = ensureAiRequestAllowed(request, user.id); + if (rejected !== undefined) return rejected; + const accessToken = request.headers.get("x-ai-provider-token"); + const accountId = request.headers.get("x-ai-provider-account-id"); + if (accessToken === null || accountId === null) { + return status(400, { error: "Missing ChatGPT credentials" }); + } + return chatGptProxyService.forward({ + body, + accessToken, + accountId, + sessionId: request.headers.get("x-ai-session-id") ?? undefined, + signal: request.signal, + }); + }, + { session: true, body: t.Record(t.String(), t.Any()) }, + ); diff --git a/packages/backend/src/server/api/guestInstances.spec.ts b/packages/backend/src/server/api/guestInstances.spec.ts new file mode 100644 index 0000000..00dbf54 --- /dev/null +++ b/packages/backend/src/server/api/guestInstances.spec.ts @@ -0,0 +1,240 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Elysia } from "elysia"; +import { instancePolicies } from "../../config"; +import { closeDb, getDb, initDb } from "../../storage"; +import { logs } from "../../storage/schema"; + +process.env.JWT_SECRET = "guest-instance-routes-test-secret"; + +const { guestInstancesRoutes } = await import("./guestInstances"); + +const app = new Elysia().use(guestInstancesRoutes); +const originalAllowGuest = instancePolicies.allowGuest; +let dataDir = ""; +let clientId = ""; + +const call = ( + pathName: string, + method: string, + body?: unknown, + token?: string, +) => + app.handle( + new Request(`http://localhost${pathName}`, { + method, + headers: { + "content-type": "application/json", + "x-internal-real-ip": clientId, + ...(token === undefined ? {} : { "x-guest-token": token }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + +const createGuest = async (body = "created") => { + const response = await call("/api/guest/instances", "POST", { + raw: `HTTP/1.1 200 OK\nContent-Type: text/plain\n\n${body}`, + }); + expect(response.status).toBe(201); + return (await response.json()) as { + instance: { id: string; raw: string; webhookIds: string[] }; + token: string; + }; +}; + +describe("guest instance routes", () => { + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-guest-route-")); + clientId = crypto.randomUUID(); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + instancePolicies.allowGuest = true; + }); + + afterEach(() => { + instancePolicies.allowGuest = originalAllowGuest; + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("returns a management token and requires it for reads and updates", async () => { + const created = await createGuest(); + expect(created.token.split(".")).toHaveLength(3); + expect(created.instance.raw).toBe( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\ncreated", + ); + expect(created.instance.webhookIds).toEqual([]); + + const anonymous = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + ); + expect(anonymous.status).toBe(404); + const wrongToken = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + undefined, + "header.payload.signature", + ); + expect(wrongToken.status).toBe(404); + + const detail = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + undefined, + created.token, + ); + expect(detail.status).toBe(200); + + const updated = await call( + `/api/guest/instances/${created.instance.id}`, + "PUT", + { raw: "HTTP/1.1 204 No Content\n\n" }, + created.token, + ); + expect(updated.status).toBe(200); + expect(((await updated.json()) as { raw: string }).raw).toBe( + "HTTP/1.1 204 No Content\r\n\r\n", + ); + }); + + test("uses strict static-only guest payloads", async () => { + const withWebhook = await call("/api/guest/instances", "POST", { + raw: "HTTP/1.1 200 OK\n\nok", + webhookIds: ["not-allowed"], + }); + expect(withWebhook.status).toBe(422); + + const dynamic = await call("/api/guest/instances", "POST", { + kind: "dynamic", + processors: [], + }); + expect(dynamic.status).toBeGreaterThanOrEqual(400); + + const tooLarge = await call("/api/guest/instances", "POST", { + raw: `HTTP/1.1 200 OK\n\n${"x".repeat(1024 * 1024)}`, + }); + expect(tooLarge.status).toBeGreaterThanOrEqual(400); + }); + + test("rate limits guest creation by trusted client address", async () => { + for (let index = 0; index < 10; index += 1) { + const response = await call("/api/guest/instances", "POST", { + raw: `HTTP/1.1 200 OK\n\n${index}`, + }); + expect(response.status).toBe(201); + } + const blocked = await call("/api/guest/instances", "POST", { + raw: "HTTP/1.1 200 OK\n\nblocked", + }); + expect(blocked.status).toBe(429); + }); + + test("bounds token-gated guest API traffic per client", async () => { + const created = await createGuest(); + for (let index = 0; index < 299; index += 1) { + const response = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + undefined, + created.token, + ); + expect(response.status).toBe(200); + } + const blocked = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + undefined, + created.token, + ); + expect(blocked.status).toBe(429); + }); + + test("loads only correctly tokened guest references in one batch", async () => { + const first = await createGuest("first"); + const second = await createGuest("second"); + getDb() + .insert(logs) + .values({ + id: "guest-summary-log", + instanceId: second.instance.id, + type: "dns", + timestamp: 1, + address: "127.0.0.1", + raw: "example.com A", + }) + .run(); + + const response = await call("/api/guest/instances/list", "POST", { + instances: [ + { id: second.instance.id, token: second.token }, + { id: first.instance.id, token: second.token }, + { id: first.instance.id, token: first.token }, + ], + }); + + expect(response.status).toBe(200); + const summaries = (await response.json()) as Array>; + expect(summaries.map(({ id }) => id)).toEqual([ + second.instance.id, + first.instance.id, + ]); + expect(summaries.map(({ logCount }) => logCount)).toEqual([1, 0]); + expect(summaries.every((summary) => !("raw" in summary))).toBe(true); + expect(summaries.every((summary) => !("webhookIds" in summary))).toBe(true); + }); + + test("pages recent logs backward behind the management token", async () => { + const created = await createGuest(); + getDb() + .insert(logs) + .values( + Array.from({ length: 105 }, (_, index) => ({ + id: `log-${index + 1}`, + instanceId: created.instance.id, + type: "http" as const, + timestamp: index + 1, + address: "127.0.0.1", + raw: `GET /${index + 1} HTTP/1.1`, + })), + ) + .run(); + + const detailResponse = await call( + `/api/guest/instances/${created.instance.id}`, + "GET", + undefined, + created.token, + ); + const detail = (await detailResponse.json()) as { + logs: Array<{ id: string }>; + olderLogsCursor: string; + }; + expect(detail.logs[0]?.id).toBe("log-6"); + expect(detail.olderLogsCursor).toBeString(); + + const olderResponse = await call( + `/api/guest/instances/${created.instance.id}/logs/recent?limit=100&cursor=${detail.olderLogsCursor}`, + "GET", + undefined, + created.token, + ); + const older = (await olderResponse.json()) as { + logs: Array<{ id: string }>; + olderLogsCursor?: string; + }; + expect(older.logs.map(({ id }) => id)).toEqual([ + "log-1", + "log-2", + "log-3", + "log-4", + "log-5", + ]); + expect(older.olderLogsCursor).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/server/api/guestInstances.ts b/packages/backend/src/server/api/guestInstances.ts index a3c5b22..b0ebe55 100644 --- a/packages/backend/src/server/api/guestInstances.ts +++ b/packages/backend/src/server/api/guestInstances.ts @@ -1,135 +1,214 @@ import { Elysia, status } from "elysia"; +import { z } from "zod"; import { - CreateInstanceSchema, - GUEST_INSTANCE_TTL_MS, + CreateGuestInstanceSchema, + GUEST_CREATE_REQUESTS_PER_MINUTE, + GuestInstanceSummariesRequestSchema, GUEST_OWNER_ID, InstanceDetailResponseSchema, + InstanceIdSchema, + RecentLogsPageResponseSchema, SetInstanceLockedSchema, - UpdateInstanceSchema, } from "shared"; import { instancePolicies } from "../../config"; import { - addInstance, clearLogsForInstance, deleteInstance, getInstanceById, - getLogsForInstance, + getInstanceSummariesByIds, + getRecentLogsForInstancePage, updateInstance, } from "../../storage"; -import { generateInstanceID, validateStaticRaw } from "../utils"; +import { + authenticateGuestInstance, + authenticateGuestInstanceReferences, + readGuestManagementToken, +} from "../guestAccess"; +import { + createGuestInstance, + replaceGuestInstance, +} from "../instances/service"; +import { createBoundedProtocolRateLimiter } from "../protocolRateLimit"; +import { clampLogLimit, decodeLogsCursor, encodeLogsCursor } from "../utils"; + +const INSTANCE_DETAIL_LOG_LIMIT = 100; +const guestCreateRateLimiter = createBoundedProtocolRateLimiter({ + maxRequests: GUEST_CREATE_REQUESTS_PER_MINUTE, + windowMs: 60_000, + maxEntries: 10_000, +}); +const guestApiRateLimiter = createBoundedProtocolRateLimiter({ + maxRequests: 300, + windowMs: 60_000, + maxEntries: 10_000, +}); + +const ParamsSchema = z.object({ id: InstanceIdSchema }); +const RecentLogsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), + cursor: z.string().max(256).optional(), + type: z.enum(["http", "dns", "smtp"]).optional(), +}); -const loadGuestInstance = (id: string) => { +const loadManagedGuestInstance = async (id: string, request: Request) => { const instance = getInstanceById(id); - if (instance?.ownerId !== GUEST_OWNER_ID) { + if ( + instance?.ownerId !== GUEST_OWNER_ID || + !(await authenticateGuestInstance( + instance, + readGuestManagementToken(request), + )) + ) { return { ok: false as const, error: status(404, { error: "Not found" }) }; } return { ok: true as const, instance }; }; +const clientRateLimitKey = (request: Request) => + request.headers.get("x-internal-real-ip") ?? "unknown"; + +const loadRecentLogs = (input: { + instanceId: string; + limit?: number; + cursor?: string; + type?: "http" | "dns" | "smtp"; +}) => { + const before = + input.cursor === undefined ? undefined : decodeLogsCursor(input.cursor); + if (input.cursor !== undefined && before === undefined) { + return { + ok: false as const, + error: status(400, { error: "Invalid cursor" }), + }; + } + const page = getRecentLogsForInstancePage({ + instanceId: input.instanceId, + limit: clampLogLimit(input.limit), + before, + type: input.type, + }); + return { + ok: true as const, + page: RecentLogsPageResponseSchema.parse({ + logs: page.logs, + olderLogsCursor: + page.olderCursor === undefined + ? undefined + : encodeLogsCursor(page.olderCursor), + }), + }; +}; + export const guestInstancesRoutes = new Elysia({ name: "routes/guest" }) .guard({ detail: { hide: true } }) - .onBeforeHandle(() => { + .onBeforeHandle(({ request }) => { if (!instancePolicies.allowGuest) { return status(403, { error: "Guest access is disabled" }); } + if (!guestApiRateLimiter.check(clientRateLimitKey(request), Date.now())) { + return status(429, { error: "Guest API rate limit exceeded" }); + } }) .post( "/api/guest/instances", - ({ body }) => { - let staticRaw: string | undefined; - if (body.kind === "static") { - const check = validateStaticRaw(body.raw); - if (!check.ok) { - return status(check.status, { error: check.error }); - } - staticRaw = check.raw; + async ({ body, request }) => { + if ( + !guestCreateRateLimiter.check(clientRateLimitKey(request), Date.now()) + ) { + return status(429, { error: "Guest creation rate limit exceeded" }); } - - const now = Date.now(); - const base = { - id: generateInstanceID(), - ownerId: GUEST_OWNER_ID, - createdAt: now, - expiresAt: now + GUEST_INSTANCE_TTL_MS, - webhookIds: [] as string[], - public: false, - locked: false, - }; - - const created = - body.kind === "static" - ? addInstance({ kind: "static", ...base, raw: staticRaw ?? body.raw }) - : addInstance({ - kind: "dynamic", - ...base, - processors: body.processors, - }); - return status(201, created); + const result = await createGuestInstance(body.raw); + return result.ok + ? status(201, result.value) + : status(result.error.status, { error: result.error.message }); }, - { body: CreateInstanceSchema }, + { body: CreateGuestInstanceSchema }, ) - .get("/api/guest/instances/:id", ({ params }) => { - const loaded = loadGuestInstance(params.id); - if (!loaded.ok) { - return loaded.error; - } - const logs = getLogsForInstance(loaded.instance.id); - return InstanceDetailResponseSchema.parse({ - instance: loaded.instance, - logs, - }); - }) - .put( + .post( + "/api/guest/instances/list", + async ({ body }) => { + const requestedIds = [...new Set(body.instances.map(({ id }) => id))]; + const summaries = getInstanceSummariesByIds(requestedIds, GUEST_OWNER_ID); + const ids = await authenticateGuestInstanceReferences( + body.instances, + summaries, + ); + const authorizedIds = new Set(ids); + return summaries.filter(({ id }) => authorizedIds.has(id)); + }, + { body: GuestInstanceSummariesRequestSchema }, + ) + .get( "/api/guest/instances/:id", - ({ params, body }) => { - const loaded = loadGuestInstance(params.id); + async ({ params, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); if (!loaded.ok) { return loaded.error; } - if (body.kind !== loaded.instance.kind) { - return status(400, { error: "Kind mismatch" }); + const recent = loadRecentLogs({ + instanceId: loaded.instance.id, + limit: INSTANCE_DETAIL_LOG_LIMIT, + }); + if (!recent.ok) { + return recent.error; } - - let staticRaw: string | undefined; - if (body.kind === "static") { - const check = validateStaticRaw(body.raw); - if (!check.ok) { - return status(check.status, { error: check.error }); - } - staticRaw = check.raw; + return InstanceDetailResponseSchema.parse({ + instance: loaded.instance, + ...recent.page, + }); + }, + { params: ParamsSchema }, + ) + .get( + "/api/guest/instances/:id/logs/recent", + async ({ params, query, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); + if (!loaded.ok) { + return loaded.error; } - - const updated = updateInstance(loaded.instance.id, (inst) => { - if (inst.kind === "static" && body.kind === "static") { - return { ...inst, raw: staticRaw ?? body.raw, webhookIds: [] }; - } - if (inst.kind === "dynamic" && body.kind === "dynamic") { - return { ...inst, processors: body.processors, webhookIds: [] }; - } - return inst; + const recent = loadRecentLogs({ + instanceId: loaded.instance.id, + limit: query.limit, + cursor: query.cursor, + type: query.type, }); - if (updated === undefined) { - return status(404, { error: "Not found" }); + return recent.ok ? recent.page : recent.error; + }, + { params: ParamsSchema, query: RecentLogsQuerySchema }, + ) + .put( + "/api/guest/instances/:id", + async ({ params, body, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); + if (!loaded.ok) { + return loaded.error; } - return updated; + const result = replaceGuestInstance(loaded.instance.id, body.raw); + return result.ok + ? result.value + : status(result.error.status, { error: result.error.message }); }, - { body: UpdateInstanceSchema }, + { params: ParamsSchema, body: CreateGuestInstanceSchema }, + ) + .delete( + "/api/guest/instances/:id", + async ({ params, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); + if (!loaded.ok) { + return loaded.error; + } + if (loaded.instance.locked) { + return status(409, { error: "Instance is locked" }); + } + deleteInstance(loaded.instance.id); + return { message: "Deleted" }; + }, + { params: ParamsSchema }, ) - .delete("/api/guest/instances/:id", ({ params }) => { - const loaded = loadGuestInstance(params.id); - if (!loaded.ok) { - return loaded.error; - } - if (loaded.instance.locked) { - return status(409, { error: "Instance is locked" }); - } - deleteInstance(loaded.instance.id); - return { message: "Deleted" }; - }) .patch( "/api/guest/instances/:id/lock", - ({ params, body }) => { - const loaded = loadGuestInstance(params.id); + async ({ params, body, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); if (!loaded.ok) { return loaded.error; } @@ -142,13 +221,17 @@ export const guestInstancesRoutes = new Elysia({ name: "routes/guest" }) } return updated; }, - { body: SetInstanceLockedSchema }, + { params: ParamsSchema, body: SetInstanceLockedSchema }, ) - .delete("/api/guest/instances/:id/logs", ({ params }) => { - const loaded = loadGuestInstance(params.id); - if (!loaded.ok) { - return loaded.error; - } - clearLogsForInstance(loaded.instance.id); - return { message: "Logs cleared" }; - }); + .delete( + "/api/guest/instances/:id/logs", + async ({ params, request }) => { + const loaded = await loadManagedGuestInstance(params.id, request); + if (!loaded.ok) { + return loaded.error; + } + clearLogsForInstance(loaded.instance.id); + return { message: "Logs cleared" }; + }, + { params: ParamsSchema }, + ); diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 82f7414..b73dddc 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -1,4 +1,5 @@ export * from "./apiKeys"; +export * from "./ai"; export * from "./guestInstances"; export * from "./instances"; export * from "./oauth"; diff --git a/packages/backend/src/server/api/instances.spec.ts b/packages/backend/src/server/api/instances.spec.ts new file mode 100644 index 0000000..1ec66d7 --- /dev/null +++ b/packages/backend/src/server/api/instances.spec.ts @@ -0,0 +1,261 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Elysia } from "elysia"; +import { addUser, addWebhook, closeDb, getDb, initDb } from "../../storage"; +import { logs } from "../../storage/schema"; + +process.env.JWT_SECRET = "instance-routes-test-secret"; + +const { issueAuthToken } = await import("../auth"); +const { createGuestInstance } = await import("../instances/service"); +const { instancesRoutes } = await import("./instances"); +const app = new Elysia().use(instancesRoutes); +let dataDir = ""; + +const call = async ( + pathName: string, + method: string, + userId: string, + body: unknown, +) => { + const token = await issueAuthToken(userId); + return app.handle( + new Request(`http://localhost${pathName}`, { + method, + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + ); +}; + +describe("authenticated instance routes", () => { + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-route-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + addUser({ id: "owner-1", googleId: "google-1", createdAt: 1 }); + addUser({ id: "owner-2", googleId: "google-2", createdAt: 1 }); + }); + + afterEach(() => { + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("creates and updates a static-only instance", async () => { + const createdResponse = await call("/api/instances", "POST", "owner-1", { + raw: "HTTP/1.1 200 OK\n\ncreated", + }); + expect(createdResponse.status).toBe(201); + const created = (await createdResponse.json()) as { + id: string; + raw: string; + kind?: unknown; + }; + expect(created.raw).toBe("HTTP/1.1 200 OK\r\n\r\ncreated"); + expect(created.kind).toBeUndefined(); + + const updatedResponse = await call( + `/api/instances/${created.id}`, + "PUT", + "owner-1", + { raw: "HTTP/1.1 204 No Content\n\n" }, + ); + expect(updatedResponse.status).toBe(200); + const updated = (await updatedResponse.json()) as { + raw: string; + kind?: unknown; + }; + expect(updated.raw).toBe("HTTP/1.1 204 No Content\r\n\r\n"); + expect(updated.kind).toBeUndefined(); + }); + + test("returns domain errors for duplicate webhooks and wrong owners", async () => { + addWebhook({ + id: "webhook-1", + ownerId: "owner-1", + name: "Alerts", + url: "https://discord.com/api/webhooks/123/token", + createdAt: 1, + }); + const duplicateResponse = await call("/api/instances", "POST", "owner-1", { + raw: "HTTP/1.1 200 OK\n\nok", + webhookIds: ["webhook-1", "webhook-1"], + }); + expect(duplicateResponse.status).toBe(400); + expect(await duplicateResponse.json()).toEqual({ + error: "Invalid webhook selection", + }); + + const ownerTwoResponse = await call("/api/instances", "POST", "owner-2", { + raw: "HTTP/1.1 200 OK\n\nowner two", + }); + const ownerTwo = (await ownerTwoResponse.json()) as { id: string }; + const forbiddenResponse = await call( + `/api/instances/${ownerTwo.id}`, + "PUT", + "owner-1", + { raw: "HTTP/1.1 200 OK\n\nforbidden" }, + ); + expect(forbiddenResponse.status).toBe(403); + expect(await forbiddenResponse.json()).toEqual({ error: "Forbidden" }); + }); + + test("lists lightweight instance summaries", async () => { + const createdResponse = await call("/api/instances", "POST", "owner-1", { + raw: "HTTP/1.1 200 OK\n\nlarge response body", + }); + const created = (await createdResponse.json()) as { id: string }; + getDb() + .insert(logs) + .values({ + id: "summary-log", + instanceId: created.id, + type: "http", + timestamp: 1, + address: "127.0.0.1", + raw: "GET / HTTP/1.1", + }) + .run(); + const token = await issueAuthToken("owner-1"); + + const response = await app.handle( + new Request("http://localhost/api/instances", { + headers: { authorization: `Bearer ${token}` }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual([ + { + id: created.id, + ownerId: "owner-1", + createdAt: expect.any(Number), + public: false, + locked: false, + logCount: 1, + }, + ]); + }); + + test("bounds embedded detail logs to the 100 most recent entries", async () => { + const createdResponse = await call("/api/instances", "POST", "owner-1", { + raw: "HTTP/1.1 200 OK\n\nok", + }); + const created = (await createdResponse.json()) as { id: string }; + getDb() + .insert(logs) + .values( + Array.from({ length: 105 }, (_, index) => ({ + id: `log-${index + 1}`, + instanceId: created.id, + type: "http" as const, + timestamp: index + 1, + address: "127.0.0.1", + raw: `GET /${index + 1} HTTP/1.1`, + })), + ) + .run(); + const token = await issueAuthToken("owner-1"); + + const response = await app.handle( + new Request(`http://localhost/api/instances/${created.id}`, { + headers: { authorization: `Bearer ${token}` }, + }), + ); + const detail = (await response.json()) as { + logs: Array<{ id: string }>; + olderLogsCursor: string; + }; + + expect(response.status).toBe(200); + expect(detail.logs).toHaveLength(100); + expect(detail.logs[0]?.id).toBe("log-6"); + expect(detail.logs.at(-1)?.id).toBe("log-105"); + expect(detail.olderLogsCursor).toBeString(); + + const older = await app.handle( + new Request( + `http://localhost/api/instances/${created.id}/logs/recent?cursor=${detail.olderLogsCursor}`, + { headers: { authorization: `Bearer ${token}` } }, + ), + ); + const olderPage = (await older.json()) as { + logs: Array<{ id: string }>; + olderLogsCursor?: string; + }; + expect(older.status).toBe(200); + expect(olderPage.logs.map(({ id }) => id)).toEqual([ + "log-1", + "log-2", + "log-3", + "log-4", + "log-5", + ]); + expect(olderPage.olderLogsCursor).toBeUndefined(); + }); + + test("does not expose guest details through the public instance route", async () => { + const created = await createGuestInstance("HTTP/1.1 200 OK\n\nsecret"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + const response = await app.handle( + new Request( + `http://localhost/api/instances/${created.value.instance.id}`, + ), + ); + expect(response.status).toBe(404); + }); + + test("allows anonymous backward log paging only for public non-guest instances", async () => { + const createdResponse = await call("/api/instances", "POST", "owner-1", { + raw: "HTTP/1.1 200 OK\n\nok", + }); + const created = (await createdResponse.json()) as { id: string }; + const publicResponse = await call( + `/api/instances/${created.id}/public`, + "PATCH", + "owner-1", + { public: true }, + ); + expect(publicResponse.status).toBe(200); + getDb() + .insert(logs) + .values({ + id: "public-log", + instanceId: created.id, + type: "http", + timestamp: 1, + address: "127.0.0.1", + raw: "GET / HTTP/1.1", + }) + .run(); + + const response = await app.handle( + new Request(`http://localhost/api/instances/${created.id}/logs/recent`), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + logs: [ + { + id: "public-log", + instanceId: created.id, + type: "http", + timestamp: 1, + address: "127.0.0.1", + raw: "GET / HTTP/1.1", + }, + ], + }); + }); +}); diff --git a/packages/backend/src/server/api/instances.ts b/packages/backend/src/server/api/instances.ts index 919b362..9503a00 100644 --- a/packages/backend/src/server/api/instances.ts +++ b/packages/backend/src/server/api/instances.ts @@ -2,21 +2,21 @@ import { Elysia, status } from "elysia"; import { z } from "zod"; import { CreateInstanceSchema, + GUEST_OWNER_ID, InstanceDetailResponseSchema, + RecentLogsPageResponseSchema, RenameInstanceSchema, SetInstanceLockedSchema, SetInstancePublicSchema, UpdateInstanceSchema, } from "shared"; import { - addInstance, clearLogsForInstance, deleteInstance, getInstanceById, - getInstancesByOwner, - getLogsForInstance, + getInstanceSummariesByOwner, + getRecentLogsForInstancePage, getLogsForInstancePage, - getWebhooksByOwner, updateInstance, } from "../../storage"; import { instancePolicies } from "../../config"; @@ -30,52 +30,42 @@ import { } from "../auth"; import { canReadInstance } from "../instances/access"; import { - clampLogLimit, - decodeLogsCursor, - encodeLogsCursor, - generateInstanceID, - validateStaticRaw, -} from "../utils"; + createInstance, + getOwnedInstance, + replaceInstance, + type InstanceServiceError, +} from "../instances/service"; +import { clampLogLimit, decodeLogsCursor, encodeLogsCursor } from "../utils"; const LogsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional(), - cursor: z.string().optional(), + cursor: z.string().max(256).optional(), type: z.enum(["http", "dns", "smtp"]).optional(), sinceTimestamp: z.coerce.number().int().min(0).optional(), }); -const ensureOwnedWebhookIds = ( - ownerId: string, - webhookIds: string[], -): { ok: true; ids: string[] } | { ok: false } => { - if (webhookIds.length === 0) { - return { ok: true, ids: [] }; - } - const ownedIds = new Set( - getWebhooksByOwner(ownerId).map((webhook) => webhook.id), - ); - for (const webhookId of webhookIds) { - if (!ownedIds.has(webhookId)) { - return { ok: false }; - } - } - return { ok: true, ids: webhookIds }; -}; +const RecentLogsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), + cursor: z.string().max(256).optional(), + type: z.enum(["http", "dns", "smtp"]).optional(), +}); + +const INSTANCE_DETAIL_LOG_LIMIT = 100; + +const serviceErrorResponse = (error: InstanceServiceError) => + status(error.status, { error: error.message }); const loadOwnedInstance = (id: string, userId: string) => { - const current = getInstanceById(id); - if (current === undefined) { - return { ok: false as const, res: status(404, { error: "Not found" }) }; - } - if (current.ownerId !== userId) { - return { ok: false as const, res: status(403, { error: "Forbidden" }) }; + const result = getOwnedInstance(id, userId); + if (!result.ok) { + return { ok: false as const, res: serviceErrorResponse(result.error) }; } - return { ok: true as const, instance: current }; + return { ok: true as const, instance: result.value }; }; export const instancesRoutes = new Elysia({ name: "routes/instances" }) .use(authPlugin) - .get("/api/instances", ({ user }) => getInstancesByOwner(user.id), { + .get("/api/instances", ({ user }) => getInstanceSummariesByOwner(user.id), { scope: "instances:read", detail: { tags: ["Instances"], @@ -86,51 +76,14 @@ export const instancesRoutes = new Elysia({ name: "routes/instances" }) .post( "/api/instances", ({ body, user }) => { - let staticRaw: string | undefined; - if (body.kind === "static") { - const check = validateStaticRaw(body.raw); - if (!check.ok) { - return status(check.status, { error: check.error }); - } - staticRaw = check.raw; - } - - const webhooks = ensureOwnedWebhookIds(user.id, body.webhookIds ?? []); - if (!webhooks.ok) { - return status(400, { error: "Invalid webhook selection" }); - } - - if ( - instancePolicies.maxInstancesPerOwner !== undefined && - getInstancesByOwner(user.id).length >= - instancePolicies.maxInstancesPerOwner - ) { - return status(403, { error: "Instance limit reached" }); - } - - const now = Date.now(); - const base = { - id: generateInstanceID(), + const result = createInstance({ ownerId: user.id, - createdAt: now, - public: false, - locked: false, - expiresAt: - instancePolicies.defaultTtlMs === undefined - ? undefined - : now + instancePolicies.defaultTtlMs, - webhookIds: webhooks.ids, - } as const; - - const created = - body.kind === "static" - ? addInstance({ kind: "static", ...base, raw: staticRaw ?? body.raw }) - : addInstance({ - kind: "dynamic", - ...base, - processors: body.processors, - }); - return status(201, created); + raw: body.raw, + webhookIds: body.webhookIds, + }); + return result.ok + ? status(201, result.value) + : serviceErrorResponse(result.error); }, { scope: "instances:write", @@ -150,6 +103,9 @@ export const instancesRoutes = new Elysia({ name: "routes/instances" }) if (instance === undefined) { return status(404, { error: "Not found" }); } + if (instance.ownerId === GUEST_OWNER_ID) { + return status(404, { error: "Not found" }); + } const access = await resolveOptionalAccess( request, @@ -169,69 +125,40 @@ export const instancesRoutes = new Elysia({ name: "routes/instances" }) const includeLogs = publiclyReadable || !apiKeyMissingScope(access, "logs:read"); - const logs = includeLogs ? getLogsForInstance(instance.id) : []; - return InstanceDetailResponseSchema.parse({ instance, logs }); + const page = includeLogs + ? getRecentLogsForInstancePage({ + instanceId: instance.id, + limit: INSTANCE_DETAIL_LOG_LIMIT, + }) + : { logs: [], olderCursor: undefined }; + return InstanceDetailResponseSchema.parse({ + instance, + logs: page.logs, + olderLogsCursor: + page.olderCursor === undefined + ? undefined + : encodeLogsCursor(page.olderCursor), + }); }, { detail: { tags: ["Instances"], summary: "Get an instance", description: - "Get one instance and its logs. Logs are included for public instances, or when the key holds logs:read.", + "Get one instance and its 100 most recent logs. Logs are included for public instances, or when the key holds logs:read.", }, }, ) .put( "/api/instances/:id", ({ params, body, user }) => { - const loaded = loadOwnedInstance(params.id, user.id); - if (!loaded.ok) { - return loaded.res; - } - if (body.kind !== loaded.instance.kind) { - return status(400, { error: "Kind mismatch" }); - } - - let staticRaw: string | undefined; - if (body.kind === "static") { - const check = validateStaticRaw(body.raw); - if (!check.ok) { - return status(check.status, { error: check.error }); - } - staticRaw = check.raw; - } - - let nextWebhookIds: string[] | undefined; - if (body.webhookIds !== undefined) { - const webhooks = ensureOwnedWebhookIds(user.id, body.webhookIds); - if (!webhooks.ok) { - return status(400, { error: "Invalid webhook selection" }); - } - nextWebhookIds = webhooks.ids; - } - - const updated = updateInstance(params.id, (inst) => { - if (inst.kind === "static" && body.kind === "static") { - return { - ...inst, - raw: staticRaw ?? body.raw, - webhookIds: nextWebhookIds ?? inst.webhookIds, - }; - } - if (inst.kind === "dynamic" && body.kind === "dynamic") { - return { - ...inst, - processors: body.processors, - webhookIds: nextWebhookIds ?? inst.webhookIds, - }; - } - return inst; + const result = replaceInstance({ + instanceId: params.id, + ownerId: user.id, + raw: body.raw, + webhookIds: body.webhookIds, }); - - if (updated === undefined) { - return status(404, { error: "Not found" }); - } - return updated; + return result.ok ? result.value : serviceErrorResponse(result.error); }, { scope: "instances:write", @@ -239,7 +166,7 @@ export const instancesRoutes = new Elysia({ name: "routes/instances" }) detail: { tags: ["Instances"], summary: "Replace an instance", - description: "Replace an owned instance. The kind must match.", + description: "Replace an owned instance response.", }, }, ) @@ -295,6 +222,60 @@ export const instancesRoutes = new Elysia({ name: "routes/instances" }) }, }, ) + .get( + "/api/instances/:id/logs/recent", + async ({ params, query, request, cookie }) => { + const instance = getInstanceById(params.id); + if (instance === undefined || instance.ownerId === GUEST_OWNER_ID) { + return status(404, { error: "Not found" }); + } + const access = await resolveOptionalAccess( + request, + readSessionCookie(cookie), + ); + if (!canReadInstance({ instance, user: access?.user })) { + return status(404, { error: "Not found" }); + } + if (!enforceApiKeyRateLimit(access)) { + return status(429, { error: "Rate limit exceeded" }); + } + const publiclyReadable = canReadInstance({ + instance, + user: undefined, + }); + if (!publiclyReadable && apiKeyMissingScope(access, "logs:read")) { + return insufficientScopeResponse("logs:read"); + } + + const before = + query.cursor === undefined ? undefined : decodeLogsCursor(query.cursor); + if (query.cursor !== undefined && before === undefined) { + return status(400, { error: "Invalid cursor" }); + } + const page = getRecentLogsForInstancePage({ + instanceId: instance.id, + limit: clampLogLimit(query.limit), + before, + type: query.type, + }); + return RecentLogsPageResponseSchema.parse({ + logs: page.logs, + olderLogsCursor: + page.olderCursor === undefined + ? undefined + : encodeLogsCursor(page.olderCursor), + }); + }, + { + query: RecentLogsQuerySchema, + detail: { + tags: ["Logs"], + summary: "Read recent instance logs", + description: + "Read logs newest-page first and follow olderLogsCursor backward.", + }, + }, + ) .get( "/api/instances/:id/logs", ({ params, query, user }) => { diff --git a/packages/backend/src/server/api/oauth.ts b/packages/backend/src/server/api/oauth.ts index 14988fb..3986969 100644 --- a/packages/backend/src/server/api/oauth.ts +++ b/packages/backend/src/server/api/oauth.ts @@ -76,7 +76,7 @@ export const oauthRoutes = new Elysia({ name: "routes/oauth" }) }); } cookie.auth_token.set({ - value: await issueAuthToken(auth.user.id), + value: await issueAuthToken(auth.user.id, auth.apiKey), ...sessionCookie, }); return auth.user; diff --git a/packages/backend/src/server/api/webhooks.spec.ts b/packages/backend/src/server/api/webhooks.spec.ts new file mode 100644 index 0000000..30c0713 --- /dev/null +++ b/packages/backend/src/server/api/webhooks.spec.ts @@ -0,0 +1,74 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Elysia } from "elysia"; +import { addUser, closeDb, initDb } from "../../storage"; + +process.env.JWT_SECRET = "webhook-routes-test-secret"; +process.env.FRONTEND_URL ??= "http://localhost:5173"; +process.env.GOOGLE_CLIENT_ID ??= "test-client"; +process.env.GOOGLE_CLIENT_SECRET ??= "test-secret"; + +const { issueAuthToken } = await import("../auth"); +const { webhooksRoutes } = await import("./webhooks"); +const app = new Elysia().use(webhooksRoutes); +const originalFetch = globalThis.fetch; +let dataDir = ""; + +describe("webhook routes", () => { + beforeAll(() => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ) as unknown as typeof fetch; + }); + + afterAll(() => { + globalThis.fetch = originalFetch; + }); + + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-webhook-route-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + addUser({ id: "owner-1", googleId: "google-1", createdAt: 1 }); + }); + + afterEach(() => { + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("rate limits outbound webhook tests per user", async () => { + const token = await issueAuthToken("owner-1"); + const send = () => + app.handle( + new Request("http://localhost/api/webhooks/test", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + url: "https://discord.com/api/webhooks/123/token", + }), + }), + ); + + for (let index = 0; index < 5; index += 1) { + expect((await send()).status).toBe(204); + } + expect((await send()).status).toBe(429); + }); +}); diff --git a/packages/backend/src/server/api/webhooks.ts b/packages/backend/src/server/api/webhooks.ts index 52de360..c1ab542 100644 --- a/packages/backend/src/server/api/webhooks.ts +++ b/packages/backend/src/server/api/webhooks.ts @@ -12,11 +12,18 @@ import { updateWebhook, } from "../../storage"; import { authPlugin } from "../auth"; +import { createBoundedProtocolRateLimiter } from "../protocolRateLimit"; import { sendDiscordTestNotification, validateDiscordWebhookUrl, } from "../webhooks"; +const webhookTestRateLimiter = createBoundedProtocolRateLimiter({ + maxRequests: 5, + windowMs: 60_000, + maxEntries: 10_000, +}); + const normalizeWebhookMessage = (message?: string) => { if (message === undefined) { return undefined; @@ -54,7 +61,10 @@ export const webhooksRoutes = new Elysia({ name: "routes/webhooks" }) ) .post( "/api/webhooks/test", - async ({ body }) => { + async ({ body, user }) => { + if (!webhookTestRateLimiter.check(user.id, Date.now())) { + return status(429, { error: "Webhook test rate limit exceeded" }); + } const validation = validateDiscordWebhookUrl(body.url); if (!validation.valid) { return status(400, { diff --git a/packages/backend/src/server/apiKeyAuth.spec.ts b/packages/backend/src/server/apiKeyAuth.spec.ts index 719f8d3..cacc11a 100644 --- a/packages/backend/src/server/apiKeyAuth.spec.ts +++ b/packages/backend/src/server/apiKeyAuth.spec.ts @@ -10,6 +10,7 @@ type ApiKeySecretRecord = ApiKey & { const state = { apiKeys: new Map(), users: new Map(), + markApiKeyUsedCalls: 0, }; mock.module("../storage", () => ({ @@ -49,6 +50,7 @@ mock.module("../storage", () => ({ }, getUserById: (id: string): UserRecord | undefined => state.users.get(id), markApiKeyUsed: (id: string, lastUsedAt: number): void => { + state.markApiKeyUsedCalls += 1; for (const [prefix, apiKey] of state.apiKeys) { if (apiKey.id === id) { state.apiKeys.set(prefix, { ...apiKey, lastUsedAt }); @@ -69,6 +71,7 @@ describe("API key auth", () => { beforeEach(() => { state.apiKeys.clear(); state.users.clear(); + state.markApiKeyUsedCalls = 0; state.users.set("user-1", { id: "user-1", googleId: "google-user-1", @@ -109,6 +112,20 @@ describe("API key auth", () => { ); }); + test("persists last-used timestamps at most once per minute", async () => { + const { authenticateApiKeyValue, createApiKeyForUser } = + await loadAuthModule(); + const created = createApiKeyForUser({ + userId: "user-1", + name: "Claude Desktop", + }); + + expect(authenticateApiKeyValue(created.secret)).toBeDefined(); + expect(authenticateApiKeyValue(created.secret)).toBeDefined(); + + expect(state.markApiKeyUsedCalls).toBe(1); + }); + test("rejects deleted and malformed keys", async () => { const { authenticateApiKeyValue, createApiKeyForUser } = await loadAuthModule(); diff --git a/packages/backend/src/server/apiKeyAuth.ts b/packages/backend/src/server/apiKeyAuth.ts index 1c7c60b..8836e3d 100644 --- a/packages/backend/src/server/apiKeyAuth.ts +++ b/packages/backend/src/server/apiKeyAuth.ts @@ -27,6 +27,38 @@ export type ApiKeyAuthContext = { user: User; }; +const API_KEY_LAST_USED_WRITE_INTERVAL_MS = 60_000; +const MAX_TRACKED_API_KEY_USAGE = 10_000; +const lastUsageWriteByKey = new Map(); + +const recordApiKeyUsageIfStale = (apiKey: ApiKey, now: number): void => { + const lastPersistedOrScheduled = Math.max( + apiKey.lastUsedAt ?? 0, + lastUsageWriteByKey.get(apiKey.id) ?? 0, + ); + if (now - lastPersistedOrScheduled < API_KEY_LAST_USED_WRITE_INTERVAL_MS) { + return; + } + + if (lastUsageWriteByKey.size >= MAX_TRACKED_API_KEY_USAGE) { + const staleBefore = now - API_KEY_LAST_USED_WRITE_INTERVAL_MS; + for (const [id, lastUsedAt] of lastUsageWriteByKey) { + if (lastUsedAt <= staleBefore) { + lastUsageWriteByKey.delete(id); + } + } + } + if (lastUsageWriteByKey.size >= MAX_TRACKED_API_KEY_USAGE) { + const oldestId = lastUsageWriteByKey.keys().next().value; + if (oldestId !== undefined) { + lastUsageWriteByKey.delete(oldestId); + } + } + + markApiKeyUsed(apiKey.id, now); + lastUsageWriteByKey.set(apiKey.id, now); +}; + const randomKeyToken = (bytes: number) => randomBytes(bytes).toString("hex"); export const hashApiKeySecret = (secret: string): string => { @@ -120,7 +152,7 @@ export const authenticateApiKeyValue = ( return undefined; } - markApiKeyUsed(apiKey.id, now); + recordApiKeyUsageIfStale(apiKey, now); const { secretHash: _secretHash, ...publicApiKey } = apiKey; return { apiKey: { ...publicApiKey, lastUsedAt: now }, diff --git a/packages/backend/src/server/auth.ts b/packages/backend/src/server/auth.ts index c3ecb4d..0c2b5d4 100644 --- a/packages/backend/src/server/auth.ts +++ b/packages/backend/src/server/auth.ts @@ -1,32 +1,30 @@ import { Elysia, status } from "elysia"; import * as jose from "jose"; import type { ApiKey, ApiKeyScope, User } from "shared"; -import { getUserById, toPublicUser } from "../storage"; +import { getActiveApiKeyById, getUserById, toPublicUser } from "../storage"; import type { ApiKeyAuthContext } from "./apiKeyAuth"; import { authenticateApiKeyValue, hasApiKeyScope } from "./apiKeyAuth"; +import { jwtSecret } from "./jwtSecret"; import { createFixedWindowRateLimiter } from "./rateLimit"; -const jwtSecret = Bun.env.JWT_SECRET; +const AUTH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; -if (jwtSecret === undefined || jwtSecret === "") { - throw new Error("JWT_SECRET must be set"); -} - -if (jwtSecret === "your-jwt-secret-here") { - throw new Error( - "JWT_SECRET must be changed, you are using the default secret", +export const issueAuthToken = async ( + userId: string, + apiKey?: Pick, +) => { + const now = Date.now(); + const expiresAt = Math.min( + now + AUTH_TOKEN_TTL_MS, + apiKey?.expiresAt ?? Number.POSITIVE_INFINITY, ); -} - -const secret = new TextEncoder().encode(jwtSecret); - -export const issueAuthToken = async (userId: string) => { - return await new jose.SignJWT({}) + const claims = apiKey === undefined ? {} : { apiKeyId: apiKey.id }; + return await new jose.SignJWT(claims) .setProtectedHeader({ alg: "HS256" }) .setSubject(userId) .setIssuedAt() - .setExpirationTime("30d") - .sign(secret); + .setExpirationTime(Math.floor(expiresAt / 1000)) + .sign(jwtSecret); }; const extractBearer = (header: string) => { @@ -40,7 +38,7 @@ const extractBearer = (header: string) => { const authenticateSession = async ( req: Request, sessionCookie?: string, -): Promise => { +): Promise<{ user: User; apiKey?: ApiKey } | undefined> => { const authHeader = req.headers.get("authorization"); const bearer = authHeader !== null ? extractBearer(authHeader) : undefined; const token = bearer ?? sessionCookie; @@ -50,9 +48,12 @@ const authenticateSession = async ( } let sub: string | undefined; + let apiKeyId: string | undefined; try { - const { payload } = await jose.jwtVerify(token, secret); + const { payload } = await jose.jwtVerify(token, jwtSecret); sub = typeof payload.sub === "string" ? payload.sub : undefined; + apiKeyId = + typeof payload.apiKeyId === "string" ? payload.apiKeyId : undefined; } catch { return undefined; } @@ -62,7 +63,17 @@ const authenticateSession = async ( } const userRecord = getUserById(sub); - return userRecord === undefined ? undefined : toPublicUser(userRecord); + if (userRecord === undefined) { + return undefined; + } + if (apiKeyId === undefined) { + return { user: toPublicUser(userRecord) }; + } + const apiKey = getActiveApiKeyById(apiKeyId, Date.now()); + if (apiKey?.userId !== sub) { + return undefined; + } + return { user: toPublicUser(userRecord), apiKey }; }; type AccessContext = @@ -124,8 +135,8 @@ const resolveAccessOutcome = async ( return { ok: true, context: apiKeyContext(apiKey.auth) }; } - const user = await authenticateSession(req, sessionCookie); - if (user === undefined) { + const session = await authenticateSession(req, sessionCookie); + if (session === undefined) { return { ok: false, httpStatus: 401, @@ -133,7 +144,16 @@ const resolveAccessOutcome = async ( wwwAuthenticate: bearerRealm, }; } - return { ok: true, context: { via: "session", user } }; + if (session.apiKey !== undefined) { + if (!apiKeyRateLimiter.check(session.apiKey.id)) { + return { ok: false, httpStatus: 429, error: "Rate limit exceeded" }; + } + return { + ok: true, + context: { via: "api_key", user: session.user, apiKey: session.apiKey }, + }; + } + return { ok: true, context: { via: "session", user: session.user } }; }; export const resolveOptionalAccess = async ( @@ -144,8 +164,13 @@ export const resolveOptionalAccess = async ( if (apiKey.present) { return apiKey.auth === undefined ? undefined : apiKeyContext(apiKey.auth); } - const user = await authenticateSession(req, sessionCookie); - return user === undefined ? undefined : { via: "session", user }; + const session = await authenticateSession(req, sessionCookie); + if (session === undefined) { + return undefined; + } + return session.apiKey === undefined + ? { via: "session", user: session.user } + : { via: "api_key", user: session.user, apiKey: session.apiKey }; }; export const enforceApiKeyRateLimit = ( @@ -184,15 +209,21 @@ export const readSessionCookie = (cookie: { export const authPlugin = new Elysia({ name: "auth" }).macro({ session: { async resolve({ request, set, cookie }) { - const user = await authenticateSession( + const session = await authenticateSession( request, readSessionCookie(cookie), ); - if (user === undefined) { + if (session === undefined) { set.headers["WWW-Authenticate"] = bearerRealm; return status(401, { error: "Unauthorized" }); } - return { user }; + if ( + session.apiKey !== undefined && + !apiKeyRateLimiter.check(session.apiKey.id) + ) { + return status(429, { error: "Rate limit exceeded" }); + } + return { user: session.user }; }, }, scope(required: ApiKeyScope) { diff --git a/packages/backend/src/server/dns/server.spec.ts b/packages/backend/src/server/dns/server.spec.ts index 6b993e8..e46ec6e 100644 --- a/packages/backend/src/server/dns/server.spec.ts +++ b/packages/backend/src/server/dns/server.spec.ts @@ -10,17 +10,6 @@ import { type DnsServerDependencies, } from "./server"; -const createInstance = (id: string) => ({ - id, - ownerId: "owner-1", - createdAt: 1, - webhookIds: [] as string[], - public: false, - locked: false, - kind: "static" as const, - raw: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", -}); - const createBaseDeps = () => ({ broadcastLog: () => {}, createId: () => "log-id", @@ -210,8 +199,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async (id) => - id === "demo" ? createInstance("demo") : undefined, + hasActiveInstance: async (id) => id === "demo", addLog: async (log) => { logs.push(log); return log; @@ -268,8 +256,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async (id) => - id === "demo" ? createInstance("demo") : undefined, + hasActiveInstance: async (id) => id === "demo", addLog: async (log) => { logs.push(log.raw); return log; @@ -306,7 +293,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async () => undefined, + hasActiveInstance: async () => false, addLog: async (log) => { logs.push(log.id); return log; @@ -341,7 +328,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async () => createInstance("demo"), + hasActiveInstance: async () => true, addLog: async (log) => { logs.push(log.id); return log; @@ -385,7 +372,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async () => createInstance("demo"), + hasActiveInstance: async () => true, addLog: async (log) => log, }, }); @@ -415,7 +402,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async () => undefined, + hasActiveInstance: async () => false, addLog: async (log) => log, }, }); @@ -463,8 +450,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async (id) => - id === "demo" ? createInstance("demo") : undefined, + hasActiveInstance: async (id) => id === "demo", addLog: async (log) => { logs.push(log.raw); return log; @@ -511,8 +497,7 @@ describe("createDnsServer", () => { config: createTestDnsConfig(), deps: { ...createBaseDeps(), - getInstanceById: async (id) => - id === "demo" ? createInstance("demo") : undefined, + hasActiveInstance: async (id) => id === "demo", addLog: async (log) => { logs.push(log.raw); return log; @@ -543,8 +528,7 @@ describe("createDnsServer", () => { describe("handleDnsRequest", () => { const deps: DnsServerDependencies = { ...createBaseDeps(), - getInstanceById: async (id: string) => - id === "demo" ? createInstance("demo") : undefined, + hasActiveInstance: async (id: string) => id === "demo", addLog: async (log) => log, }; @@ -870,8 +854,7 @@ describe("handleDnsRequest", () => { deps: { ...deps, now: () => now, - getInstanceById: async (id: string) => - id === "rate" ? createInstance("rate") : undefined, + hasActiveInstance: async (id: string) => id === "rate", addLog: async (log) => { logs.push(log.id); return log; @@ -915,8 +898,7 @@ describe("handleDnsRequest", () => { deps: { ...deps, now: () => now, - getInstanceById: async (id: string) => - id === "rate" ? createInstance("rate") : undefined, + hasActiveInstance: async (id: string) => id === "rate", addLog: async (log) => { logs.push(log.id); return log; diff --git a/packages/backend/src/server/dns/server.ts b/packages/backend/src/server/dns/server.ts index 215d45b..1350d3c 100644 --- a/packages/backend/src/server/dns/server.ts +++ b/packages/backend/src/server/dns/server.ts @@ -1,8 +1,10 @@ import { createServer, type Server as TcpServer, type Socket } from "node:net"; import dgram, { type RemoteInfo, type Socket as UdpSocket } from "node:dgram"; import type { Packet } from "dns-packet"; -import type { Instance, Log } from "shared"; +import type { Log } from "shared"; +import type { AddLogOutcome } from "../../storage"; import { dnsConfig, type DnsConfig } from "../../config"; +import { createBoundedProtocolRateLimiter } from "../protocolRateLimit"; import { buildDnsAcmeChallengeAnswers, buildDnsInstanceAnswers, @@ -27,16 +29,15 @@ const dnsLogWindowMs = 60_000; const maxDnsLogsPerWindow = 120; const maxDnsLogRateLimitEntries = 10_000; -type DnsLogRateLimitEntry = { - windowStartedAt: number; - count: number; -}; - -const dnsLogRateLimit = new Map(); +const dnsLogRateLimit = createBoundedProtocolRateLimiter({ + maxRequests: maxDnsLogsPerWindow, + windowMs: dnsLogWindowMs, + maxEntries: maxDnsLogRateLimitEntries, +}); export type DnsServerDependencies = { - getInstanceById: (id: string) => Promise; - addLog: (log: Log) => Promise; + hasActiveInstance: (id: string) => Promise; + addLog: (log: Log) => Log | AddLogOutcome | Promise; broadcastLog: (log: Log) => void; createId: () => string; now: () => number; @@ -99,51 +100,13 @@ const getFirstQuestion = (request: { return request.questions?.[0]; }; -const pruneDnsLogRateLimit = (now: number): void => { - for (const [key, entry] of dnsLogRateLimit) { - if (now - entry.windowStartedAt >= dnsLogWindowMs) { - dnsLogRateLimit.delete(key); - } - } - - while (dnsLogRateLimit.size > maxDnsLogRateLimitEntries) { - const oldestKey = dnsLogRateLimit.keys().next().value; - if (oldestKey === undefined) { - break; - } - - dnsLogRateLimit.delete(oldestKey); - } -}; - const shouldPersistDnsLog = (params: { instanceId: string; clientAddress: string; now: number; }): boolean => { - if (dnsLogRateLimit.size >= maxDnsLogRateLimitEntries) { - pruneDnsLogRateLimit(params.now); - } - const key = `${params.instanceId}:${params.clientAddress}`; - const entry = dnsLogRateLimit.get(key); - if ( - entry === undefined || - params.now - entry.windowStartedAt >= dnsLogWindowMs - ) { - dnsLogRateLimit.set(key, { - windowStartedAt: params.now, - count: 1, - }); - return true; - } - - if (entry.count >= maxDnsLogsPerWindow) { - return false; - } - - entry.count += 1; - return true; + return dnsLogRateLimit.check(key, params.now); }; const encodeDnsResponse = ( @@ -233,8 +196,10 @@ export const handleDnsRequest = async ({ return encodeDnsResponse(transport, response); } case "instance": { - const instance = await deps.getInstanceById(resolution.instanceId); - if (instance === undefined) { + const instanceExists = await deps.hasActiveInstance( + resolution.instanceId, + ); + if (!instanceExists) { const response = buildDnsResponse({ request, code: DNS_RCODE.NXDOMAIN, @@ -252,14 +217,14 @@ export const handleDnsRequest = async ({ const timestamp = deps.now(); if ( shouldPersistDnsLog({ - instanceId: instance.id, + instanceId: resolution.instanceId, clientAddress, now: timestamp, }) ) { const log = { id: deps.createId(), - instanceId: instance.id, + instanceId: resolution.instanceId, type: "dns", timestamp, address: clientAddress, @@ -272,8 +237,18 @@ export const handleDnsRequest = async ({ } satisfies Log; try { - await deps.addLog(log); - deps.broadcastLog(log); + const pendingOutcome = deps.addLog(log); + const outcome = + pendingOutcome instanceof Promise + ? await pendingOutcome + : pendingOutcome; + if ( + outcome === undefined || + !("tombstoned" in outcome) || + !outcome.tombstoned + ) { + deps.broadcastLog(log); + } } catch (error) { console.error("Failed to persist DNS log", error); } @@ -387,7 +362,10 @@ const createTcpServer = ( } pending = Buffer.concat([pending, chunk]); - void flush(); + flush().catch((error) => { + console.error("Failed to process DNS TCP request", error); + socket.destroy(); + }); }); socket.on("error", (error) => { @@ -403,7 +381,7 @@ const createUdpServer = ( const udpServer = dgram.createSocket("udp4"); udpServer.on("message", (message: Buffer, remote: RemoteInfo) => { - void (async () => { + const handleMessage = async () => { const response = await handleDnsRequest({ payload: message, transport: "udp", @@ -421,7 +399,10 @@ const createUdpServer = ( console.error("Failed to send DNS UDP response", error); } }); - })(); + }; + handleMessage().catch((error) => { + console.error("Failed to process DNS UDP request", error); + }); }); udpServer.on("error", (error) => { diff --git a/packages/backend/src/server/guestAccess.spec.ts b/packages/backend/src/server/guestAccess.spec.ts new file mode 100644 index 0000000..73bbb90 --- /dev/null +++ b/packages/backend/src/server/guestAccess.spec.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { closeDb, initDb } from "../storage"; + +process.env.JWT_SECRET = "guest-access-test-secret"; + +const { + authenticateGuestInstance, + issueGuestManagementToken, + readGuestManagementToken, + readGuestWebSocketProtocol, +} = await import("./guestAccess"); +const { issueAuthToken } = await import("./auth"); +const { createGuestInstance } = await import("./instances/service"); + +let dataDir = ""; + +describe("guest management access", () => { + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-guest-access-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + }); + + afterEach(() => { + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("authenticates only the generated token and parses safe transports", async () => { + const created = await createGuestInstance("HTTP/1.1 200 OK\n\nok"); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + + expect( + await authenticateGuestInstance( + created.value.instance, + created.value.token, + ), + ).toBe(true); + expect( + await authenticateGuestInstance( + { ...created.value.instance, id: "other-id" }, + created.value.token, + ), + ).toBe(false); + expect( + await authenticateGuestInstance( + { + ...created.value.instance, + createdAt: created.value.instance.createdAt + 1, + }, + created.value.token, + ), + ).toBe(false); + + const sessionToken = await issueAuthToken(created.value.instance.id); + expect( + await authenticateGuestInstance(created.value.instance, sessionToken), + ).toBe(false); + + const expired = await issueGuestManagementToken( + created.value.instance, + Date.now() - 2_000, + ); + expect( + await authenticateGuestInstance(created.value.instance, expired), + ).toBe(false); + + const request = new Request("http://localhost", { + headers: { + "x-guest-token": created.value.token, + "sec-websocket-protocol": `other, guest-token.${created.value.token}`, + }, + }); + expect(readGuestManagementToken(request)).toBe(created.value.token); + expect(readGuestWebSocketProtocol(request)).toEqual({ + protocol: `guest-token.${created.value.token}`, + token: created.value.token, + }); + }); +}); diff --git a/packages/backend/src/server/guestAccess.ts b/packages/backend/src/server/guestAccess.ts new file mode 100644 index 0000000..0daa1a6 --- /dev/null +++ b/packages/backend/src/server/guestAccess.ts @@ -0,0 +1,111 @@ +import { createHmac } from "node:crypto"; +import * as jose from "jose"; +import { + GuestManagementTokenSchema, + type GuestInstanceReference, + type Instance, +} from "shared"; +import { jwtSecret } from "./jwtSecret"; + +const GUEST_TOKEN_HEADER = "x-guest-token"; +const GUEST_WEBSOCKET_PROTOCOL_PREFIX = "guest-token."; +const GUEST_TOKEN_AUDIENCE = "httpworkbench:guest-instance"; +const GUEST_TOKEN_ISSUER = "httpworkbench"; +const GUEST_TOKEN_KIND = "guest_instance"; +const guestTokenSecret = createHmac("sha256", jwtSecret) + .update(GUEST_TOKEN_AUDIENCE) + .digest(); + +type GuestInstanceIdentity = Pick; + +export const issueGuestManagementToken = async ( + instance: GuestInstanceIdentity, + expiresAt: number, +) => + await new jose.SignJWT({ + kind: GUEST_TOKEN_KIND, + createdAt: instance.createdAt, + }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuer(GUEST_TOKEN_ISSUER) + .setAudience(GUEST_TOKEN_AUDIENCE) + .setSubject(instance.id) + .setIssuedAt() + .setExpirationTime(Math.ceil(expiresAt / 1000)) + .sign(guestTokenSecret); + +export const authenticateGuestInstance = async ( + instance: GuestInstanceIdentity, + token: string | undefined, +): Promise => { + if ( + token === undefined || + !GuestManagementTokenSchema.safeParse(token).success + ) { + return false; + } + try { + const { payload } = await jose.jwtVerify(token, guestTokenSecret, { + algorithms: ["HS256"], + audience: GUEST_TOKEN_AUDIENCE, + issuer: GUEST_TOKEN_ISSUER, + subject: instance.id, + typ: "JWT", + requiredClaims: ["exp", "iat", "kind", "createdAt"], + }); + return ( + payload.kind === GUEST_TOKEN_KIND && + payload.createdAt === instance.createdAt + ); + } catch { + return false; + } +}; + +export const authenticateGuestInstanceReferences = async ( + references: GuestInstanceReference[], + instances: GuestInstanceIdentity[], +): Promise => { + const uniqueIds = [...new Set(references.map(({ id }) => id))]; + const instancesById = new Map( + instances.map((instance) => [instance.id, instance]), + ); + const results = await Promise.all( + references.map(async ({ id, token }) => { + const instance = instancesById.get(id); + return { + id, + authenticated: + instance !== undefined && + (await authenticateGuestInstance(instance, token)), + }; + }), + ); + const authorizedIds = new Set( + results.flatMap(({ id, authenticated }) => (authenticated ? [id] : [])), + ); + return uniqueIds.filter((id) => authorizedIds.has(id)); +}; + +export const readGuestManagementToken = (request: Request) => + request.headers.get(GUEST_TOKEN_HEADER) ?? undefined; + +export const readGuestWebSocketProtocol = ( + request: Request, +): { protocol: string; token: string } | undefined => { + const protocols = request.headers + .get("sec-websocket-protocol") + ?.split(",") + .map((protocol) => protocol.trim()); + const protocol = protocols?.find((candidate) => + candidate.startsWith(GUEST_WEBSOCKET_PROTOCOL_PREFIX), + ); + if (protocol === undefined) { + return undefined; + } + const token = protocol.slice(GUEST_WEBSOCKET_PROTOCOL_PREFIX.length); + if (!GuestManagementTokenSchema.safeParse(token).success) { + return undefined; + } + return { protocol, token }; +}; diff --git a/packages/backend/src/server/index.spec.ts b/packages/backend/src/server/index.spec.ts new file mode 100644 index 0000000..3ccf340 --- /dev/null +++ b/packages/backend/src/server/index.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { INSTANCE_RAW_LIMIT_BYTES } from "shared"; +import { addUser, closeDb, initDb } from "../storage"; + +process.env.JWT_SECRET = "api-server-test-secret"; +process.env.GOOGLE_CLIENT_ID = "api-server-test-client"; +process.env.GOOGLE_CLIENT_SECRET = "api-server-test-client-secret"; +process.env.FRONTEND_URL = "http://localhost:5173"; + +const { issueAuthToken } = await import("./auth"); +const { buildApiServer } = await import("./index"); + +let dataDir = ""; +let apiServer: ReturnType | undefined; + +describe("API server transport limits", () => { + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-api-server-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + addUser({ id: "owner-1", googleId: "google-1", createdAt: 1 }); + }); + + afterEach(() => { + apiServer?.stop(true); + apiServer = undefined; + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("accepts a maximally sized raw response after JSON escaping", async () => { + apiServer = buildApiServer(0); + const token = await issueAuthToken("owner-1"); + const prefix = "HTTP/1.1 200 OK\r\n\r\n"; + const raw = `${prefix}${"\\".repeat( + INSTANCE_RAW_LIMIT_BYTES - prefix.length, + )}`; + const body = JSON.stringify({ raw }); + expect(new TextEncoder().encode(body).length).toBeGreaterThan( + 12 * 1024 * 1024, + ); + + const response = await fetch( + `http://127.0.0.1:${apiServer.server?.port}/api/instances`, + { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body, + }, + ); + + expect(response.status).toBe(201); + await response.body?.cancel(); + }, 30_000); +}); diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index 3d721e1..ee13fb2 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -1,12 +1,19 @@ import { Elysia, status } from "elysia"; import { - addLog, + GUEST_INSTANCE_TTL_MS, + GUEST_OWNER_ID, + INSTANCE_RAW_LIMIT_BYTES, +} from "shared"; +import { + addLogWithOutcome, flushPendingWebhookNotifications, - getInstanceById, + getInstanceAccessMetadata, + hasActiveInstance, removeExpiredInstances, } from "../storage"; import { dnsConfig, instancePolicies, smtpConfig } from "../config"; import { + aiRoutes, apiKeysRoutes, guestInstancesRoutes, instancesRoutes, @@ -20,9 +27,13 @@ import { readSessionCookie, resolveOptionalAccess, } from "./auth"; -import { handleMcpRequest } from "./mcp"; +import { closeMcpHandler, handleMcpRequest } from "./mcp"; import { openApiPlugin } from "./openapi"; import { canReadInstance } from "./instances/access"; +import { + authenticateGuestInstance, + readGuestWebSocketProtocol, +} from "./guestAccess"; import { broadcastLog, createInstancesServer, @@ -31,9 +42,21 @@ import { } from "./instances"; import { createDnsServer } from "./dns"; import { createSmtpServer } from "./smtp"; +import { createBoundedProtocolRateLimiter } from "./protocolRateLimit"; +import { isAllowedBrowserOrigin } from "./webSocketOrigin"; import { version } from "../version"; -const buildApiServer = (port: number) => { +const guestStreamRateLimiter = createBoundedProtocolRateLimiter({ + maxRequests: 60, + windowMs: 60_000, + maxEntries: 10_000, +}); + +// JSON.stringify can expand one string character to a six-byte escape. Leave +// additional room for the request envelope and bounded companion fields. +const maxRequestBodySize = INSTANCE_RAW_LIMIT_BYTES * 6 + 1024 * 1024; + +export const buildApiServer = (port: number) => { return new Elysia() .onError({ as: "global" }, ({ code, error, set }) => { if (code === "VALIDATION") { @@ -62,6 +85,7 @@ const buildApiServer = (port: number) => { .get("/api/health", () => ({ status: "ok" }), { detail: { hide: true } }) .get("/api/version", () => ({ version }), { detail: { hide: true } }) .use(oauthRoutes) + .use(aiRoutes) .use(userRoutes) .use(instancesRoutes) .use(guestInstancesRoutes) @@ -69,10 +93,29 @@ const buildApiServer = (port: number) => { .use(apiKeysRoutes) .ws("/api/instances/:id/stream", { async beforeHandle({ params, request, set, cookie }) { - const instance = getInstanceById(params.id); + if (!isAllowedBrowserOrigin(request)) { + return status(403, { error: "Invalid origin" }); + } + const instance = getInstanceAccessMetadata(params.id); if (instance === undefined) { return status(404, { error: "Not found" }); } + if (instance.ownerId === GUEST_OWNER_ID) { + const clientKey = + request.headers.get("x-internal-real-ip") ?? "unknown"; + if (!guestStreamRateLimiter.check(clientKey, Date.now())) { + return status(429, { error: "Guest stream rate limit exceeded" }); + } + const guestProtocol = readGuestWebSocketProtocol(request); + if ( + guestProtocol === undefined || + !(await authenticateGuestInstance(instance, guestProtocol.token)) + ) { + return status(404, { error: "Not found" }); + } + set.headers["Sec-WebSocket-Protocol"] = guestProtocol.protocol; + return; + } const access = await resolveOptionalAccess( request, readSessionCookie(cookie), @@ -107,7 +150,7 @@ const buildApiServer = (port: number) => { unsubscribeFromLogStream(ws.data.params.id, ws.raw); }, }) - .listen(port, () => { + .listen({ port, maxRequestBodySize }, () => { console.log(`API server running on port ${port}`); }); }; @@ -122,8 +165,8 @@ export const initServer = async () => { ? await createDnsServer({ config: dnsConfig, deps: { - getInstanceById: async (id) => getInstanceById(id), - addLog: async (log) => addLog(log), + hasActiveInstance: async (id) => hasActiveInstance(id), + addLog: addLogWithOutcome, broadcastLog, createId: () => crypto.randomUUID(), now: () => Date.now(), @@ -135,8 +178,8 @@ export const initServer = async () => { ? await createSmtpServer({ config: smtpConfig, deps: { - getInstanceById: async (id) => getInstanceById(id), - addLog: async (log) => addLog(log), + hasActiveInstance: async (id) => hasActiveInstance(id), + addLog: addLogWithOutcome, broadcastLog, createId: () => crypto.randomUUID(), now: () => Date.now(), @@ -146,8 +189,14 @@ export const initServer = async () => { let cleanupInterval: ReturnType | undefined; const defaultTtlMs = instancePolicies.defaultTtlMs; - if (defaultTtlMs !== undefined) { - const intervalMs = Math.min(defaultTtlMs, 60 * 60 * 1000); + if (defaultTtlMs !== undefined || instancePolicies.allowGuest) { + const shortestTtlMs = Math.min( + defaultTtlMs ?? Number.POSITIVE_INFINITY, + instancePolicies.allowGuest + ? GUEST_INSTANCE_TTL_MS + : Number.POSITIVE_INFINITY, + ); + const intervalMs = Math.min(shortestTtlMs, 60 * 60 * 1000); const runCleanup = () => { try { removeExpiredInstances(Date.now()); @@ -167,6 +216,7 @@ export const initServer = async () => { }; const drainBackgroundWork = async () => { + await closeMcpHandler(); await flushPendingWebhookNotifications(); }; diff --git a/packages/backend/src/server/instances/access.spec.ts b/packages/backend/src/server/instances/access.spec.ts index 28ec23a..a0af125 100644 --- a/packages/backend/src/server/instances/access.spec.ts +++ b/packages/backend/src/server/instances/access.spec.ts @@ -17,7 +17,6 @@ const otherUser: User = { const privateInstance: Instance = { id: "instance-1", - kind: "static", ownerId: user.id, createdAt: 1, raw: "HTTP/1.1 200 OK\r\n\r\nok", @@ -57,7 +56,7 @@ describe("canReadInstance", () => { ).toBe(true); }); - test("allows guest-owned instances to be read without auth", () => { + test("requires the dedicated guest management path for guest instances", () => { expect( canReadInstance({ instance: { @@ -66,6 +65,6 @@ describe("canReadInstance", () => { }, user: undefined, }), - ).toBe(true); + ).toBe(false); }); }); diff --git a/packages/backend/src/server/instances/access.ts b/packages/backend/src/server/instances/access.ts index 1a5181e..e24f50f 100644 --- a/packages/backend/src/server/instances/access.ts +++ b/packages/backend/src/server/instances/access.ts @@ -4,11 +4,11 @@ export const canReadInstance = ({ instance, user, }: { - instance: Instance; + instance: Pick; user: User | undefined; }) => { if (instance.ownerId === GUEST_OWNER_ID) { - return true; + return false; } if (user?.id === instance.ownerId) { diff --git a/packages/backend/src/server/instances/httpBuffer.spec.ts b/packages/backend/src/server/instances/httpBuffer.spec.ts index 0933904..c98f07c 100644 --- a/packages/backend/src/server/instances/httpBuffer.spec.ts +++ b/packages/backend/src/server/instances/httpBuffer.spec.ts @@ -41,6 +41,36 @@ test("waits for the full body when content-length is provided", () => { expect(buffer.isComplete()).toBe(true); }); +test("recognizes a header delimiter split across packets", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode("GET / HTTP/1.1\r\nHost: demo.instances.example.com\r\n\r"), + ); + + expect(buffer.isComplete()).toBe(false); + + buffer.append(encode("\n")); + + expect(buffer.hasError()).toBe(false); + expect(buffer.isComplete()).toBe(true); +}); + +test("preserves fragmented request bodies while growing", () => { + const body = "a".repeat(1024 * 1024); + const rawRequest = encode( + `POST / HTTP/1.1\r\nHost: demo.instances.example.com\r\nContent-Length: ${body.length}\r\n\r\n${body}`, + ); + const buffer = new HttpRequestBuffer(); + + for (let offset = 0; offset < rawRequest.length; offset += 4096) { + buffer.append(rawRequest.subarray(offset, offset + 4096)); + } + + expect(buffer.hasError()).toBe(false); + expect(buffer.isComplete()).toBe(true); + expect(buffer.getRaw()).toEndWith(body); +}); + test("treats a request without content-length as complete after headers", () => { const rawRequest = [ "GET / HTTP/1.1", @@ -82,6 +112,156 @@ test("rejects negative content-length values", () => { expect(buffer.getError()).toBe("Invalid Content-Length"); }); +test("rejects non-decimal content-length values", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length: 5junk", + "", + "hello", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe("Invalid Content-Length"); +}); + +test("accepts matching duplicate content-length values", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length: 5", + "Content-Length: 5", + "", + "hello", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(false); + expect(buffer.isComplete()).toBe(true); +}); + +test("rejects conflicting duplicate content-length values", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length: 5", + "Content-Length: 10", + "", + "hello", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe("Conflicting Content-Length headers"); +}); + +test("rejects transfer-encoded requests", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Transfer-Encoding: chunked", + "", + "", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe("Transfer-Encoding is not supported"); +}); + +test("rejects requests with transfer-encoding and content-length", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Transfer-Encoding: chunked", + "Content-Length: 5", + "", + "", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe( + "Conflicting Transfer-Encoding and Content-Length", + ); +}); + +test("rejects whitespace before a header colon", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length : 5", + "", + "hello", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe("Invalid header name"); +}); + +test("rejects obsolete folded headers", () => { + const buffer = new HttpRequestBuffer(); + buffer.append( + encode( + [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length: 5", + " 5", + "", + "hello", + ].join("\r\n"), + ), + ); + + expect(buffer.hasError()).toBe(true); + expect(buffer.getError()).toBe("Obsolete folded headers are not supported"); +}); + +test("returns only the framed request when extra bytes follow", () => { + const buffer = new HttpRequestBuffer(); + const firstRequest = [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + "Content-Length: 5", + "", + "hello", + ].join("\r\n"); + buffer.append( + encode(`${firstRequest}GET /smuggled HTTP/1.1\r\nHost: other\r\n\r\n`), + ); + + expect(buffer.hasError()).toBe(false); + expect(buffer.isComplete()).toBe(true); + expect(buffer.getRaw()).toBe(firstRequest); +}); + test("rejects bodies larger than the configured limit", () => { const rawRequest = [ "POST / HTTP/1.1", diff --git a/packages/backend/src/server/instances/httpBuffer.ts b/packages/backend/src/server/instances/httpBuffer.ts index 971ccc7..74decc9 100644 --- a/packages/backend/src/server/instances/httpBuffer.ts +++ b/packages/backend/src/server/instances/httpBuffer.ts @@ -3,8 +3,13 @@ const MAX_HEADER_SIZE = 8 * 1024; const MAX_BODY_SIZE = 32 * 1024 * 1024; const MAX_REQUEST_SIZE = MAX_HEADER_SIZE + MAX_BODY_SIZE; +type FramingResult = + { ok: true; contentLength: number } | { ok: false; error: string }; + export class HttpRequestBuffer { private buffer: Uint8Array = new Uint8Array(0); + private length = 0; + private headerScanIndex = 0; private headersEndIndex: number | undefined; private expectedLength: number | undefined; private error: string | undefined; @@ -12,14 +17,12 @@ export class HttpRequestBuffer { append(data: Uint8Array): void { if (this.error !== undefined) return; - const newLength = this.buffer.length + data.length; - + const newLength = this.length + data.length; const nextLength = Math.min(newLength, MAX_REQUEST_SIZE); - const appendedLength = Math.max(0, nextLength - this.buffer.length); - const result = new Uint8Array(nextLength); - result.set(this.buffer, 0); - result.set(data.slice(0, appendedLength), this.buffer.length); - this.buffer = result; + const appendedLength = Math.max(0, nextLength - this.length); + this.ensureCapacity(nextLength); + this.buffer.set(data.subarray(0, appendedLength), this.length); + this.length = nextLength; if (newLength > MAX_REQUEST_SIZE) { this.error = "Request too large"; @@ -29,7 +32,7 @@ export class HttpRequestBuffer { if (this.headersEndIndex === undefined) { const headersEnd = this.findHeadersEnd(); if (headersEnd === -1) { - if (this.buffer.length > MAX_HEADER_SIZE) { + if (this.length > MAX_HEADER_SIZE) { this.error = "Headers too large"; } return; @@ -42,21 +45,20 @@ export class HttpRequestBuffer { this.headersEndIndex = headersEnd; const headerString = new TextDecoder().decode( - this.buffer.slice(0, headersEnd), + this.buffer.subarray(0, headersEnd), ); - const contentLength = this.parseContentLength(headerString); - - if (contentLength !== undefined && contentLength < 0) { - this.error = "Invalid Content-Length"; + const framing = this.parseFraming(headerString); + if (!framing.ok) { + this.error = framing.error; return; } - if (contentLength !== undefined && contentLength > MAX_BODY_SIZE) { + if (framing.contentLength > MAX_BODY_SIZE) { this.error = "Body too large"; return; } - this.expectedLength = headersEnd + (contentLength ?? 0); + this.expectedLength = headersEnd + framing.contentLength; } } @@ -64,7 +66,7 @@ export class HttpRequestBuffer { if (this.headersEndIndex === undefined) { return false; } - return this.buffer.length >= (this.expectedLength ?? 0); + return this.length >= (this.expectedLength ?? 0); } hasError(): boolean { @@ -76,11 +78,16 @@ export class HttpRequestBuffer { } getRaw(): string { - return new TextDecoder().decode(this.buffer); + const framedLength = + this.error === undefined && this.expectedLength !== undefined + ? Math.min(this.length, this.expectedLength) + : this.length; + return new TextDecoder().decode(this.buffer.subarray(0, framedLength)); } private findHeadersEnd(): number { - for (let i = 0; i <= this.buffer.length - HEADERS_SEPARATOR.length; i++) { + const lastStart = this.length - HEADERS_SEPARATOR.length; + for (let i = this.headerScanIndex; i <= lastStart; i++) { let found = true; for (let j = 0; j < HEADERS_SEPARATOR.length; j++) { if (this.buffer[i + j] !== HEADERS_SEPARATOR[j]) { @@ -92,21 +99,87 @@ export class HttpRequestBuffer { return i + HEADERS_SEPARATOR.length; } } + this.headerScanIndex = Math.max( + 0, + this.length - HEADERS_SEPARATOR.length + 1, + ); return -1; } - private parseContentLength(headerString: string): number | undefined { + private ensureCapacity(requiredLength: number): void { + if (requiredLength <= this.buffer.length) { + return; + } + + let capacity = Math.max(this.buffer.length, 1024); + while (capacity < requiredLength) { + capacity = Math.min(capacity * 2, MAX_REQUEST_SIZE); + } + + const next = new Uint8Array(capacity); + next.set(this.buffer.subarray(0, this.length)); + this.buffer = next; + } + + private parseFraming(headerString: string): FramingResult { const lines = headerString.split("\r\n"); - for (const line of lines) { - const lowerLine = line.toLowerCase(); - if (lowerLine.startsWith("content-length:")) { - const value = line.slice("content-length:".length).trim(); - const parsed = parseInt(value, 10); - if (!Number.isNaN(parsed)) { - return parsed; + const contentLengths: number[] = []; + let hasTransferEncoding = false; + + for (const line of lines.slice(1)) { + if (line.startsWith(" ") || line.startsWith("\t")) { + return { + ok: false, + error: "Obsolete folded headers are not supported", + }; + } + + const separatorIndex = line.indexOf(":"); + if (separatorIndex === -1) { + continue; + } + + const rawName = line.slice(0, separatorIndex); + if (rawName !== rawName.trim()) { + return { ok: false, error: "Invalid header name" }; + } + const name = rawName.toLowerCase(); + const value = line.slice(separatorIndex + 1).trim(); + if (name === "transfer-encoding") { + hasTransferEncoding = true; + continue; + } + if (name !== "content-length") { + continue; + } + + for (const candidate of value.split(",")) { + const normalized = candidate.trim(); + if (!/^\d+$/.test(normalized)) { + return { ok: false, error: "Invalid Content-Length" }; } + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed)) { + return { ok: false, error: "Invalid Content-Length" }; + } + contentLengths.push(parsed); } } - return undefined; + + if (hasTransferEncoding) { + return { + ok: false, + error: + contentLengths.length === 0 + ? "Transfer-Encoding is not supported" + : "Conflicting Transfer-Encoding and Content-Length", + }; + } + + const contentLength = contentLengths[0] ?? 0; + if (contentLengths.some((value) => value !== contentLength)) { + return { ok: false, error: "Conflicting Content-Length headers" }; + } + return { ok: true, contentLength }; } } diff --git a/packages/backend/src/server/instances/logStream.spec.ts b/packages/backend/src/server/instances/logStream.spec.ts new file mode 100644 index 0000000..a2448de --- /dev/null +++ b/packages/backend/src/server/instances/logStream.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { Log } from "shared"; +import { + broadcastLog, + subscribeToLogStream, + unsubscribeFromLogStream, + waitForInstanceLog, +} from "./logStream"; + +const log: Log = { + id: "log-1", + instanceId: "instance-1", + type: "http", + timestamp: 1, + address: "127.0.0.1", + raw: "GET / HTTP/1.1", +}; + +describe("log stream", () => { + test("wakes log waiters and supports cancellation", async () => { + const notified = waitForInstanceLog(log.instanceId, 1000); + const cancelled = waitForInstanceLog(log.instanceId, 1000); + cancelled.cancel(); + + broadcastLog(log); + + await Promise.all([notified.promise, cancelled.promise]); + }); + + test("cancels log waiters when their request is aborted", async () => { + const controller = new AbortController(); + const waiter = waitForInstanceLog(log.instanceId, 1000, controller.signal); + + controller.abort(); + + await waiter.promise; + }); + + test("disconnects a backpressured subscriber", () => { + const socket = { + readyState: 1, + send: mock(), + close: mock(), + getBufferedAmount: () => 2 * 1024 * 1024, + }; + subscribeToLogStream(log.instanceId, socket); + + broadcastLog(log); + broadcastLog(log); + + expect(socket.close).toHaveBeenCalledWith( + 1013, + "Log stream client is too slow", + ); + expect(socket.send).not.toHaveBeenCalled(); + unsubscribeFromLogStream(log.instanceId, socket); + }); + + test("includes the next payload when enforcing backpressure", () => { + const socket = { + readyState: 1, + send: mock(), + close: mock(), + getBufferedAmount: () => 0, + }; + subscribeToLogStream(log.instanceId, socket); + + broadcastLog({ ...log, raw: "a".repeat(1024 * 1024) }); + + expect(socket.close).toHaveBeenCalledWith( + 1013, + "Log stream client is too slow", + ); + expect(socket.send).not.toHaveBeenCalled(); + unsubscribeFromLogStream(log.instanceId, socket); + }); + + test("isolates socket send failures from other subscribers", () => { + const brokenSocket = { + readyState: 1, + send: mock(() => { + throw new Error("closed"); + }), + close: mock(), + getBufferedAmount: () => 0, + }; + const healthySocket = { + readyState: 1, + send: mock(), + close: mock(), + getBufferedAmount: () => 0, + }; + subscribeToLogStream(log.instanceId, brokenSocket); + subscribeToLogStream(log.instanceId, healthySocket); + + expect(() => broadcastLog(log)).not.toThrow(); + expect(brokenSocket.close).toHaveBeenCalled(); + expect(healthySocket.send).toHaveBeenCalledTimes(1); + + broadcastLog(log); + expect(brokenSocket.send).toHaveBeenCalledTimes(1); + expect(healthySocket.send).toHaveBeenCalledTimes(2); + unsubscribeFromLogStream(log.instanceId, healthySocket); + }); +}); diff --git a/packages/backend/src/server/instances/logStream.ts b/packages/backend/src/server/instances/logStream.ts index 7d74322..d25a1b8 100644 --- a/packages/backend/src/server/instances/logStream.ts +++ b/packages/backend/src/server/instances/logStream.ts @@ -3,9 +3,66 @@ import type { Log } from "shared"; interface LogStreamSocket { send(data: string): unknown; readonly readyState: number; + getBufferedAmount?: () => number; + close?: (code?: number, reason?: string) => unknown; } const listeners = new Map>(); +const logWaiters = new Map void>>(); +const maxSocketBufferedBytes = 1024 * 1024; +const streamEncoder = new TextEncoder(); + +const closeSlowSocket = (socket: LogStreamSocket): void => { + try { + socket.close?.(1013, "Log stream client is too slow"); + } catch { + // The socket may already have closed between the state and buffer checks. + } +}; + +export const waitForInstanceLog = ( + instanceId: string, + timeoutMs: number, + signal?: AbortSignal, +) => { + let waiters = logWaiters.get(instanceId); + if (waiters === undefined) { + waiters = new Set(); + logWaiters.set(instanceId, waiters); + } + + let settled = false; + let resolvePromise: () => void = () => undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + let timeout: ReturnType | undefined; + + const settle = () => { + if (settled) { + return; + } + settled = true; + if (timeout !== undefined) { + clearTimeout(timeout); + } + signal?.removeEventListener("abort", settle); + waiters.delete(settle); + if (waiters.size === 0) { + logWaiters.delete(instanceId); + } + resolvePromise(); + }; + timeout = setTimeout(settle, timeoutMs); + waiters.add(settle); + if (signal?.aborted === true) { + settle(); + } else { + signal?.addEventListener("abort", settle, { once: true }); + } + + return { promise, cancel: settle }; +}; export const subscribeToLogStream = ( instanceId: string, @@ -34,14 +91,42 @@ export const unsubscribeFromLogStream = ( }; export const broadcastLog = (log: Log) => { + const waiters = logWaiters.get(log.instanceId); + if (waiters !== undefined) { + for (const settle of [...waiters]) { + settle(); + } + } + const sockets = listeners.get(log.instanceId); if (sockets === undefined) { return; } const payload = JSON.stringify(log); + const payloadBytes = streamEncoder.encode(payload).byteLength; for (const socket of sockets) { if (socket.readyState === 1) { - socket.send(payload); + let bufferedBytes = 0; + try { + bufferedBytes = socket.getBufferedAmount?.() ?? 0; + } catch { + closeSlowSocket(socket); + sockets.delete(socket); + continue; + } + + if (payloadBytes + bufferedBytes > maxSocketBufferedBytes) { + closeSlowSocket(socket); + sockets.delete(socket); + continue; + } + + try { + socket.send(payload); + } catch { + closeSlowSocket(socket); + sockets.delete(socket); + } continue; } sockets.delete(socket); diff --git a/packages/backend/src/server/instances/server.spec.ts b/packages/backend/src/server/instances/server.spec.ts index e9196ff..470e3a4 100644 --- a/packages/backend/src/server/instances/server.spec.ts +++ b/packages/backend/src/server/instances/server.spec.ts @@ -10,13 +10,9 @@ import { const addLogMock = mock(); const broadcastLogMock = mock(); -const getInstanceByIdMock = mock(); +const getServableInstanceByIdMock = mock(); const listenMock = mock(); -const parseMock = mock(); - -mock.module("http-z", () => ({ - parse: parseMock, -})); +let addLogTombstoned = false; mock.module("../../config", () => ({ dnsConfig: { @@ -25,18 +21,22 @@ mock.module("../../config", () => ({ })); mock.module("../../storage", () => ({ - addLog: addLogMock, + addLogWithOutcome: (log: unknown) => { + addLogMock(log); + return { log, tombstoned: addLogTombstoned }; + }, })); mock.module("../../storage/repositories/instances", () => ({ - getInstanceById: getInstanceByIdMock, + getServableInstanceById: getServableInstanceByIdMock, })); mock.module("./logStream", () => ({ broadcastLog: broadcastLogMock, })); -const { createInstancesServer } = await import("./server"); +const { createInstancesServer, resetInstanceResponseCacheForTests } = + await import("./server"); const encode = (value: string) => new TextEncoder().encode(value); const decode = (value: Uint8Array) => new TextDecoder().decode(value); @@ -47,13 +47,7 @@ const createRawRequest = (headers: string[], body = "") => { const createStaticInstance = (raw: string) => ({ id: "demo", - kind: "static" as const, - ownerId: "user-1", - createdAt: 1, raw, - webhookIds: [], - public: false, - locked: false, }); const createSocket = () => ({ @@ -81,6 +75,8 @@ describe("createInstancesServer", () => { beforeEach(() => { mock.clearAllMocks(); + addLogTombstoned = false; + resetInstanceResponseCacheForTests(); spyOn(console, "error").mockImplementation(() => undefined); spyOn(console, "log").mockImplementation(() => undefined); @@ -111,15 +107,12 @@ describe("createInstancesServer", () => { }); test("returns a bad request response when the host header is missing", async () => { - parseMock.mockReturnValue({ - headers: [], - }); const socket = createSocket(); handlers.open(socket); await handlers.data(socket, encode(createRawRequest([]))); - expect(getInstanceByIdMock).not.toHaveBeenCalled(); + expect(getServableInstanceByIdMock).not.toHaveBeenCalled(); expect(decode(socket.write.mock.calls[0]?.[0])).toContain( "400 Bad Request", ); @@ -132,7 +125,7 @@ describe("createInstancesServer", () => { }); test("logs oversized requests when they can be attributed to an instance", async () => { - getInstanceByIdMock.mockReturnValue( + getServableInstanceByIdMock.mockReturnValue( createStaticInstance("HTTP/1.1 200 OK\r\n\r\nok"), ); const socket = createSocket(); @@ -169,21 +162,25 @@ describe("createInstancesServer", () => { expect(decode(socket.write.mock.calls[0]?.[0])).toContain("Body too large"); }); + test("does not broadcast a log when moderation tombstones an instance", async () => { + addLogTombstoned = true; + getServableInstanceByIdMock.mockReturnValue( + createStaticInstance("HTTP/1.1 200 OK\r\n\r\nok"), + ); + const socket = createSocket(); + + handlers.open(socket); + await handlers.data( + socket, + encode(createRawRequest(["Host: demo.instances.example.com"])), + ); + + expect(broadcastLogMock).not.toHaveBeenCalled(); + }); + test("logs sanitized requests and writes large static responses", async () => { const body = "a".repeat(1024 * 1024); - parseMock.mockReturnValue({ - headers: [ - { - name: "Host", - value: "demo.instances.example.com", - }, - { - name: "X-Internal-Real-IP", - value: "203.0.113.42", - }, - ], - }); - getInstanceByIdMock.mockReturnValue( + getServableInstanceByIdMock.mockReturnValue( createStaticInstance( [ "HTTP/1.1 200 OK", @@ -226,16 +223,78 @@ describe("createInstancesServer", () => { expect(socket.end).toHaveBeenCalledTimes(1); }); + test("persists and broadcasts complete large HTTP interactions", async () => { + getServableInstanceByIdMock.mockReturnValue( + createStaticInstance("HTTP/1.1 200 OK\r\n\r\nok"), + ); + const body = "a".repeat(2 * 1024 * 1024); + const rawRequest = [ + "POST / HTTP/1.1", + "Host: demo.instances.example.com", + `Content-Length: ${body.length}`, + "", + body, + ].join("\r\n"); + const socket = createSocket(); + + handlers.open(socket); + await handlers.data(socket, encode(rawRequest)); + + const persistedLog = addLogMock.mock.calls[0]?.[0] as + { raw: string } | undefined; + expect(persistedLog?.raw).toBe(rawRequest); + expect(broadcastLogMock).toHaveBeenCalledWith(persistedLog); + }); + + test("does not serve a cached response after raw content changes", async () => { + getServableInstanceByIdMock + .mockReturnValueOnce(createStaticInstance("HTTP/1.1 200 OK\r\n\r\nfirst")) + .mockReturnValueOnce( + createStaticInstance("HTTP/1.1 200 OK\r\n\r\nsecond"), + ); + const firstSocket = createSocket(); + const secondSocket = createSocket(); + + handlers.open(firstSocket); + await handlers.data( + firstSocket, + encode(createRawRequest(["Host: demo.instances.example.com"])), + ); + handlers.open(secondSocket); + await handlers.data( + secondSocket, + encode(createRawRequest(["Host: demo.instances.example.com"])), + ); + + expect(decode(firstSocket.write.mock.calls[0]?.[0])).toEndWith("first"); + expect(decode(secondSocket.write.mock.calls[0]?.[0])).toEndWith("second"); + }); + + test("reuses the encoded response while raw content is unchanged", async () => { + getServableInstanceByIdMock.mockReturnValue( + createStaticInstance("HTTP/1.1 200 OK\r\n\r\ncached"), + ); + const firstSocket = createSocket(); + const secondSocket = createSocket(); + + handlers.open(firstSocket); + await handlers.data( + firstSocket, + encode(createRawRequest(["Host: demo.instances.example.com"])), + ); + handlers.open(secondSocket); + await handlers.data( + secondSocket, + encode(createRawRequest(["Host: demo.instances.example.com"])), + ); + + expect(secondSocket.write.mock.calls[0]?.[0]).toBe( + firstSocket.write.mock.calls[0]?.[0], + ); + }); + test("returns a bad request response when the instance does not exist", async () => { - parseMock.mockReturnValue({ - headers: [ - { - name: "Host", - value: "missing.instances.example.com", - }, - ], - }); - getInstanceByIdMock.mockReturnValue(undefined); + getServableInstanceByIdMock.mockReturnValue(undefined); const socket = createSocket(); handlers.open(socket); @@ -251,12 +310,12 @@ describe("createInstancesServer", () => { ); }); - test("returns an internal server error when request parsing throws", async () => { - getInstanceByIdMock.mockReturnValue( + test("returns an internal server error when log persistence throws", async () => { + getServableInstanceByIdMock.mockReturnValue( createStaticInstance("HTTP/1.1 200 OK\r\n\r\nok"), ); - parseMock.mockImplementation(() => { - throw new Error("parse failed"); + addLogMock.mockImplementation(() => { + throw new Error("write failed"); }); const socket = createSocket(); @@ -266,10 +325,8 @@ describe("createInstancesServer", () => { encode(createRawRequest(["Host: demo.instances.example.com"])), ); - expect(addLogMock).toHaveBeenCalledTimes(1); - expect(broadcastLogMock).toHaveBeenCalledWith( - addLogMock.mock.calls[0]?.[0], - ); + expect(addLogMock).toHaveBeenCalledTimes(2); + expect(broadcastLogMock).not.toHaveBeenCalled(); expect(console.error).toHaveBeenCalled(); expect(decode(socket.write.mock.calls[0]?.[0])).toContain( "500 Internal Server Error", diff --git a/packages/backend/src/server/instances/server.ts b/packages/backend/src/server/instances/server.ts index 68a0ecf..d63a244 100644 --- a/packages/backend/src/server/instances/server.ts +++ b/packages/backend/src/server/instances/server.ts @@ -1,8 +1,7 @@ import { listen, type Socket } from "bun"; -import { parse } from "http-z"; import { type Log } from "shared"; -import { addLog } from "../../storage"; -import { getInstanceById } from "../../storage/repositories/instances"; +import { addLogWithOutcome } from "../../storage"; +import { getServableInstanceById } from "../../storage/repositories/instances"; import { broadcastLog } from "./logStream"; import { HttpRequestBuffer } from "./httpBuffer"; import { @@ -19,6 +18,56 @@ type SocketData = { }; const INTERNAL_HEADER_NAMES = ["x-internal-real-ip"]; +const responseEncoder = new TextEncoder(); +const maxCachedResponseBytes = 64 * 1024 * 1024; + +type CachedResponse = { + raw: string; + encoded: Uint8Array; + size: number; +}; + +const responseCache = new Map(); +let cachedResponseBytes = 0; + +export const resetInstanceResponseCacheForTests = () => { + responseCache.clear(); + cachedResponseBytes = 0; +}; + +const encodeInstanceResponse = (instance: { id: string; raw: string }) => { + const cached = responseCache.get(instance.id); + if (cached?.raw === instance.raw) { + responseCache.delete(instance.id); + responseCache.set(instance.id, cached); + return cached.encoded; + } + + if (cached !== undefined) { + responseCache.delete(instance.id); + cachedResponseBytes -= cached.size; + } + + const encoded = responseEncoder.encode(adjustContentLength(instance.raw)); + const size = encoded.byteLength + instance.raw.length * 2; + if (size > maxCachedResponseBytes) { + return encoded; + } + + while (cachedResponseBytes + size > maxCachedResponseBytes) { + const oldestId = responseCache.keys().next().value; + if (oldestId === undefined) { + break; + } + const oldest = responseCache.get(oldestId); + responseCache.delete(oldestId); + cachedResponseBytes -= oldest?.size ?? 0; + } + + responseCache.set(instance.id, { raw: instance.raw, encoded, size }); + cachedResponseBytes += size; + return encoded; +}; const createHttpLog = ( instanceId: string, @@ -50,7 +99,7 @@ const tryLogInteraction = ( return false; } - const instance = getInstanceById(result.instanceId); + const instance = getServableInstanceById(result.instanceId); if (instance === undefined) { return false; } @@ -67,8 +116,10 @@ const tryLogInteraction = ( rawWithoutInternalHeaders, ); - addLog(log); - broadcastLog(log); + const outcome = addLogWithOutcome(log); + if (!outcome.tombstoned) { + broadcastLog(log); + } return true; } catch (error) { console.error(error); @@ -110,11 +161,7 @@ export const createInstancesServer = ( } const rawRequest = socket.data.buffer.getRaw(); - const request = parse(rawRequest); - - const host = request.headers.find( - (header) => header.name === "Host", - )?.value; + const host = getHeaderValue(rawRequest, "host"); if (host === undefined || host === "") { respond( @@ -130,7 +177,7 @@ export const createInstancesServer = ( return; } - const instance = getInstanceById(result.instanceId); + const instance = getServableInstanceById(result.instanceId); if (!instance) { respond( socket, @@ -152,19 +199,13 @@ export const createInstancesServer = ( rawWithoutInternalHeaders, ); - addLog(log); - broadcastLog(log); + const outcome = addLogWithOutcome(log); + if (!outcome.tombstoned) { + broadcastLog(log); + } didLog = true; - switch (instance.kind) { - case "static": { - const adjustedResponse = adjustContentLength(instance.raw); - respond(socket, new TextEncoder().encode(adjustedResponse)); - break; - } - case "dynamic": - throw new Error("Dynamic instances are not supported yet"); - } + respond(socket, encodeInstanceResponse(instance)); } catch (error) { console.error(error); if (!didLog) { diff --git a/packages/backend/src/server/instances/service.spec.ts b/packages/backend/src/server/instances/service.spec.ts new file mode 100644 index 0000000..d47a51d --- /dev/null +++ b/packages/backend/src/server/instances/service.spec.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + GuestManagementTokenSchema, + GUEST_MAX_ACTIVE_INSTANCES, + GUEST_OWNER_ID, +} from "shared"; +import { instancePolicies } from "../../config"; +import { + addWebhook, + closeDb, + deleteInstance, + getDb, + getInstanceById, + initDb, +} from "../../storage"; +import { instances } from "../../storage/schema"; + +process.env.JWT_SECRET = "instance-service-test-secret"; + +const { + createGuestInstance, + createInstance, + replaceGuestInstance, + replaceInstance, +} = await import("./service"); + +const validRaw = "HTTP/1.1 200 OK\nContent-Type: text/plain\n\nhello"; +const originalInstanceLimit = instancePolicies.maxInstancesPerOwner; +let dataDir = ""; + +const addOwnedWebhook = (id: string, ownerId: string) => + addWebhook({ + id, + ownerId, + name: id, + url: "https://discord.com/api/webhooks/123/token", + createdAt: 1, + }); + +describe("instance service", () => { + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-service-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + instancePolicies.maxInstancesPerOwner = undefined; + }); + + afterEach(() => { + instancePolicies.maxInstancesPerOwner = originalInstanceLimit; + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + test("normalizes raw responses and persists owned webhooks", () => { + addOwnedWebhook("webhook-1", "owner-1"); + + const result = createInstance({ + ownerId: "owner-1", + raw: validRaw, + webhookIds: ["webhook-1"], + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + return; + } + expect(result.value.raw).toBe( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello", + ); + expect(result.value.webhookIds).toEqual(["webhook-1"]); + expect("kind" in result.value).toBe(false); + }); + + test("creates token-protected guests and enforces the smaller raw limit", async () => { + const created = await createGuestInstance(validRaw); + expect(created.ok).toBe(true); + if (!created.ok) { + return; + } + expect( + GuestManagementTokenSchema.safeParse(created.value.token).success, + ).toBe(true); + expect(getInstanceById(created.value.instance.id)).toEqual( + created.value.instance, + ); + + const tooLarge = replaceGuestInstance( + created.value.instance.id, + `HTTP/1.1 200 OK\r\n\r\n${"x".repeat(1024 * 1024)}`, + ); + expect(tooLarge).toEqual({ + ok: false, + error: { + code: "invalid_raw", + message: "Guest response exceeds 1MB limit", + status: 413, + }, + }); + + deleteInstance(created.value.instance.id); + expect(getInstanceById(created.value.instance.id)).toBeUndefined(); + }); + + test("enforces the global active guest quota", async () => { + getDb() + .insert(instances) + .values( + Array.from({ length: GUEST_MAX_ACTIVE_INSTANCES }, (_, index) => ({ + id: index.toString(36).padStart(8, "0"), + ownerId: GUEST_OWNER_ID, + createdAt: index, + expiresAt: Date.now() + 60_000, + isPublic: false, + isLocked: false, + raw: "HTTP/1.1 200 OK\r\n\r\nok", + })), + ) + .run(); + + expect(await createGuestInstance(validRaw)).toEqual({ + ok: false, + error: { + code: "instance_limit", + message: "Guest instance limit reached", + status: 503, + }, + }); + }); + + test("rejects webhooks owned by another user and duplicate ids", () => { + addOwnedWebhook("owned", "owner-1"); + addOwnedWebhook("foreign", "owner-2"); + + const foreign = createInstance({ + ownerId: "owner-1", + raw: validRaw, + webhookIds: ["foreign"], + }); + expect(foreign).toEqual({ + ok: false, + error: { + code: "invalid_webhooks", + message: "Invalid webhook selection", + status: 400, + }, + }); + + const duplicate = createInstance({ + ownerId: "owner-1", + raw: validRaw, + webhookIds: ["owned", "owned"], + }); + expect(duplicate).toEqual({ + ok: false, + error: { + code: "invalid_webhooks", + message: "Invalid webhook selection", + status: 400, + }, + }); + }); + + test("enforces the active instance quota without hydrating instances", () => { + instancePolicies.maxInstancesPerOwner = 1; + expect(createInstance({ ownerId: "owner-1", raw: validRaw }).ok).toBe(true); + + expect(createInstance({ ownerId: "owner-1", raw: validRaw })).toEqual({ + ok: false, + error: { + code: "instance_limit", + message: "Instance limit reached", + status: 403, + }, + }); + }); + + test("returns a typed forbidden result for wrong-owner updates", () => { + const created = createInstance({ ownerId: "owner-1", raw: validRaw }); + if (!created.ok) { + throw new Error(created.error.message); + } + + expect( + replaceInstance({ + instanceId: created.value.id, + ownerId: "owner-2", + raw: "HTTP/1.1 204 No Content\n\n", + }), + ).toEqual({ + ok: false, + error: { code: "forbidden", message: "Forbidden", status: 403 }, + }); + }); +}); diff --git a/packages/backend/src/server/instances/service.ts b/packages/backend/src/server/instances/service.ts new file mode 100644 index 0000000..356629d --- /dev/null +++ b/packages/backend/src/server/instances/service.ts @@ -0,0 +1,241 @@ +import { + GUEST_INSTANCE_RAW_LIMIT_BYTES, + GUEST_INSTANCE_TTL_MS, + GUEST_MAX_ACTIVE_INSTANCES, + GUEST_OWNER_ID, + type Instance, +} from "shared"; +import { instancePolicies } from "../../config"; +import { + addInstance, + addGuestInstance, + countActiveInstancesByOwner, + getInstanceById, + getWebhooksByOwner, + updateInstance, +} from "../../storage"; +import { issueGuestManagementToken } from "../guestAccess"; +import { generateInstanceID, validateStaticRaw } from "../utils"; + +type InstanceServiceErrorCode = + | "invalid_raw" + | "invalid_webhooks" + | "instance_limit" + | "not_found" + | "forbidden"; + +export type InstanceServiceError = { + code: InstanceServiceErrorCode; + message: string; + status: number; +}; + +export type InstanceServiceResult = + { ok: true; value: T } | { ok: false; error: InstanceServiceError }; + +const fail = ( + code: InstanceServiceErrorCode, + message: string, + status: number, +): InstanceServiceResult => ({ + ok: false, + error: { code, message, status }, +}); + +const validateWebhookIds = ( + ownerId: string, + webhookIds: string[], +): InstanceServiceResult => { + if (webhookIds.length === 0) { + return { ok: true, value: [] }; + } + + if (new Set(webhookIds).size !== webhookIds.length) { + return fail("invalid_webhooks", "Invalid webhook selection", 400); + } + + const ownedIds = new Set( + getWebhooksByOwner(ownerId).map((webhook) => webhook.id), + ); + if (webhookIds.some((webhookId) => !ownedIds.has(webhookId))) { + return fail("invalid_webhooks", "Invalid webhook selection", 400); + } + return { ok: true, value: webhookIds }; +}; + +const validateRaw = (raw: string): InstanceServiceResult => { + const result = validateStaticRaw(raw); + return result.ok + ? { ok: true, value: result.raw } + : fail("invalid_raw", result.error, result.status); +}; + +const rawEncoder = new TextEncoder(); + +const validateGuestRaw = (raw: string): InstanceServiceResult => { + if (rawEncoder.encode(raw).length > GUEST_INSTANCE_RAW_LIMIT_BYTES) { + return fail("invalid_raw", "Guest response exceeds 1MB limit", 413); + } + return validateRaw(raw); +}; + +export const getOwnedInstance = ( + instanceId: string, + ownerId: string, +): InstanceServiceResult => { + const instance = getInstanceById(instanceId); + if (instance === undefined) { + return fail("not_found", "Not found", 404); + } + if (instance.ownerId !== ownerId) { + return fail("forbidden", "Forbidden", 403); + } + return { ok: true, value: instance }; +}; + +type CreateInstanceInput = { + ownerId: string; + raw: string; + webhookIds?: string[]; + label?: string; +}; + +export const createInstance = ( + input: CreateInstanceInput, +): InstanceServiceResult => { + const raw = validateRaw(input.raw); + if (!raw.ok) { + return raw; + } + + const webhookIds = validateWebhookIds(input.ownerId, input.webhookIds ?? []); + if (!webhookIds.ok) { + return webhookIds; + } + + if ( + instancePolicies.maxInstancesPerOwner !== undefined && + countActiveInstancesByOwner(input.ownerId) >= + instancePolicies.maxInstancesPerOwner + ) { + return fail("instance_limit", "Instance limit reached", 403); + } + + const now = Date.now(); + const expiresAt = + instancePolicies.defaultTtlMs === undefined + ? undefined + : now + instancePolicies.defaultTtlMs; + + return { + ok: true, + value: addInstance({ + id: generateInstanceID(), + ownerId: input.ownerId, + createdAt: now, + expiresAt, + webhookIds: webhookIds.value, + label: input.label, + public: false, + locked: false, + raw: raw.value, + }), + }; +}; + +type UpdateInstanceInput = { + instanceId: string; + ownerId: string; + raw: string; + webhookIds?: string[]; +}; + +export const replaceInstance = ( + input: UpdateInstanceInput, +): InstanceServiceResult => { + const owned = getOwnedInstance(input.instanceId, input.ownerId); + if (!owned.ok) { + return owned; + } + + const raw = validateRaw(input.raw); + if (!raw.ok) { + return raw; + } + + const webhookIds = + input.webhookIds === undefined + ? ({ ok: true, value: owned.value.webhookIds } as const) + : validateWebhookIds(input.ownerId, input.webhookIds); + if (!webhookIds.ok) { + return webhookIds; + } + + const updated = updateInstance(input.instanceId, (instance) => ({ + ...instance, + raw: raw.value, + webhookIds: webhookIds.value, + })); + + return updated === undefined + ? fail("not_found", "Not found", 404) + : { ok: true, value: updated }; +}; + +export const createGuestInstance = async ( + raw: string, +): Promise> => { + const validatedRaw = validateGuestRaw(raw); + if (!validatedRaw.ok) { + return validatedRaw; + } + if ( + countActiveInstancesByOwner(GUEST_OWNER_ID) >= GUEST_MAX_ACTIVE_INSTANCES + ) { + return fail("instance_limit", "Guest instance limit reached", 503); + } + + const now = Date.now(); + const expiresAt = now + GUEST_INSTANCE_TTL_MS; + const instanceId = generateInstanceID(); + const token = await issueGuestManagementToken( + { id: instanceId, createdAt: now }, + expiresAt, + ); + const instance = addGuestInstance({ + id: instanceId, + ownerId: GUEST_OWNER_ID, + createdAt: now, + expiresAt, + webhookIds: [], + public: false, + locked: false, + raw: validatedRaw.value, + }); + return { + ok: true, + value: { instance, token }, + }; +}; + +export const replaceGuestInstance = ( + instanceId: string, + raw: string, +): InstanceServiceResult => { + const owned = getOwnedInstance(instanceId, GUEST_OWNER_ID); + if (!owned.ok) { + return owned; + } + const validatedRaw = validateGuestRaw(raw); + if (!validatedRaw.ok) { + return validatedRaw; + } + const updated = updateInstance(instanceId, (instance) => ({ + ...instance, + raw: validatedRaw.value, + webhookIds: [], + })); + return updated === undefined + ? fail("not_found", "Not found", 404) + : { ok: true, value: updated }; +}; diff --git a/packages/backend/src/server/instances/utils.ts b/packages/backend/src/server/instances/utils.ts index 9edcfea..fc34240 100644 --- a/packages/backend/src/server/instances/utils.ts +++ b/packages/backend/src/server/instances/utils.ts @@ -8,7 +8,7 @@ const responseLineBreakPattern = /\r?\n/; const responseHeaderSeparatorPattern = /\r?\n\r?\n/; export const createResponse = (status: string, message: string) => { - return new TextEncoder().encode( + return encoder.encode( `HTTP/1.1 ${status}\r\nContent-Type: text/plain\r\n\r\n${message}`, ); }; diff --git a/packages/backend/src/server/jwtSecret.ts b/packages/backend/src/server/jwtSecret.ts new file mode 100644 index 0000000..0947636 --- /dev/null +++ b/packages/backend/src/server/jwtSecret.ts @@ -0,0 +1,13 @@ +const jwtSecretValue = Bun.env.JWT_SECRET; + +if (jwtSecretValue === undefined || jwtSecretValue === "") { + throw new Error("JWT_SECRET must be set"); +} + +if (jwtSecretValue === "your-jwt-secret-here") { + throw new Error( + "JWT_SECRET must be changed, you are using the default secret", + ); +} + +export const jwtSecret = new TextEncoder().encode(jwtSecretValue); diff --git a/packages/backend/src/server/mcp.ts b/packages/backend/src/server/mcp.ts index 29550b8..027dfb2 100644 --- a/packages/backend/src/server/mcp.ts +++ b/packages/backend/src/server/mcp.ts @@ -1,66 +1,38 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import * as z from "zod/v4"; -import { instancePolicies } from "../config"; -import { - addInstance, - clearLogsForInstance, - deleteInstance, - getInstanceById, - getInstancesByOwner, - getLogsForInstancePage, - getRecentLogsForInstance, - updateInstance, -} from "../storage"; -import type { ApiKeyAuthContext } from "./apiKeyAuth"; -import { authenticateApiKeyRequest, hasApiKeyScope } from "./apiKeyAuth"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import { authenticateApiKeyRequest } from "./apiKeyAuth"; +import { registerInstanceTools } from "./mcp/instanceTools"; +import { registerLogTools } from "./mcp/logTools"; import { createFixedWindowRateLimiter } from "./rateLimit"; -import { - clampLogLimit, - decodeLogsCursor, - encodeLogsCursor, - generateInstanceID, - MAX_LOG_LIMIT, - validateStaticRaw, -} from "./utils"; -const mcpLogPreviewLimit = 10; const mcpRateLimitWindowMs = 60_000; -const mcpRateLimitMaxRequests = 120; -const unauthMcpRateLimitMaxRequests = 600; +const mcpRateLimiter = createFixedWindowRateLimiter({ + maxRequests: 120, + windowMs: mcpRateLimitWindowMs, +}); +const unauthMcpRateLimiter = createFixedWindowRateLimiter({ + maxRequests: 600, + windowMs: mcpRateLimitWindowMs, +}); + const mcpServerInstructions = ` HTTP Workbench helps pentesters and security researchers create temporary hosted HTTP proof-of-concept pages and observe HTTP/DNS/SMTP interactions for authorized security testing. -An instance is a hosted subdomain under the configured instances domain. Static instances serve an exact raw HTTP response and record incoming HTTP requests. Deployments with DNS enabled also record DNS lookups for instance hostnames. - -Use these tools when the user needs a hosted page, a separate origin, a callback/redirect receiver, a custom raw HTTP response, or interaction logs for a specific HTTP Workbench instance. - -Instance URLs returned by tools are public network endpoints; anyone who can reach the URL can trigger logs and receive the configured response. +An instance is a hosted subdomain under the configured instances domain. It serves an exact raw HTTP response and records incoming HTTP requests. Deployments with DNS enabled also record DNS lookups for instance hostnames. Create and update tools require a complete raw HTTP response: status line, headers, a blank line, and body. They normalize line endings, enforce response size limits, and reject invalid HTTP. -Use watch_instance_logs by polling again with nextCursor after pollAfterMs when the user is actively waiting for new interactions. -`.trim(); +Publishing an instance lets an unauthenticated user visit https://httpworkbench.com/instances/:id and view its logs and basic data. Access is read-only. This is useful when sharing callback information with a triager in a vulnerability report. -const mcpRateLimiter = createFixedWindowRateLimiter({ - maxRequests: mcpRateLimitMaxRequests, - windowMs: mcpRateLimitWindowMs, -}); -const unauthMcpRateLimiter = createFixedWindowRateLimiter({ - maxRequests: unauthMcpRateLimitMaxRequests, - windowMs: mcpRateLimitWindowMs, -}); +Use watch_instance_logs by polling again with resumeCursor after pollAfterMs when the user is actively waiting for new interactions. nextCursor only indicates another historical page is already available. +`.trim(); const frontendUrl = Bun.env.FRONTEND_URL; const configuredDomain = Bun.env.DOMAIN; - const allowedOrigins = new Set( [frontendUrl, "http://localhost:5173", "http://127.0.0.1:5173"].filter( (value): value is string => value !== undefined && value !== "", ), ); - const allowedHosts = new Set( [ configuredDomain, @@ -74,511 +46,63 @@ const allowedHosts = new Set( ].filter((value): value is string => value !== undefined && value !== ""), ); -const jsonToolResult = (value: Record) => ({ - content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], - structuredContent: value, -}); - -const toolError = (message: string) => ({ - content: [{ type: "text" as const, text: message }], - isError: true, -}); - -const unauthorizedResponse = () => - new Response("Unauthorized", { - status: 401, - headers: { - "WWW-Authenticate": 'Bearer realm="httpworkbench-mcp"', - }, - }); - -const validateMcpHeaders = (req: Request): Response | undefined => { - const host = req.headers.get("host"); +const validateMcpHeaders = (request: Request): Response | undefined => { + const host = request.headers.get("host"); if (host === null || !allowedHosts.has(host)) { return new Response("Invalid host", { status: 403 }); } - const origin = req.headers.get("origin"); + const origin = request.headers.get("origin"); if (origin !== null && !allowedOrigins.has(origin)) { return new Response("Invalid origin", { status: 403 }); } - return undefined; }; -const getAuthContext = (extra: { authInfo?: AuthInfo }): ApiKeyAuthContext => { - const context = extra.authInfo?.extra?.httpworkbenchAuth; - if (context === undefined) { - throw new Error("Unauthorized"); - } - return context as ApiKeyAuthContext; -}; - -const requireScope = ( - auth: ApiKeyAuthContext, - scope: - | "instances:read" - | "instances:write" - | "instances:delete" - | "logs:read" - | "logs:stream", -) => { - if (!hasApiKeyScope(auth.apiKey, scope)) { - throw new Error(`Missing API key scope: ${scope}`); - } -}; - -const getOwnedInstance = (instanceId: string, auth: ApiKeyAuthContext) => { - const instance = getInstanceById(instanceId); - if (instance?.ownerId !== auth.user.id) { - throw new Error("Instance not found"); - } - return instance; -}; - -const serializeInstance = (instance: ReturnType) => { - if (instance === undefined) { - return undefined; - } - return { - ...instance, - url: `${instance.id}.${Bun.env.INSTANCES_DOMAIN ?? ""}`, - }; -}; - -const serializeInstanceSummary = ( - instance: ReturnType, -) => { - if (instance === undefined) { - return undefined; - } - return { - id: instance.id, - kind: instance.kind, - ownerId: instance.ownerId, - createdAt: instance.createdAt, - expiresAt: instance.expiresAt, - label: instance.label, - public: instance.public, - locked: instance.locked, - url: `${instance.id}.${Bun.env.INSTANCES_DOMAIN ?? ""}`, - }; -}; - -const readLogsPage = (input: { - auth: ApiKeyAuthContext; - instanceId: string; - limit?: number; - cursor?: string; - type?: "http" | "dns" | "smtp"; - sinceTimestamp?: number; -}) => { - getOwnedInstance(input.instanceId, input.auth); - const limit = clampLogLimit(input.limit); - const cursor = - input.cursor === undefined ? undefined : decodeLogsCursor(input.cursor); - if (input.cursor !== undefined && cursor === undefined) { - throw new Error("Invalid cursor"); - } - - const page = getLogsForInstancePage({ - instanceId: input.instanceId, - limit, - cursor, - type: input.type, - sinceTimestamp: input.sinceTimestamp, - }); - - return { - logs: page.logs, - nextCursor: - page.nextCursor === undefined - ? undefined - : encodeLogsCursor(page.nextCursor), - }; -}; - const createMcpServer = () => { const server = new McpServer( - { - name: "httpworkbench", - version: "1.0.0", - }, - { - instructions: mcpServerInstructions, - }, - ); - - server.registerTool( - "list_instances", - { - title: "List Instances", - description: - "List HTTP Workbench instances owned by the authenticated API key user. Use this first when the user refers to an existing instance but has not provided an instanceId. This is read-only and returns instance metadata plus public URLs, not full response bodies or logs.", - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:read"); - return jsonToolResult({ - instances: getInstancesByOwner(auth.user.id).map( - serializeInstanceSummary, - ), - }); - }, - ); - - server.registerTool( - "create_instance", - { - title: "Create Instance", - description: - "Create a new static HTTP Workbench instance that serves the provided raw HTTP response from a public instance URL. Use this when the user needs a new hosted PoC page, callback receiver, redirect target, CORS test endpoint, separate origin, or other temporary hosted surface. Side effects: persists a new instance owned by the API key user, consumes instance quota, creates a reachable public endpoint, and future requests or DNS lookups for that endpoint may be logged. The raw input must be a complete valid HTTP response with status line, headers, blank line, and body. Consider cleaning up instance once you are done testing", - inputSchema: { - raw: z - .string() - .describe( - "Complete raw HTTP response to serve, including status line, headers, blank line, and body. Example: HTTP/1.1 200 OK\\r\\nContent-Type: text/html\\r\\n\\r\\n...", - ), - label: z - .string() - .max(100) - .optional() - .describe( - "Optional human-readable label for the instance, used only for organization.", - ), - }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, - }, - async ({ raw, label }, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:write"); - - const check = validateStaticRaw(raw); - if (!check.ok) { - return toolError(check.error); - } - const normalizedRaw = check.raw; - - if (instancePolicies.maxInstancesPerOwner !== undefined) { - const ownedInstances = getInstancesByOwner(auth.user.id); - if (ownedInstances.length >= instancePolicies.maxInstancesPerOwner) { - return toolError("Instance limit reached"); - } - } - - const now = Date.now(); - const instance = addInstance({ - id: generateInstanceID(), - ownerId: auth.user.id, - createdAt: now, - expiresAt: - instancePolicies.defaultTtlMs === undefined - ? undefined - : now + instancePolicies.defaultTtlMs, - webhookIds: [], - label, - public: false, - locked: false, - kind: "static", - raw: normalizedRaw, - }); - - return jsonToolResult({ instance: serializeInstance(instance) }); - }, - ); - - server.registerTool( - "get_instance", - { - title: "Get Instance", - description: - "Get one owned HTTP Workbench instance, including its full configuration and a small recent log preview when the API key also has logs:read. Use this to inspect the current hosted response, URL, lock/public flags, expiration, or recent interactions before deciding whether to update, delete, or read more logs. This is read-only and only returns instances owned by the API key user.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe( - "ID of the HTTP Workbench instance to inspect, usually the leftmost label of its instance URL.", - ), - }, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async ({ instanceId }, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:read"); - const instance = getOwnedInstance(instanceId, auth); - const logs = hasApiKeyScope(auth.apiKey, "logs:read") - ? getRecentLogsForInstance(instance.id, mcpLogPreviewLimit) - : []; - return jsonToolResult({ instance: serializeInstance(instance), logs }); - }, - ); - - server.registerTool( - "update_instance", - { - title: "Update Instance", - description: - "Replace the raw HTTP response served by an owned static instance. Use this when the user wants to revise an existing hosted PoC or change the behavior of an existing instance URL instead of creating a new one. Side effects: overwrites the response seen by future visitors to that public URL; existing logs remain unchanged. Only static instances can be updated through MCP. The raw input must be a complete valid HTTP response with status line, headers, blank line, and body.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe("ID of the owned static instance to update."), - raw: z - .string() - .describe( - "Complete replacement raw HTTP response to serve, including status line, headers, blank line, and body.", - ), - }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: true, - }, - }, - async ({ instanceId, raw }, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:write"); - const current = getOwnedInstance(instanceId, auth); - if (current.kind !== "static") { - return toolError("Only static instances can be updated through MCP"); - } - - const check = validateStaticRaw(raw); - if (!check.ok) { - return toolError(check.error); - } - const normalizedRaw = check.raw; - - const updated = updateInstance(instanceId, (instance) => ({ - ...instance, - raw: normalizedRaw, - })); - return jsonToolResult({ instance: serializeInstance(updated) }); - }, - ); - - server.registerTool( - "delete_instance", - { - title: "Delete Instance", - description: - "Delete an owned, unlocked HTTP Workbench instance. Use this only when the user wants to remove a hosted endpoint and its associated data from HTTP Workbench. Side effects: removes the instance, stops serving its public URL, and cascades deletion of related instance data such as logs according to storage rules. Locked instances cannot be deleted.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe("ID of the owned, unlocked instance to delete."), - }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: false, - openWorldHint: false, - }, - }, - async ({ instanceId }, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:delete"); - const instance = getOwnedInstance(instanceId, auth); - if (instance.locked) { - return toolError("Instance is locked"); - } - deleteInstance(instanceId); - return jsonToolResult({ deleted: true }); - }, - ); - - server.registerTool( - "clear_instance_logs", - { - title: "Clear Instance Logs", - description: - "Clear all recorded HTTP/DNS/SMTP logs for an owned instance while leaving the hosted instance itself intact. Use this when the user wants a clean slate before reproducing an interaction. Side effects: permanently removes stored logs for the instance; it does not change the served response or public URL.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe("ID of the owned instance whose logs should be cleared."), - }, - annotations: { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async ({ instanceId }, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "instances:write"); - getOwnedInstance(instanceId, auth); - clearLogsForInstance(instanceId); - return jsonToolResult({ cleared: true }); - }, - ); - - server.registerTool( - "read_instance_logs", - { - title: "Read Instance Logs", - description: - "Read historical paginated HTTP/DNS/SMTP interaction logs for an owned instance. Use this when the user asks what requests, callbacks, DNS lookups, headers, paths, bodies, or source addresses reached an instance. This is read-only, but returned logs can contain sensitive request data. Use nextCursor to fetch additional pages.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe("ID of the owned instance whose logs should be read."), - limit: z - .number() - .int() - .min(1) - .max(MAX_LOG_LIMIT) - .optional() - .describe( - `Maximum number of logs to return, up to ${MAX_LOG_LIMIT}.`, - ), - cursor: z - .string() - .optional() - .describe("Opaque cursor returned by a previous log read."), - type: z - .enum(["http", "dns", "smtp"]) - .optional() - .describe("Optional log type filter."), - sinceTimestamp: z - .number() - .optional() - .describe( - "Optional Unix timestamp in milliseconds; only newer logs are returned.", - ), - }, - annotations: { - readOnlyHint: true, - idempotentHint: true, - openWorldHint: false, - }, - }, - async (input, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "logs:read"); - return jsonToolResult(readLogsPage({ ...input, auth })); - }, - ); - - server.registerTool( - "watch_instance_logs", - { - title: "Watch Instance Logs", - description: - "Poll for new HTTP/DNS/SMTP logs from an owned instance while the user is waiting for an interaction to arrive. This is read-only, but returned logs can contain sensitive request data. Call again after pollAfterMs using the returned nextCursor to continue watching without replaying older entries.", - inputSchema: { - instanceId: z - .string() - .min(1) - .describe("ID of the owned instance whose logs should be watched."), - limit: z - .number() - .int() - .min(1) - .max(MAX_LOG_LIMIT) - .optional() - .describe( - `Maximum number of new logs to return, up to ${MAX_LOG_LIMIT}.`, - ), - cursor: z - .string() - .optional() - .describe( - "Opaque cursor returned by a previous log read or watch call.", - ), - type: z - .enum(["http", "dns", "smtp"]) - .optional() - .describe("Optional log type filter."), - sinceTimestamp: z - .number() - .optional() - .describe( - "Optional Unix timestamp in milliseconds; only newer logs are returned.", - ), - }, - annotations: { - readOnlyHint: true, - idempotentHint: false, - openWorldHint: false, - }, - }, - async (input, extra) => { - const auth = getAuthContext(extra); - requireScope(auth, "logs:stream"); - return jsonToolResult({ - ...readLogsPage({ ...input, auth }), - pollAfterMs: 1000, - }); - }, + { name: "httpworkbench", version: "1.0.0" }, + { instructions: mcpServerInstructions }, ); - + registerInstanceTools(server); + registerLogTools(server); return server; }; -export const handleMcpRequest = async (req: Request): Promise => { - const headerError = validateMcpHeaders(req); +const mcpHandler = createMcpHandler(createMcpServer); + +export const closeMcpHandler = () => mcpHandler.close(); + +export const handleMcpRequest = async (request: Request): Promise => { + const headerError = validateMcpHeaders(request); if (headerError !== undefined) { return headerError; } - const auth = authenticateApiKeyRequest(req); + const auth = authenticateApiKeyRequest(request); if (auth === undefined) { if (!unauthMcpRateLimiter.check("unauthenticated")) { return new Response("Rate limit exceeded", { status: 429 }); } - return unauthorizedResponse(); + return new Response("Unauthorized", { + status: 401, + headers: { "WWW-Authenticate": 'Bearer realm="httpworkbench-mcp"' }, + }); } - if (!mcpRateLimiter.check(auth.apiKey.id)) { return new Response("Rate limit exceeded", { status: 429 }); } - const server = createMcpServer(); - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, + return mcpHandler.fetch(request, { + authInfo: { + token: auth.apiKey.id, + clientId: auth.apiKey.id, + scopes: auth.apiKey.scopes, + expiresAt: + auth.apiKey.expiresAt === undefined + ? undefined + : Math.floor(auth.apiKey.expiresAt / 1000), + extra: { httpworkbenchAuth: auth }, + }, }); - - try { - await server.connect(transport); - return await transport.handleRequest(req, { - authInfo: { - token: auth.apiKey.id, - clientId: auth.apiKey.id, - scopes: auth.apiKey.scopes, - expiresAt: - auth.apiKey.expiresAt === undefined - ? undefined - : Math.floor(auth.apiKey.expiresAt / 1000), - extra: { - httpworkbenchAuth: auth, - }, - }, - }); - } finally { - await transport.close(); - await server.close(); - } }; diff --git a/packages/backend/src/server/mcp/context.spec.ts b/packages/backend/src/server/mcp/context.spec.ts new file mode 100644 index 0000000..800ec97 --- /dev/null +++ b/packages/backend/src/server/mcp/context.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import type { Instance } from "shared"; + +process.env.JWT_SECRET = "mcp-context-test-secret"; + +const { serializeInstance, serializeInstanceSummary } = + await import("./context"); + +const instance: Instance = { + id: "instance-1", + ownerId: "owner-1", + createdAt: 1, + webhookIds: [], + public: false, + locked: false, + raw: "HTTP/1.1 200 OK\r\n\r\nok", +}; + +const instanceSummary = { + ...instance, + logCount: 3, +}; + +describe("MCP instance serialization", () => { + test("serializes static-only instance details", () => { + const serialized = serializeInstance(instance); + + expect(serialized.raw).toBe(instance.raw); + expect("kind" in serialized).toBe(false); + expect("processors" in serialized).toBe(false); + }); + + test("omits response content and legacy fields from summaries", () => { + const serialized = serializeInstanceSummary(instanceSummary); + + expect(serialized.id).toBe(instance.id); + expect("raw" in serialized).toBe(false); + expect("kind" in serialized).toBe(false); + expect("processors" in serialized).toBe(false); + expect(serialized.logCount).toBe(3); + }); +}); diff --git a/packages/backend/src/server/mcp/context.ts b/packages/backend/src/server/mcp/context.ts new file mode 100644 index 0000000..5f07498 --- /dev/null +++ b/packages/backend/src/server/mcp/context.ts @@ -0,0 +1,57 @@ +import type { ServerContext } from "@modelcontextprotocol/server"; +import type { ApiKeyScope, Instance, InstanceSummary } from "shared"; +import type { ApiKeyAuthContext } from "../apiKeyAuth"; +import { hasApiKeyScope } from "../apiKeyAuth"; +import { getOwnedInstance } from "../instances/service"; + +export const jsonToolResult = (value: Record) => ({ + content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], + structuredContent: value, +}); + +export const toolError = (message: string) => ({ + content: [{ type: "text" as const, text: message }], + isError: true, +}); + +export const getAuthContext = (extra: Pick) => { + const context = extra.http?.authInfo?.extra?.httpworkbenchAuth; + if (context === undefined) { + throw new Error("Unauthorized"); + } + return context as ApiKeyAuthContext; +}; + +export const requireScope = (auth: ApiKeyAuthContext, scope: ApiKeyScope) => { + if (!hasApiKeyScope(auth.apiKey, scope)) { + throw new Error(`Missing API key scope: ${scope}`); + } +}; + +export const requireOwnedInstance = ( + instanceId: string, + auth: ApiKeyAuthContext, +) => { + const result = getOwnedInstance(instanceId, auth.user.id); + if (!result.ok) { + throw new Error("Instance not found"); + } + return result.value; +}; + +export const serializeInstance = (instance: Instance) => ({ + ...instance, + url: `${instance.id}.${Bun.env.INSTANCES_DOMAIN ?? ""}`, +}); + +export const serializeInstanceSummary = (instance: InstanceSummary) => ({ + id: instance.id, + ownerId: instance.ownerId, + createdAt: instance.createdAt, + expiresAt: instance.expiresAt, + label: instance.label, + public: instance.public, + locked: instance.locked, + logCount: instance.logCount, + url: `${instance.id}.${Bun.env.INSTANCES_DOMAIN ?? ""}`, +}); diff --git a/packages/backend/src/server/mcp/instanceTools.spec.ts b/packages/backend/src/server/mcp/instanceTools.spec.ts new file mode 100644 index 0000000..7f896ed --- /dev/null +++ b/packages/backend/src/server/mcp/instanceTools.spec.ts @@ -0,0 +1,138 @@ +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + addInstance, + addUser, + closeDb, + getInstanceById, + initDb, +} from "../../storage"; + +process.env.JWT_SECRET = "mcp-instance-tools-test-secret"; +process.env.INSTANCES_DOMAIN = "instances.example.com"; +process.env.DOMAIN = "test.local"; + +const [{ createApiKeyForUser }, { closeMcpHandler, handleMcpRequest }] = + await Promise.all([import("../apiKeyAuth"), import("../mcp")]); + +const connectClient = async (apiKey: string) => { + const requests: Array<{ + method: string | null; + name: string | null; + protocolVersion: string | null; + }> = []; + const transport = new StreamableHTTPClientTransport( + new URL("http://test.local/mcp"), + { + requestInit: { + headers: { Authorization: `Bearer ${apiKey}` }, + }, + fetch: async (url, init) => { + const headers = new Headers(init?.headers); + headers.set("host", "test.local"); + requests.push({ + method: headers.get("mcp-method"), + name: headers.get("mcp-name"), + protocolVersion: headers.get("mcp-protocol-version"), + }); + return handleMcpRequest( + new Request(url.toString(), { ...init, headers }), + ); + }, + }, + ); + const client = new Client( + { name: "test", version: "1.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + await client.connect(transport); + return { client, requests }; +}; + +describe("MCP instance tools", () => { + let dataDir = ""; + let apiKey = ""; + + beforeEach(() => { + dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-mcp-tools-")); + const result = initDb({ dataDir, reset: true }); + if (result.kind === "error") { + throw result.error; + } + addUser({ id: "owner-1", googleId: "google-1", createdAt: 1 }); + addInstance({ + id: "instance-1", + ownerId: "owner-1", + createdAt: 1, + webhookIds: [], + public: false, + locked: false, + raw: "HTTP/1.1 200 OK\r\n\r\nok", + }); + apiKey = createApiKeyForUser({ + userId: "owner-1", + name: "MCP test", + scopes: ["instances:write"], + }).secret; + }); + + afterEach(() => { + closeDb(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + afterAll(async () => { + await closeMcpHandler(); + }); + + test("serves MCP 2026-07-28 and updates instance visibility", async () => { + const { client, requests } = await connectClient(apiKey); + try { + expect(client.getProtocolEra()).toBe("modern"); + expect(client.getNegotiatedProtocolVersion()).toBe("2026-07-28"); + + const tools = await client.listTools(); + const visibilityTool = tools.tools.find( + ({ name }) => name === "set_instance_public", + ); + expect(visibilityTool?.description).toContain( + "https://httpworkbench.com/instances/:id", + ); + expect(visibilityTool?.description).toContain("Access is read-only"); + + const published = await client.callTool({ + name: "set_instance_public", + arguments: { instanceId: "instance-1", public: true }, + }); + expect(published.isError).not.toBe(true); + expect(getInstanceById("instance-1")?.public).toBe(true); + expect(requests).toContainEqual({ + method: "tools/call", + name: "set_instance_public", + protocolVersion: "2026-07-28", + }); + + const unpublished = await client.callTool({ + name: "set_instance_public", + arguments: { instanceId: "instance-1", public: false }, + }); + expect(unpublished.isError).not.toBe(true); + expect(getInstanceById("instance-1")?.public).toBe(false); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/backend/src/server/mcp/instanceTools.ts b/packages/backend/src/server/mcp/instanceTools.ts new file mode 100644 index 0000000..3b23c6b --- /dev/null +++ b/packages/backend/src/server/mcp/instanceTools.ts @@ -0,0 +1,192 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; +import { + deleteInstance, + getInstanceSummariesByOwner, + getRecentLogsForInstance, + updateInstance, +} from "../../storage"; +import { createInstance, replaceInstance } from "../instances/service"; +import { hasApiKeyScope } from "../apiKeyAuth"; +import { + getAuthContext, + jsonToolResult, + requireOwnedInstance, + requireScope, + serializeInstance, + serializeInstanceSummary, + toolError, +} from "./context"; + +const mcpLogPreviewLimit = 10; + +export const registerInstanceTools = (server: McpServer) => { + server.registerTool( + "list_instances", + { + title: "List Instances", + description: + "List HTTP Workbench instances owned by the authenticated API key user. Use this first when the user refers to an existing instance but has not provided an instanceId. This is read-only and returns instance metadata plus public URLs, not full response bodies or logs.", + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:read"); + return jsonToolResult({ + instances: getInstanceSummariesByOwner(auth.user.id).map( + serializeInstanceSummary, + ), + }); + }, + ); + + server.registerTool( + "create_instance", + { + title: "Create Instance", + description: + "Create a new HTTP Workbench instance that serves the provided raw HTTP response from a public instance URL. The raw input must be a complete valid HTTP response with status line, headers, blank line, and body.", + inputSchema: z.object({ + raw: z + .string() + .describe( + "Complete raw HTTP response to serve, including status line, headers, blank line, and body.", + ), + label: z.string().max(100).optional(), + }), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ raw, label }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:write"); + const result = createInstance({ + ownerId: auth.user.id, + raw, + label, + }); + return result.ok + ? jsonToolResult({ instance: serializeInstance(result.value) }) + : toolError(result.error.message); + }, + ); + + server.registerTool( + "get_instance", + { + title: "Get Instance", + description: + "Get one owned HTTP Workbench instance, including its full configuration and a small recent log preview when the API key also has logs:read.", + inputSchema: z.object({ instanceId: z.string().min(1) }), + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ instanceId }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:read"); + const instance = requireOwnedInstance(instanceId, auth); + const logs = hasApiKeyScope(auth.apiKey, "logs:read") + ? getRecentLogsForInstance(instance.id, mcpLogPreviewLimit) + : []; + return jsonToolResult({ instance: serializeInstance(instance), logs }); + }, + ); + + server.registerTool( + "update_instance", + { + title: "Update Instance", + description: + "Replace the raw HTTP response served by an owned instance. Existing logs remain unchanged.", + inputSchema: z.object({ + instanceId: z.string().min(1), + raw: z.string(), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ instanceId, raw }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:write"); + const result = replaceInstance({ + instanceId, + ownerId: auth.user.id, + raw, + }); + return result.ok + ? jsonToolResult({ instance: serializeInstance(result.value) }) + : toolError(result.error.message); + }, + ); + + server.registerTool( + "set_instance_public", + { + title: "Set Instance Visibility", + description: + "Publish or unpublish an owned HTTP Workbench instance. When published, an unauthenticated user can visit https://httpworkbench.com/instances/:id and view its logs and basic data. Access is read-only. Publishing is useful for sharing callback information with a triager in a vulnerability report.", + inputSchema: z.object({ + instanceId: z.string().min(1), + public: z.boolean(), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ instanceId, public: isPublic }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:write"); + requireOwnedInstance(instanceId, auth); + const updated = updateInstance(instanceId, (instance) => ({ + ...instance, + public: isPublic, + })); + return updated === undefined + ? toolError("Instance not found") + : jsonToolResult({ instance: serializeInstance(updated) }); + }, + ); + + server.registerTool( + "delete_instance", + { + title: "Delete Instance", + description: "Delete an owned, unlocked HTTP Workbench instance.", + inputSchema: z.object({ instanceId: z.string().min(1) }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async ({ instanceId }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:delete"); + const instance = requireOwnedInstance(instanceId, auth); + if (instance.locked) { + return toolError("Instance is locked"); + } + deleteInstance(instanceId); + return jsonToolResult({ deleted: true }); + }, + ); +}; diff --git a/packages/backend/src/server/mcp/logTools.ts b/packages/backend/src/server/mcp/logTools.ts new file mode 100644 index 0000000..b014899 --- /dev/null +++ b/packages/backend/src/server/mcp/logTools.ts @@ -0,0 +1,158 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; +import { clearLogsForInstance, getLogsForInstancePage } from "../../storage"; +import type { ApiKeyAuthContext } from "../apiKeyAuth"; +import { waitForInstanceLog } from "../instances/logStream"; +import { + clampLogLimit, + decodeLogsCursor, + encodeLogsCursor, + MAX_LOG_LIMIT, +} from "../utils"; +import { + getAuthContext, + jsonToolResult, + requireOwnedInstance, + requireScope, + toolError, +} from "./context"; +import { createConcurrentWatchLimiter } from "./watchLimit"; + +const readLogsPage = (input: { + auth: ApiKeyAuthContext; + instanceId: string; + limit?: number; + cursor?: string; + type?: "http" | "dns" | "smtp"; + sinceTimestamp?: number; +}) => { + requireOwnedInstance(input.instanceId, input.auth); + const cursor = + input.cursor === undefined ? undefined : decodeLogsCursor(input.cursor); + if (input.cursor !== undefined && cursor === undefined) { + throw new Error("Invalid cursor"); + } + + const page = getLogsForInstancePage({ + instanceId: input.instanceId, + limit: clampLogLimit(input.limit), + cursor, + type: input.type, + sinceTimestamp: input.sinceTimestamp, + }); + return { + logs: page.logs, + resumeCursor: + page.resumeCursor === undefined + ? input.cursor + : encodeLogsCursor(page.resumeCursor), + nextCursor: + page.nextCursor === undefined + ? undefined + : encodeLogsCursor(page.nextCursor), + }; +}; + +const logsInputSchema = z.object({ + instanceId: z.string().min(1), + limit: z.number().int().min(1).max(MAX_LOG_LIMIT).optional(), + cursor: z.string().optional(), + type: z.enum(["http", "dns", "smtp"]).optional(), + sinceTimestamp: z.number().optional(), +}); + +const mcpLogWatchTimeoutMs = 20_000; +const mcpLogWatchLimiter = createConcurrentWatchLimiter({ + maxPerKey: 4, + maxGlobal: 100, +}); + +export const registerLogTools = (server: McpServer) => { + server.registerTool( + "clear_instance_logs", + { + title: "Clear Instance Logs", + description: "Clear all recorded interaction logs for an owned instance.", + inputSchema: z.object({ instanceId: z.string().min(1) }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async ({ instanceId }, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "instances:write"); + requireOwnedInstance(instanceId, auth); + clearLogsForInstance(instanceId); + return jsonToolResult({ cleared: true }); + }, + ); + + server.registerTool( + "read_instance_logs", + { + title: "Read Instance Logs", + description: "Read paginated HTTP/DNS/SMTP logs for an owned instance.", + inputSchema: logsInputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, + }, + async (input, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "logs:read"); + return jsonToolResult(readLogsPage({ ...input, auth })); + }, + ); + + server.registerTool( + "watch_instance_logs", + { + title: "Watch Instance Logs", + description: + "Wait for new logs. Pass the returned resumeCursor as cursor on the next call; nextCursor only indicates another historical page is already available.", + inputSchema: logsInputSchema, + annotations: { + readOnlyHint: true, + idempotentHint: false, + openWorldHint: false, + }, + }, + async (input, extra) => { + const auth = getAuthContext(extra); + requireScope(auth, "logs:stream"); + requireOwnedInstance(input.instanceId, auth); + const release = mcpLogWatchLimiter.acquire(auth.apiKey.id); + if (release === undefined) { + return toolError("Too many concurrent log watches"); + } + + const waiter = waitForInstanceLog( + input.instanceId, + mcpLogWatchTimeoutMs, + extra.mcpReq.signal, + ); + try { + if (extra.mcpReq.signal.aborted) { + return toolError("Log watch cancelled"); + } + let page = readLogsPage({ ...input, auth }); + if (page.logs.length === 0) { + await waiter.promise; + if (extra.mcpReq.signal.aborted) { + return toolError("Log watch cancelled"); + } + page = readLogsPage({ ...input, auth }); + } + return jsonToolResult({ ...page, pollAfterMs: 1000 }); + } finally { + waiter.cancel(); + release(); + } + }, + ); +}; diff --git a/packages/backend/src/server/mcp/watchLimit.spec.ts b/packages/backend/src/server/mcp/watchLimit.spec.ts new file mode 100644 index 0000000..939ca43 --- /dev/null +++ b/packages/backend/src/server/mcp/watchLimit.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { createConcurrentWatchLimiter } from "./watchLimit"; + +test("limits concurrent watches per key and globally", () => { + const limiter = createConcurrentWatchLimiter({ + maxPerKey: 2, + maxGlobal: 3, + }); + const releaseA1 = limiter.acquire("a"); + const releaseA2 = limiter.acquire("a"); + const releaseB = limiter.acquire("b"); + + expect(releaseA1).toBeFunction(); + expect(releaseA2).toBeFunction(); + expect(releaseB).toBeFunction(); + expect(limiter.acquire("a")).toBeUndefined(); + expect(limiter.acquire("c")).toBeUndefined(); + + releaseA1?.(); + const releaseC = limiter.acquire("c"); + expect(releaseC).toBeFunction(); + + releaseA1?.(); + releaseA2?.(); + releaseB?.(); + releaseC?.(); + expect(limiter.acquire("a")).toBeFunction(); +}); diff --git a/packages/backend/src/server/mcp/watchLimit.ts b/packages/backend/src/server/mcp/watchLimit.ts new file mode 100644 index 0000000..50c84fb --- /dev/null +++ b/packages/backend/src/server/mcp/watchLimit.ts @@ -0,0 +1,37 @@ +export const createConcurrentWatchLimiter = ({ + maxPerKey, + maxGlobal, +}: { + maxPerKey: number; + maxGlobal: number; +}) => { + const activeByKey = new Map(); + let activeGlobal = 0; + + return { + acquire(key: string): (() => void) | undefined { + const activeForKey = activeByKey.get(key) ?? 0; + if (activeForKey >= maxPerKey || activeGlobal >= maxGlobal) { + return undefined; + } + + activeByKey.set(key, activeForKey + 1); + activeGlobal += 1; + let released = false; + + return () => { + if (released) { + return; + } + released = true; + activeGlobal -= 1; + const remainingForKey = (activeByKey.get(key) ?? 1) - 1; + if (remainingForKey === 0) { + activeByKey.delete(key); + } else { + activeByKey.set(key, remainingForKey); + } + }; + }, + }; +}; diff --git a/packages/backend/src/server/protocolRateLimit.spec.ts b/packages/backend/src/server/protocolRateLimit.spec.ts new file mode 100644 index 0000000..da1a199 --- /dev/null +++ b/packages/backend/src/server/protocolRateLimit.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { createBoundedProtocolRateLimiter } from "./protocolRateLimit"; + +test("limits requests within a fixed window", () => { + const limiter = createBoundedProtocolRateLimiter({ + maxRequests: 2, + windowMs: 1000, + maxEntries: 10, + }); + + expect(limiter.check("instance:address", 100)).toBe(true); + expect(limiter.check("instance:address", 200)).toBe(true); + expect(limiter.check("instance:address", 300)).toBe(false); + expect(limiter.check("instance:address", 1100)).toBe(true); +}); + +test("rejects new keys at capacity without resetting active buckets", () => { + const limiter = createBoundedProtocolRateLimiter({ + maxRequests: 2, + windowMs: 1000, + maxEntries: 2, + }); + + expect(limiter.check("a", 0)).toBe(true); + expect(limiter.check("b", 0)).toBe(true); + expect(limiter.check("a", 1)).toBe(true); + expect(limiter.canCheck("c", 1)).toBe(false); + expect(limiter.check("c", 1)).toBe(false); + expect(limiter.check("a", 2)).toBe(false); + expect(limiter.check("c", 1000)).toBe(true); +}); + +test("recovers capacity as soon as a staggered bucket expires", () => { + const limiter = createBoundedProtocolRateLimiter({ + maxRequests: 2, + windowMs: 1000, + maxEntries: 2, + }); + + expect(limiter.check("a", 0)).toBe(true); + expect(limiter.check("b", 500)).toBe(true); + expect(limiter.check("a", 1000)).toBe(true); + + expect(limiter.canCheck("c", 1500)).toBe(true); + expect(limiter.check("c", 1500)).toBe(true); +}); diff --git a/packages/backend/src/server/protocolRateLimit.ts b/packages/backend/src/server/protocolRateLimit.ts new file mode 100644 index 0000000..bbe2733 --- /dev/null +++ b/packages/backend/src/server/protocolRateLimit.ts @@ -0,0 +1,78 @@ +type RateLimitEntry = { + windowStartedAt: number; + count: number; +}; + +export const createBoundedProtocolRateLimiter = ({ + maxRequests, + windowMs, + maxEntries, +}: { + maxRequests: number; + windowMs: number; + maxEntries: number; +}) => { + const entries = new Map(); + let nextExpiryAt = Number.POSITIVE_INFINITY; + + const pruneExpired = (now: number) => { + if (now < nextExpiryAt) { + return; + } + + nextExpiryAt = Number.POSITIVE_INFINITY; + for (const [key, entry] of entries) { + if (now - entry.windowStartedAt >= windowMs) { + entries.delete(key); + } else { + nextExpiryAt = Math.min(nextExpiryAt, entry.windowStartedAt + windowMs); + } + } + }; + + const addEntry = (key: string, now: number) => { + entries.set(key, { windowStartedAt: now, count: 1 }); + nextExpiryAt = Math.min(nextExpiryAt, now + windowMs); + }; + + return { + canCheck(key: string, now: number): boolean { + const current = entries.get(key); + if (current !== undefined) { + return ( + now - current.windowStartedAt >= windowMs || + current.count < maxRequests + ); + } + + pruneExpired(now); + return entries.size < maxEntries; + }, + check(key: string, now: number): boolean { + const current = entries.get(key); + if (current !== undefined && now - current.windowStartedAt < windowMs) { + if (current.count >= maxRequests) { + return false; + } + current.count += 1; + return true; + } + + if (current !== undefined) { + entries.delete(key); + } + pruneExpired(now); + + if (entries.size >= maxEntries) { + return false; + } + + addEntry(key, now); + return true; + }, + reset(): void { + entries.clear(); + nextExpiryAt = Number.POSITIVE_INFINITY; + }, + }; +}; diff --git a/packages/backend/src/server/scopeAuth.spec.ts b/packages/backend/src/server/scopeAuth.spec.ts index 582c1e1..be3ed5c 100644 --- a/packages/backend/src/server/scopeAuth.spec.ts +++ b/packages/backend/src/server/scopeAuth.spec.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Elysia } from "elysia"; +import * as jose from "jose"; import type { ApiKey, ApiKeyScope, UserRecord } from "shared"; process.env.JWT_SECRET = "scope-auth-test-secret"; @@ -48,6 +49,17 @@ mock.module("../storage", () => ({ } return apiKey; }, + getActiveApiKeyById: (id: string, now: number): ApiKey | undefined => { + const apiKey = [...state.apiKeys.values()].find((key) => key.id === id); + if (apiKey?.expiresAt !== undefined && apiKey.expiresAt <= now) { + return undefined; + } + if (apiKey === undefined) { + return undefined; + } + const { secretHash: _secretHash, ...publicApiKey } = apiKey; + return publicApiKey; + }, getUserById: (id: string): UserRecord | undefined => state.users.get(id), markApiKeyUsed: (id: string, lastUsedAt: number): void => { for (const [prefix, apiKey] of state.apiKeys) { @@ -208,4 +220,59 @@ describe("auth macro enforcement (e2e)", () => { expect(viaCookie.status).toBe(200); expect(await viaCookie.json()).toEqual({ id: "user-1" }); }); + + test("api-key dashboard sessions remain tied to active key state", async () => { + const { auth, apiKeyAuth } = await loadModules(); + const app = new Elysia() + .use(auth.authPlugin) + .get("/session", ({ user }) => ({ id: user.id }), { session: true }); + const key = apiKeyAuth.createApiKeyForUser({ + userId: "user-1", + name: "dashboard", + }); + const sessionToken = await auth.issueAuthToken("user-1", key.apiKey); + + const active = await app.handle(authedRequest("/session", sessionToken)); + expect(active.status).toBe(200); + + state.apiKeys.delete(key.apiKey.prefix); + const revoked = await app.handle(authedRequest("/session", sessionToken)); + expect(revoked.status).toBe(401); + }); + + test("caps api-key dashboard session expiry to the key expiry", async () => { + const { auth, apiKeyAuth } = await loadModules(); + const expiresAt = Date.now() + 60_000; + const key = apiKeyAuth.createApiKeyForUser({ + userId: "user-1", + name: "short-lived", + expiresAt, + }); + const token = await auth.issueAuthToken("user-1", key.apiKey); + const payload = jose.decodeJwt(token); + + expect(payload.apiKeyId).toBe(key.apiKey.id); + expect(payload.exp).toBe(Math.floor(expiresAt / 1000)); + }); + + test("rate limits api-key-derived dashboard sessions", async () => { + const { auth, apiKeyAuth } = await loadModules(); + const app = new Elysia() + .use(auth.authPlugin) + .get("/session", ({ user }) => ({ id: user.id }), { session: true }); + const key = apiKeyAuth.createApiKeyForUser({ + userId: "user-1", + name: "rate-limited", + }); + const token = await auth.issueAuthToken("user-1", key.apiKey); + + for (let index = 0; index < 300; index += 1) { + expect((await app.handle(authedRequest("/session", token))).status).toBe( + 200, + ); + } + expect((await app.handle(authedRequest("/session", token))).status).toBe( + 429, + ); + }); }); diff --git a/packages/backend/src/server/smtp/server.spec.ts b/packages/backend/src/server/smtp/server.spec.ts index c15f18e..1d4352e 100644 --- a/packages/backend/src/server/smtp/server.spec.ts +++ b/packages/backend/src/server/smtp/server.spec.ts @@ -1,6 +1,6 @@ import { createConnection } from "node:net"; import { describe, expect, test } from "bun:test"; -import type { Instance, Log } from "shared"; +import type { Log } from "shared"; import { createSmtpServer, createSmtpSession, @@ -13,24 +13,12 @@ const runtimeConfig = { smtpPort: 0, }; -const createInstance = (id: string): Instance => ({ - id, - ownerId: "owner-1", - createdAt: 1, - webhookIds: [], - public: false, - locked: false, - kind: "static", - raw: "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n", -}); - const createCapturingDeps = (knownInstanceId: string) => { const added: Log[] = []; const broadcasted: Log[] = []; const deps: SmtpServerDependencies = { - getInstanceById: async (id) => - id === knownInstanceId ? createInstance(id) : undefined, + hasActiveInstance: async (id) => id === knownInstanceId, addLog: async (log) => { added.push(log); return log; diff --git a/packages/backend/src/server/smtp/server.ts b/packages/backend/src/server/smtp/server.ts index 4c38995..a5e5864 100644 --- a/packages/backend/src/server/smtp/server.ts +++ b/packages/backend/src/server/smtp/server.ts @@ -1,6 +1,8 @@ import { createServer, type Server as TcpServer, type Socket } from "node:net"; -import type { Instance, Log } from "shared"; +import type { Log } from "shared"; +import type { AddLogOutcome } from "../../storage"; import { smtpConfig, type SmtpConfig } from "../../config"; +import { createBoundedProtocolRateLimiter } from "../protocolRateLimit"; import { formatSmtpLogRaw, maxSmtpDataBytes, @@ -21,16 +23,15 @@ const smtpLogWindowMs = 60_000; const maxSmtpLogsPerWindow = 120; const maxSmtpLogRateLimitEntries = 10_000; -type SmtpLogRateLimitEntry = { - windowStartedAt: number; - count: number; -}; - -const smtpLogRateLimit = new Map(); +const smtpLogRateLimit = createBoundedProtocolRateLimiter({ + maxRequests: maxSmtpLogsPerWindow, + windowMs: smtpLogWindowMs, + maxEntries: maxSmtpLogRateLimitEntries, +}); export type SmtpServerDependencies = { - getInstanceById: (id: string) => Promise; - addLog: (log: Log) => Promise; + hasActiveInstance: (id: string) => Promise; + addLog: (log: Log) => Log | AddLogOutcome | Promise; broadcastLog: (log: Log) => void; createId: () => string; now: () => number; @@ -70,51 +71,13 @@ const toRuntimeSmtpConfig = (config: SmtpConfig): SmtpRuntimeConfig => { }; }; -const pruneSmtpLogRateLimit = (now: number): void => { - for (const [key, entry] of smtpLogRateLimit) { - if (now - entry.windowStartedAt >= smtpLogWindowMs) { - smtpLogRateLimit.delete(key); - } - } - - while (smtpLogRateLimit.size > maxSmtpLogRateLimitEntries) { - const oldestKey = smtpLogRateLimit.keys().next().value; - if (oldestKey === undefined) { - break; - } - - smtpLogRateLimit.delete(oldestKey); - } -}; - const shouldPersistSmtpLog = (params: { instanceId: string; clientAddress: string; now: number; }): boolean => { - if (smtpLogRateLimit.size >= maxSmtpLogRateLimitEntries) { - pruneSmtpLogRateLimit(params.now); - } - const key = `${params.instanceId}:${params.clientAddress}`; - const entry = smtpLogRateLimit.get(key); - if ( - entry === undefined || - params.now - entry.windowStartedAt >= smtpLogWindowMs - ) { - smtpLogRateLimit.set(key, { - windowStartedAt: params.now, - count: 1, - }); - return true; - } - - if (entry.count >= maxSmtpLogsPerWindow) { - return false; - } - - entry.count += 1; - return true; + return smtpLogRateLimit.check(key, params.now); }; const buildEhloReplies = (config: SmtpRuntimeConfig): string[] => { @@ -192,8 +155,18 @@ export const createSmtpSession = ({ } satisfies Log; try { - await deps.addLog(log); - deps.broadcastLog(log); + const pendingOutcome = deps.addLog(log); + const outcome = + pendingOutcome instanceof Promise + ? await pendingOutcome + : pendingOutcome; + if ( + outcome === undefined || + !("tombstoned" in outcome) || + !outcome.tombstoned + ) { + deps.broadcastLog(log); + } } catch (error) { console.error("Failed to persist SMTP log", error); } @@ -294,13 +267,16 @@ export const createSmtpSession = ({ return reply("550 5.1.1 No such instance"); } - const instance = await deps.getInstanceById(resolution.instanceId); - if (instance === undefined) { + if (!(await deps.hasActiveInstance(resolution.instanceId))) { return reply("550 5.1.1 No such instance"); } - if (!recipients.some((entry) => entry.instanceId === instance.id)) { - recipients.push({ raw: to, instanceId: instance.id }); + if ( + !recipients.some( + (entry) => entry.instanceId === resolution.instanceId, + ) + ) { + recipients.push({ raw: to, instanceId: resolution.instanceId }); } return reply("250 2.1.5 OK"); @@ -420,7 +396,10 @@ const createSmtpTcpServer = ( } pending = Buffer.concat([pending, chunk]); - void flush(); + flush().catch((error) => { + console.error("Failed to process SMTP request", error); + socket.destroy(); + }); }); socket.on("error", (error) => { diff --git a/packages/backend/src/server/webSocketOrigin.spec.ts b/packages/backend/src/server/webSocketOrigin.spec.ts new file mode 100644 index 0000000..ab573d8 --- /dev/null +++ b/packages/backend/src/server/webSocketOrigin.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { isAllowedBrowserOrigin } from "./webSocketOrigin"; + +const request = (url: string, origin?: string) => + new Request(url, { + headers: origin === undefined ? undefined : { origin }, + }); + +describe("websocket origin validation", () => { + test("allows the configured frontend and non-browser clients", () => { + expect( + isAllowedBrowserOrigin( + request("https://api.example.com/ws", "https://app.example.com"), + "https://app.example.com", + ), + ).toBe(true); + expect(isAllowedBrowserOrigin(request("https://api.example.com/ws"))).toBe( + true, + ); + }); + + test("rejects sibling and attacker origins", () => { + expect( + isAllowedBrowserOrigin( + request( + "https://httpworkbench.com/ws", + "https://attacker.instances.httpworkbench.com", + ), + "https://httpworkbench.com", + ), + ).toBe(false); + expect( + isAllowedBrowserOrigin( + request("https://api.example.com/ws", "not-an-origin"), + "https://app.example.com", + ), + ).toBe(false); + }); + + test("allows local frontend proxies only for loopback backends", () => { + expect( + isAllowedBrowserOrigin( + request("http://localhost:8081/ws", "http://localhost:5173"), + "https://app.example.com", + ), + ).toBe(true); + expect( + isAllowedBrowserOrigin( + request("https://api.example.com/ws", "http://localhost:5173"), + "https://app.example.com", + ), + ).toBe(false); + }); +}); diff --git a/packages/backend/src/server/webSocketOrigin.ts b/packages/backend/src/server/webSocketOrigin.ts new file mode 100644 index 0000000..4d04687 --- /dev/null +++ b/packages/backend/src/server/webSocketOrigin.ts @@ -0,0 +1,38 @@ +const loopbackHostnames = new Set(["localhost", "127.0.0.1", "[::1]"]); + +const parseOrigin = (value: string | undefined | null) => { + if (value === undefined || value === null || value === "") { + return undefined; + } + try { + return new URL(value); + } catch { + return undefined; + } +}; + +export const isAllowedBrowserOrigin = ( + request: Request, + frontendUrl = Bun.env.FRONTEND_URL, +) => { + const originHeader = request.headers.get("origin"); + if (originHeader === null) { + return true; + } + const origin = parseOrigin(originHeader); + if (origin === undefined) { + return false; + } + const configuredFrontend = parseOrigin(frontendUrl); + if (configuredFrontend?.origin === origin.origin) { + return true; + } + const requestUrl = new URL(request.url); + if (requestUrl.origin === origin.origin) { + return true; + } + return ( + loopbackHostnames.has(requestUrl.hostname) && + loopbackHostnames.has(origin.hostname) + ); +}; diff --git a/packages/backend/src/server/webhooks/discord.ts b/packages/backend/src/server/webhooks/discord.ts index 6af5f0a..57cff07 100644 --- a/packages/backend/src/server/webhooks/discord.ts +++ b/packages/backend/src/server/webhooks/discord.ts @@ -21,6 +21,11 @@ const DISCORD_WEBHOOK_URL_POLICY = { allowedProtocols: ["https"], } as const; +export type DiscordLogBatchSummary = { + total: number; + counts: Record; +}; + export function validateDiscordWebhookUrl(url: string): { valid: boolean; error?: string; @@ -87,6 +92,7 @@ const renderDiscordMessageTemplate = (template: string, log: Log): string => { export const buildDiscordNotificationPayload = ( log: Log, messageTemplate?: string, + batch?: DiscordLogBatchSummary, ) => { const color = DISCORD_LOG_COLORS[log.type]; const timestamp = new Date(log.timestamp).toISOString(); @@ -108,12 +114,30 @@ export const buildDiscordNotificationPayload = ( renderDiscordMessageTemplate(messageTemplate, log), MAX_MESSAGE_CONTENT_LENGTH, ); + const batchFields = + batch === undefined || batch.total <= 1 + ? [] + : [ + { + name: "Debounced batch", + value: (["http", "dns", "smtp"] as const) + .flatMap((type) => { + const count = batch.counts[type]; + return count === 0 ? [] : [`${type.toUpperCase()}: ${count}`]; + }) + .join("\n"), + inline: false, + }, + ]; return { content, embeds: [ { - title: `${log.type.toUpperCase()} Log Received`, + title: + batch === undefined || batch.total <= 1 + ? `${log.type.toUpperCase()} Log Received` + : `${log.type.toUpperCase()} Log Received (${batch.total} total)`, color, fields: [ { @@ -131,6 +155,7 @@ export const buildDiscordNotificationPayload = ( value: timestamp, inline: false, }, + ...batchFields, { name: "Raw Content", value: `\`\`\`\n${truncatedRaw}\n\`\`\``, diff --git a/packages/backend/src/server/webhooks/index.ts b/packages/backend/src/server/webhooks/index.ts index 88155f8..46f9e6f 100644 --- a/packages/backend/src/server/webhooks/index.ts +++ b/packages/backend/src/server/webhooks/index.ts @@ -1,5 +1,6 @@ export { validateDiscordWebhookUrl } from "./discord"; export { - sendDiscordNotificationThrottled, + flushDiscordNotificationQueue, + queueDiscordNotification, sendDiscordTestNotification, } from "./service"; diff --git a/packages/backend/src/server/webhooks/service.spec.ts b/packages/backend/src/server/webhooks/service.spec.ts index 538ac0c..07793b2 100644 --- a/packages/backend/src/server/webhooks/service.spec.ts +++ b/packages/backend/src/server/webhooks/service.spec.ts @@ -1,8 +1,10 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, describe, expect, jest, mock, test } from "bun:test"; import type { Log, Webhook } from "shared"; +import { abusePolicy } from "../../config/abuse"; import { - resetDiscordNotificationThrottleStateForTests, - sendDiscordNotificationThrottled, + flushDiscordNotificationQueue, + queueDiscordNotification, + resetDiscordNotificationStateForTests, sendDiscordNotification, sendDiscordTestNotification, } from "./service"; @@ -26,26 +28,77 @@ const log: Log = { const originalFetch = globalThis.fetch; -describe("sendDiscordNotificationThrottled", () => { +describe("queueDiscordNotification", () => { afterEach(() => { - resetDiscordNotificationThrottleStateForTests(); + resetDiscordNotificationStateForTests(); + jest.useRealTimers(); globalThis.fetch = originalFetch; mock.restore(); }); - test("skips a second send to the same webhook within 1 second", async () => { - const fetchMock = mock(() => - Promise.resolve(new Response(null, { status: 204 })), + test("debounces a burst and keeps HTTP visible among DNS logs", async () => { + let requestBody = ""; + const fetchMock = mock( + (_input: string | URL | Request, init?: { body?: unknown }) => { + requestBody = String(init?.body ?? ""); + return Promise.resolve(new Response(null, { status: 204 })); + }, ); globalThis.fetch = fetchMock as unknown as typeof fetch; + jest.useFakeTimers(); + + queueDiscordNotification(webhook, { + ...log, + id: "dns-1", + type: "dns", + timestamp: 1, + raw: "A example.test", + }); + queueDiscordNotification(webhook, { + ...log, + id: "http-1", + type: "http", + timestamp: 2, + raw: "GET /important HTTP/1.1", + }); + queueDiscordNotification(webhook, { + ...log, + id: "dns-2", + type: "dns", + timestamp: 3, + raw: "AAAA example.test", + }); - await sendDiscordNotificationThrottled(webhook, log); - await sendDiscordNotificationThrottled(webhook, log); + jest.advanceTimersByTime(abusePolicy.discordWebhookDebounceMs - 1); + expect(fetchMock).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); expect(fetchMock).toHaveBeenCalledTimes(1); + + await flushDiscordNotificationQueue(); + expect(JSON.parse(requestBody)).toMatchObject({ + embeds: [ + { + title: "HTTP Log Received (3 total)", + fields: [ + { name: "Type", value: "HTTP" }, + { name: "Address" }, + { name: "Timestamp" }, + { + name: "Debounced batch", + value: "HTTP: 1\nDNS: 2", + }, + { + name: "Raw Content", + value: "```\nGET /important HTTP/1.1\n```", + }, + ], + }, + ], + }); }); - test("allows at most five Discord sends per instance per minute", async () => { + test("allows at most five queued Discord batches per instance per minute", async () => { const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 204 })), ); @@ -55,29 +108,140 @@ describe("sendDiscordNotificationThrottled", () => { Date.now = () => fixedNow; try { for (let i = 0; i < 5; i += 1) { - await sendDiscordNotificationThrottled( - { - ...webhook, - id: `webhook-${i}`, - }, - log, - ); + expect( + queueDiscordNotification( + { + ...webhook, + id: `webhook-${i}`, + }, + log, + ), + ).toBe(true); + } + expect( + queueDiscordNotification({ ...webhook, id: "webhook-extra" }, log), + ).toBe(false); + + await flushDiscordNotificationQueue(); + expect(fetchMock).toHaveBeenCalledTimes(5); + } finally { + Date.now = originalDateNow; + } + }); + + test("coalesced logs do not consume additional instance slots", async () => { + const fetchMock = mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const now = 1_700_000_000_000; + const originalDateNow = Date.now; + Date.now = () => now; + try { + for (let i = 0; i < 10; i += 1) { + expect( + queueDiscordNotification(webhook, { + ...log, + id: `log-${i}`, + }), + ).toBe(true); + } + for (let i = 2; i < 6; i += 1) { + expect( + queueDiscordNotification({ ...webhook, id: `webhook-${i}` }, log), + ).toBe(true); } - await sendDiscordNotificationThrottled( - { ...webhook, id: "webhook-extra" }, - log, - ); + expect( + queueDiscordNotification({ ...webhook, id: "webhook-extra" }, log), + ).toBe(false); + await flushDiscordNotificationQueue(); expect(fetchMock).toHaveBeenCalledTimes(5); } finally { Date.now = originalDateNow; } }); + + test("keeps shared webhook batches separate by instance", async () => { + const requestBodies: unknown[] = []; + const fetchMock = mock( + (_input: string | URL | Request, init?: { body?: unknown }) => { + requestBodies.push(JSON.parse(String(init?.body ?? ""))); + return Promise.resolve(new Response(null, { status: 204 })); + }, + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + expect(queueDiscordNotification(webhook, log)).toBe(true); + expect( + queueDiscordNotification(webhook, { + ...log, + id: "log-2", + instanceId: "inst-2", + }), + ).toBe(true); + + await flushDiscordNotificationQueue(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(requestBodies).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + embeds: [ + expect.objectContaining({ + footer: { text: "Instance: inst-1" }, + }), + ], + }), + expect.objectContaining({ + embeds: [ + expect.objectContaining({ + footer: { text: "Instance: inst-2" }, + }), + ], + }), + ]), + ); + }); + + test("does not bypass an instance limit through a shared pending webhook", async () => { + const fetchMock = mock(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + const fixedNow = 1_700_000_000_000; + const originalDateNow = Date.now; + Date.now = () => fixedNow; + try { + for (let i = 0; i < 5; i += 1) { + expect( + queueDiscordNotification( + { ...webhook, id: `instance-two-webhook-${i}` }, + { ...log, id: `instance-two-log-${i}`, instanceId: "inst-2" }, + ), + ).toBe(true); + } + + expect(queueDiscordNotification(webhook, log)).toBe(true); + expect( + queueDiscordNotification(webhook, { + ...log, + id: "instance-two-shared-log", + instanceId: "inst-2", + }), + ).toBe(false); + + await flushDiscordNotificationQueue(); + expect(fetchMock).toHaveBeenCalledTimes(6); + } finally { + Date.now = originalDateNow; + } + }); }); describe("sendDiscordNotification", () => { afterEach(() => { - resetDiscordNotificationThrottleStateForTests(); + resetDiscordNotificationStateForTests(); globalThis.fetch = originalFetch; mock.restore(); }); @@ -116,11 +280,26 @@ describe("sendDiscordNotification", () => { content: "<@123456> GET / HTTP/1.1", }); }); + + test("sets a deadline on Discord requests", async () => { + let signal: AbortSignal | undefined; + const fetchMock = mock( + (_input: string | URL | Request, init?: Parameters[1]) => { + signal = init?.signal ?? undefined; + return Promise.resolve(new Response(null, { status: 204 })); + }, + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await sendDiscordNotification(webhook, log); + + expect(signal).toBeInstanceOf(AbortSignal); + }); }); describe("sendDiscordTestNotification", () => { afterEach(() => { - resetDiscordNotificationThrottleStateForTests(); + resetDiscordNotificationStateForTests(); globalThis.fetch = originalFetch; mock.restore(); }); @@ -147,4 +326,30 @@ describe("sendDiscordTestNotification", () => { "Test HTTP 203.0.113.10 example-instance 2026-04-12T12:00:00.000Z GET /example HTTP/1.1\nHost: example.httpworkbench.dev\nUser-Agent: webhook-test-button\nX-Test: true", }); }); + + test("bounds concurrent outbound Discord requests", async () => { + const pendingResponses: Array<(response: Response) => void> = []; + const fetchMock = mock( + () => + new Promise((resolve) => { + pendingResponses.push(resolve); + }), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const activeRequests = Array.from({ length: 16 }, () => + sendDiscordTestNotification({ url: webhook.url }), + ); + await Bun.sleep(0); + + expect(fetchMock).toHaveBeenCalledTimes(16); + await expect( + sendDiscordTestNotification({ url: webhook.url }), + ).rejects.toThrow("Discord webhook concurrency limit reached"); + + for (const resolve of pendingResponses) { + resolve(new Response(null, { status: 204 })); + } + await Promise.all(activeRequests); + }); }); diff --git a/packages/backend/src/server/webhooks/service.ts b/packages/backend/src/server/webhooks/service.ts index b28395d..41ea035 100644 --- a/packages/backend/src/server/webhooks/service.ts +++ b/packages/backend/src/server/webhooks/service.ts @@ -1,22 +1,36 @@ import type { Log, Webhook } from "shared"; import { abusePolicy } from "../../config/abuse"; +import { createBoundedProtocolRateLimiter } from "../protocolRateLimit"; import { buildDiscordNotificationPayload, + type DiscordLogBatchSummary, validateDiscordWebhookUrl, } from "./discord"; -const webhookMinIntervalMs = 1000; -const lastDiscordSendByWebhookId = new Map(); - -type DiscordWebhookInstanceBucket = { - minuteStartMs: number; - sendsInMinute: number; +const discordRequestTimeoutMs = 10_000; +const maxPendingDiscordBatches = 10_000; +const maxConcurrentDiscordRequests = 16; +let activeDiscordRequests = 0; +const discordInstanceRateLimit = createBoundedProtocolRateLimiter({ + maxRequests: abusePolicy.discordWebhookLimitPerMinutePerInstance, + windowMs: abusePolicy.minuteBucketMs, + maxEntries: maxPendingDiscordBatches, +}); +const discordLogPriority: Record = { + dns: 0, + smtp: 1, + http: 2, }; - -const discordWebhookSendsByInstanceId = new Map< - string, - DiscordWebhookInstanceBucket ->(); +type PendingDiscordBatch = { + webhook: Webhook; + representativeLog: Log; + summary: DiscordLogBatchSummary; + timeout: ReturnType; +}; +const pendingDiscordBatches = new Map(); +const activeDiscordNotifications = new Set>(); +const getDiscordBatchKey = (webhookId: string, instanceId: string) => + JSON.stringify([webhookId, instanceId]); const EXAMPLE_DISCORD_LOG: Log = { id: "test-log", instanceId: "example-instance", @@ -31,63 +45,60 @@ const EXAMPLE_DISCORD_LOG: Log = { ].join("\n"), }; -const tryConsumeDiscordWebhookSlot = ( - instanceId: string, - now: number, -): boolean => { - let bucket = discordWebhookSendsByInstanceId.get(instanceId); - if ( - bucket === undefined || - now >= bucket.minuteStartMs + abusePolicy.minuteBucketMs - ) { - bucket = { minuteStartMs: now, sendsInMinute: 0 }; - discordWebhookSendsByInstanceId.set(instanceId, bucket); +export function resetDiscordNotificationStateForTests(): void { + for (const batch of pendingDiscordBatches.values()) { + clearTimeout(batch.timeout); } - if ( - bucket.sendsInMinute >= abusePolicy.discordWebhookLimitPerMinutePerInstance - ) { - return false; - } - bucket.sendsInMinute += 1; - return true; -}; - -export function resetDiscordNotificationThrottleStateForTests(): void { - lastDiscordSendByWebhookId.clear(); - discordWebhookSendsByInstanceId.clear(); + pendingDiscordBatches.clear(); + discordInstanceRateLimit.reset(); } const postDiscordNotification = async ( webhook: Webhook, log: Log, + batch?: DiscordLogBatchSummary, ): Promise => { const validation = validateDiscordWebhookUrl(webhook.url); if (!validation.valid) { throw new Error(validation.error ?? "Invalid webhook URL"); } - const response = await fetch(webhook.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(buildDiscordNotificationPayload(log, webhook.message)), - redirect: "error", - }); + if (activeDiscordRequests >= maxConcurrentDiscordRequests) { + throw new Error("Discord webhook concurrency limit reached"); + } + activeDiscordRequests += 1; - if (!response.ok) { - throw new Error( - `Discord webhook request failed: ${response.status} ${response.statusText}`, - ); + try { + const response = await fetch(webhook.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify( + buildDiscordNotificationPayload(log, webhook.message, batch), + ), + redirect: "error", + signal: AbortSignal.timeout(discordRequestTimeoutMs), + }); + + await response.body?.cancel(); + if (!response.ok) { + throw new Error( + `Discord webhook request failed: ${response.status} ${response.statusText}`, + ); + } + } finally { + activeDiscordRequests -= 1; } }; export async function sendDiscordNotification( webhook: Webhook, log: Log, + batch?: DiscordLogBatchSummary, ): Promise { try { - await postDiscordNotification(webhook, log); + await postDiscordNotification(webhook, log, batch); } catch (error) { console.error( `Error sending Discord notification to webhook ${webhook.id}:`, @@ -113,18 +124,97 @@ export async function sendDiscordTestNotification(input: { ); } -export async function sendDiscordNotificationThrottled( +const trackDiscordNotification = (notification: Promise) => { + activeDiscordNotifications.add(notification); + notification.finally(() => activeDiscordNotifications.delete(notification)); + return notification; +}; + +const deliverPendingDiscordBatch = ( + batchKey: string, + expectedBatch: PendingDiscordBatch, +) => { + const batch = pendingDiscordBatches.get(batchKey); + if (batch !== expectedBatch) { + return Promise.resolve(); + } + + clearTimeout(batch.timeout); + pendingDiscordBatches.delete(batchKey); + return trackDiscordNotification( + sendDiscordNotification( + batch.webhook, + batch.representativeLog, + batch.summary, + ), + ); +}; + +const addToPendingDiscordBatch = ( + batch: PendingDiscordBatch, webhook: Webhook, log: Log, -): Promise { +) => { + batch.webhook = webhook; + batch.summary.total += 1; + batch.summary.counts[log.type] += 1; + + const currentPriority = discordLogPriority[batch.representativeLog.type]; + const nextPriority = discordLogPriority[log.type]; + if ( + nextPriority > currentPriority || + (nextPriority === currentPriority && + log.timestamp >= batch.representativeLog.timestamp) + ) { + batch.representativeLog = log; + } +}; + +export function queueDiscordNotification(webhook: Webhook, log: Log): boolean { + const batchKey = getDiscordBatchKey(webhook.id, log.instanceId); + const pending = pendingDiscordBatches.get(batchKey); + if (pending !== undefined) { + addToPendingDiscordBatch(pending, webhook, log); + return true; + } + + if (pendingDiscordBatches.size >= maxPendingDiscordBatches) { + return false; + } + const now = Date.now(); - const last = lastDiscordSendByWebhookId.get(webhook.id) ?? 0; - if (now - last < webhookMinIntervalMs) { - return; + if (!discordInstanceRateLimit.check(log.instanceId, now)) { + return false; } - if (!tryConsumeDiscordWebhookSlot(log.instanceId, now)) { - return; + + const batch: PendingDiscordBatch = { + webhook, + representativeLog: log, + summary: { + total: 1, + counts: { + dns: log.type === "dns" ? 1 : 0, + http: log.type === "http" ? 1 : 0, + smtp: log.type === "smtp" ? 1 : 0, + }, + }, + timeout: setTimeout(() => { + void deliverPendingDiscordBatch(batchKey, batch); + }, abusePolicy.discordWebhookDebounceMs), + }; + pendingDiscordBatches.set(batchKey, batch); + return true; +} + +export async function flushDiscordNotificationQueue(): Promise { + while ( + pendingDiscordBatches.size > 0 || + activeDiscordNotifications.size > 0 + ) { + const pending = Array.from(pendingDiscordBatches.entries()); + const deliveries = pending.map(([batchKey, batch]) => + deliverPendingDiscordBatch(batchKey, batch), + ); + await Promise.allSettled([...deliveries, ...activeDiscordNotifications]); } - lastDiscordSendByWebhookId.set(webhook.id, now); - await sendDiscordNotification(webhook, log); } diff --git a/packages/backend/src/storage/db.spec.ts b/packages/backend/src/storage/db.spec.ts index b9dcc2f..d4f78c0 100644 --- a/packages/backend/src/storage/db.spec.ts +++ b/packages/backend/src/storage/db.spec.ts @@ -1,10 +1,15 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { Database } from "bun:sqlite"; +import { readMigrationFiles } from "drizzle-orm/migrator"; import type { Instance, Log, UserRecord, Webhook } from "shared"; -import { closeDb, initDb, replaceData, resolveSqliteDbPath } from "./db"; +import { closeDb, getDb, initDb, resolveSqliteDbPath } from "./db"; +import { addInstance } from "./repositories/instances"; +import { addUser } from "./repositories/users"; +import { addWebhook } from "./repositories/webhooks"; +import { logs } from "./schema"; const createUser = (): UserRecord => ({ id: "user-1", @@ -27,7 +32,6 @@ const createInstance = (): Instance => ({ webhookIds: ["webhook-1"], public: false, locked: false, - kind: "static", raw: "HTTP/1.1 200 OK\r\n\r\nok", }); @@ -89,20 +93,19 @@ describe("storage db migrations", () => { { name: "users" }, { name: "webhooks" }, ]); + expect( + readdirSync(dataDir).some((name) => name.includes(".pre-migration-")), + ).toBe(false); }); test("initDb preserves existing data when reopened", () => { const first = initDb({ dataDir, reset: true }); expect(first.kind).toBe("ok"); - replaceData({ - users: [createUser()], - instances: [createInstance()], - logs: [createLog()], - webhooks: [createWebhook()], - instanceModerations: [], - userNotices: [], - }); + addUser(createUser()); + addWebhook(createWebhook()); + addInstance(createInstance()); + getDb().insert(logs).values(createLog()).run(); closeDb(); const reopened = initDb({ dataDir }); @@ -124,14 +127,10 @@ describe("storage db migrations", () => { const first = initDb({ dataDir, reset: true }); expect(first.kind).toBe("ok"); - replaceData({ - users: [createUser()], - instances: [createInstance()], - logs: [createLog()], - webhooks: [createWebhook()], - instanceModerations: [], - userNotices: [], - }); + addUser(createUser()); + addWebhook(createWebhook()); + addInstance(createInstance()); + getDb().insert(logs).values(createLog()).run(); const reset = initDb({ dataDir, reset: true }); @@ -147,4 +146,278 @@ describe("storage db migrations", () => { }, }); }); + + test("grandfathers orphans from databases migrated before the integrity marker", () => { + const sqlite = new Database(resolveSqliteDbPath(dataDir)); + const migrations = readMigrationFiles({ + migrationsFolder: path.join(import.meta.dir, "../../drizzle"), + }); + for (const migration of migrations) { + for (const statement of migration.sql) { + sqlite.exec(statement); + } + } + sqlite.exec(` + CREATE TABLE __drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at NUMERIC + ); + INSERT INTO logs (id, instanceId, type, timestamp, address, raw) + VALUES ('orphan', 'missing', 'http', 1, '127.0.0.1', 'GET /'); + `); + for (const migration of migrations) { + sqlite + .query( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?1, ?2)", + ) + .run(migration.hash, migration.folderMillis); + } + sqlite.close(); + + const first = initDb({ dataDir }); + expect(first.kind).toBe("ok"); + closeDb(); + + const reopened = initDb({ dataDir }); + expect(reopened.kind).toBe("ok"); + if (reopened.kind === "ok") { + expect(reopened.stats.logsLength).toBe(1); + } + }); + + test("rejects an orphan added after a clean integrity check", () => { + const first = initDb({ dataDir, reset: true }); + expect(first.kind).toBe("ok"); + closeDb(); + + const sqlite = new Database(resolveSqliteDbPath(dataDir)); + sqlite.run("PRAGMA foreign_keys = OFF"); + sqlite + .query( + "INSERT INTO logs (id, instanceId, type, timestamp, address, raw) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .run("orphan", "missing", "http", 1, "127.0.0.1", "GET /"); + sqlite.close(); + + const reopened = initDb({ dataDir }); + expect(reopened.kind).toBe("error"); + if (reopened.kind === "error") { + expect(reopened.error.message).toContain("foreign key violations"); + } + }); + + test("keeps rejecting migration violations after a restart", () => { + const dbPath = resolveSqliteDbPath(dataDir); + const sqlite = new Database(dbPath); + const migrations = readMigrationFiles({ + migrationsFolder: path.join(import.meta.dir, "../../drizzle"), + }); + for (const migration of migrations.slice(0, 3)) { + for (const statement of migration.sql) { + sqlite.exec(statement); + } + } + sqlite.exec(` + CREATE TABLE __drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at NUMERIC + ); + INSERT INTO logs (id, instanceId, type, timestamp, address, raw) + VALUES ('orphan', 'missing', 'http', 1, '127.0.0.1', 'GET /'); + `); + for (const migration of migrations.slice(0, 3)) { + sqlite + .query( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?1, ?2)", + ) + .run(migration.hash, migration.folderMillis); + } + sqlite.close(); + + const first = initDb({ dataDir }); + expect(first.kind).toBe("error"); + if (first.kind === "error") { + expect(first.error.message).toContain("foreign key violations"); + } + + const restarted = initDb({ dataDir }); + expect(restarted.kind).toBe("error"); + if (restarted.kind === "error") { + expect(restarted.error.message).toContain("foreign key violations"); + } + }); + + test("static-only migration removes unusable instances and preserves valid associations", () => { + const dbPath = resolveSqliteDbPath(dataDir); + const sqlite = new Database(dbPath); + const migrations = readMigrationFiles({ + migrationsFolder: path.join(import.meta.dir, "../../drizzle"), + }); + for (const migration of migrations.slice(0, 3)) { + for (const statement of migration.sql) { + sqlite.exec(statement); + } + } + sqlite.exec(` + CREATE TABLE __drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at NUMERIC + ); + INSERT INTO webhooks (id, name, url, message, ownerId, createdAt) + VALUES ('webhook-1', 'Alerts', 'https://example.com', NULL, 'owner', 1); + INSERT INTO instances VALUES + ('valid', 'owner', 1, NULL, NULL, 0, 0, 'static', 'HTTP/1.1 200 OK\r\n\r\nvalid', NULL), + ('dynamic', 'owner', 2, NULL, NULL, 0, 0, 'dynamic', NULL, '[]'), + ('null-raw', 'owner', 3, NULL, NULL, 0, 0, 'static', NULL, NULL), + ('legacy-guest', 'guest', 4, NULL, NULL, 0, 0, 'static', 'HTTP/1.1 200 OK\r\n\r\nguest', NULL); + INSERT INTO instanceWebhooks VALUES ('valid', 'webhook-1', 0); + INSERT INTO instanceWebhooks VALUES ('dynamic', 'webhook-1', 0); + INSERT INTO logs (id, instanceId, type, timestamp, address, raw) VALUES + ('log-valid', 'valid', 'http', 1, '127.0.0.1', 'valid'), + ('log-dynamic', 'dynamic', 'http', 2, '127.0.0.1', 'dynamic'), + ('log-null', 'null-raw', 'http', 3, '127.0.0.1', 'null'), + ('log-guest', 'legacy-guest', 'http', 4, '127.0.0.1', 'guest'); + INSERT INTO instanceModerations VALUES + ('valid', 0, 0, 0, '[]', NULL, 0, 0, 0, 0), + ('dynamic', 0, 0, 0, '[]', NULL, 0, 0, 0, 0), + ('legacy-guest', 0, 0, 0, '[]', NULL, 0, 0, 0, 0); + `); + for (const migration of migrations.slice(0, 3)) { + sqlite + .query( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?1, ?2)", + ) + .run(migration.hash, migration.folderMillis); + } + sqlite.close(); + + const upgraded = initDb({ dataDir }); + expect(upgraded.kind).toBe("ok"); + closeDb(); + + const backupName = readdirSync(dataDir).find((name) => + name.includes(".pre-migration-"), + ); + expect(backupName).toBeDefined(); + if (backupName === undefined) { + return; + } + const backup = new Database(path.join(dataDir, backupName)); + const backupColumns = backup + .query("PRAGMA table_info(instances)") + .all() as Array<{ name: string }>; + expect(backupColumns.some(({ name }) => name === "kind")).toBe(true); + expect( + backup.query("SELECT id, kind FROM instances ORDER BY id").all(), + ).toEqual([ + { id: "dynamic", kind: "dynamic" }, + { id: "legacy-guest", kind: "static" }, + { id: "null-raw", kind: "static" }, + { id: "valid", kind: "static" }, + ]); + backup.close(); + + const migrated = new Database(dbPath); + expect(migrated.query("SELECT id, raw FROM instances").all()).toEqual([ + { id: "valid", raw: "HTTP/1.1 200 OK\r\n\r\nvalid" }, + ]); + expect( + migrated.query("SELECT instanceId FROM instanceWebhooks").all(), + ).toEqual([{ instanceId: "valid" }]); + expect(migrated.query("SELECT instanceId FROM logs").all()).toEqual([ + { instanceId: "valid" }, + ]); + expect( + migrated.query("SELECT instanceId FROM instanceModerations").all(), + ).toEqual([{ instanceId: "valid" }]); + + const columns = migrated + .query("PRAGMA table_info(instances)") + .all() as Array<{ name: string; notnull: number }>; + expect(columns.some((column) => column.name === "kind")).toBe(false); + expect(columns.some((column) => column.name === "processorsJson")).toBe( + false, + ); + expect(columns.find((column) => column.name === "raw")?.notnull).toBe(1); + const instanceWebhookIndexes = migrated + .query("PRAGMA index_list(instanceWebhooks)") + .all() as Array<{ name: string }>; + expect( + instanceWebhookIndexes.some( + ({ name }) => name === "instanceWebhooks_by_instance", + ), + ).toBe(false); + expect(migrated.query("PRAGMA foreign_key_check").all()).toEqual([]); + expect( + migrated + .query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'guestInstanceCredentials'", + ) + .all(), + ).toEqual([]); + migrated.close(); + }); + + test("JWT guest migration removes guests whose stored tokens become invalid", () => { + const dbPath = resolveSqliteDbPath(dataDir); + const sqlite = new Database(dbPath); + const migrations = readMigrationFiles({ + migrationsFolder: path.join(import.meta.dir, "../../drizzle"), + }); + for (const migration of migrations.slice(0, 6)) { + for (const statement of migration.sql) { + sqlite.exec(statement); + } + } + sqlite.exec(` + CREATE TABLE __drizzle_migrations ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at NUMERIC + ); + INSERT INTO instances + (id, ownerId, createdAt, expiresAt, label, isPublic, isLocked, raw) + VALUES + ('jwtguest', 'guest', 1, 9999999999999, NULL, 0, 0, 'HTTP/1.1 200 OK\r\n\r\nguest'); + INSERT INTO guestInstanceCredentials (instanceId, tokenHash) + VALUES ('jwtguest', '${"a".repeat(64)}'); + INSERT INTO logs (id, instanceId, type, timestamp, address, raw) + VALUES ('guest-log', 'jwtguest', 'http', 1, '127.0.0.1', 'GET /'); + INSERT INTO instanceModerations + (instanceId, window5mStartMs, requestsInWindow5m, strikeCommittedForWindow, strikeTimestampsJson, discordMutedUntilMs, window15mStartMs, requestsInWindow15m, lastMinuteBucketStartMs, requestsInCurrentMinute) + VALUES ('jwtguest', 0, 0, 0, '[]', NULL, 0, 0, 0, 0); + `); + for (const migration of migrations.slice(0, 6)) { + sqlite + .query( + "INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?1, ?2)", + ) + .run(migration.hash, migration.folderMillis); + } + sqlite.close(); + + expect(initDb({ dataDir }).kind).toBe("ok"); + closeDb(); + + const migrated = new Database(dbPath); + expect( + migrated + .query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'guestInstanceCredentials'", + ) + .all(), + ).toEqual([]); + expect( + migrated.query("SELECT id FROM instances WHERE ownerId = 'guest'").all(), + ).toEqual([]); + expect(migrated.query("SELECT id FROM logs").all()).toEqual([]); + expect( + migrated.query("SELECT instanceId FROM instanceModerations").all(), + ).toEqual([]); + expect(migrated.query("PRAGMA foreign_key_check").all()).toEqual([]); + migrated.close(); + }); }); diff --git a/packages/backend/src/storage/db.ts b/packages/backend/src/storage/db.ts index fc810de..180bf4d 100644 --- a/packages/backend/src/storage/db.ts +++ b/packages/backend/src/storage/db.ts @@ -1,13 +1,11 @@ import { existsSync, mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; import { Database } from "bun:sqlite"; import { count } from "drizzle-orm"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import type { LegacyStorageData } from "./legacyData"; -import { resolveDataDir } from "./legacyData"; +import { readMigrationFiles } from "drizzle-orm/migrator"; import { - instanceWebhooks, - instanceModerations, instances, apiKeys, logs, @@ -16,16 +14,13 @@ import { users, webhooks, } from "./schema"; -import { - toInstanceRow, - toModerationRow, - toUserNoticeRow, - toWebhookRow, -} from "./records"; const SQLITE_DB_FILENAME = "db.sqlite"; const MIGRATIONS_FOLDER = `${import.meta.dir}/../../drizzle`; +const resolveDataDir = (dataDir?: string) => + dataDir ?? Bun.env.DATA_DIR ?? path.join(process.cwd(), "data"); + type InitDbOptions = { dataDir?: string; reset?: boolean; @@ -46,15 +41,151 @@ type DbState = { let dbState: DbState | undefined; -const openDatabase = (dbPath: string) => { +const countAppliedMigrations = (sqlite: Database) => { + const migrationsTable = sqlite + .query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'", + ) + .get(); + if (migrationsTable === null) { + return 0; + } + + return ( + sqlite + .query<{ count: number }, []>( + "SELECT COUNT(*) AS count FROM __drizzle_migrations", + ) + .get()?.count ?? 0 + ); +}; + +const HISTORICAL_VIOLATIONS_STATE_OFFSET = 1_000_000_000; + +type MigrationIntegrityState = { + migrationCount: number; + allowHistoricalViolations: boolean; +}; + +const readMigrationIntegrityState = ( + sqlite: Database, +): MigrationIntegrityState | undefined => { + const row = sqlite + .query<{ user_version: number }, []>("PRAGMA user_version") + .get(); + const encodedState = row?.user_version ?? 0; + if (encodedState === 0) { + return undefined; + } + + const allowHistoricalViolations = + encodedState >= HISTORICAL_VIOLATIONS_STATE_OFFSET; + const encodedCount = allowHistoricalViolations + ? encodedState - HISTORICAL_VIOLATIONS_STATE_OFFSET + : encodedState; + return { + migrationCount: encodedCount - 1, + allowHistoricalViolations, + }; +}; + +const writeMigrationIntegrityState = ( + sqlite: Database, + state: MigrationIntegrityState, +) => { + const encodedState = + state.migrationCount + + 1 + + (state.allowHistoricalViolations ? HISTORICAL_VIOLATIONS_STATE_OFFSET : 0); + sqlite.run(`PRAGMA user_version = ${encodedState}`); +}; + +const createPreMigrationBackup = ( + sqlite: Database, + dbPath: string, + appliedCount: number, + availableCount: number, +) => { + const backupPath = `${dbPath}.pre-migration-${appliedCount}-to-${availableCount}.backup`; + if (!existsSync(backupPath)) { + sqlite.query("VACUUM INTO ?1").run(backupPath); + } +}; + +const openDatabase = (dbPath: string, databaseExisted: boolean) => { const sqlite = new Database(dbPath, { strict: true }); - sqlite.run("PRAGMA foreign_keys = ON"); sqlite.run("PRAGMA journal_mode = WAL"); sqlite.run("PRAGMA synchronous = NORMAL"); sqlite.run("PRAGMA busy_timeout = 5000"); const db = createDrizzleDb(sqlite); - migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); - return { sqlite, db }; + try { + const migrationCountBefore = countAppliedMigrations(sqlite); + const availableMigrationCount = readMigrationFiles({ + migrationsFolder: MIGRATIONS_FOLDER, + }).length; + const hasPendingMigrations = migrationCountBefore < availableMigrationCount; + if (databaseExisted && hasPendingMigrations) { + createPreMigrationBackup( + sqlite, + dbPath, + migrationCountBefore, + availableMigrationCount, + ); + } + + // Mark the pre-migration level before applying anything. If validation + // fails after Drizzle commits its migration records, the old marker makes + // every later startup repeat and fail the integrity check. + let integrityState = readMigrationIntegrityState(sqlite); + if (integrityState === undefined && hasPendingMigrations) { + integrityState = { + migrationCount: migrationCountBefore, + allowHistoricalViolations: false, + }; + writeMigrationIntegrityState(sqlite, integrityState); + } + + // Table-rebuild migrations must run with foreign keys disabled at the + // connection level; changing this pragma inside Drizzle's transaction is + // ignored by SQLite. + sqlite.run("PRAGMA foreign_keys = OFF"); + migrate(db, { migrationsFolder: MIGRATIONS_FOLDER }); + sqlite.run("PRAGMA foreign_keys = ON"); + const migrationCountAfter = countAppliedMigrations(sqlite); + const violations = sqlite.query("PRAGMA foreign_key_check").all(); + if (violations.length > 0) { + if ( + integrityState === undefined && + migrationCountAfter === migrationCountBefore + ) { + // Databases already at the latest migration before this integrity + // marker existed keep their historical behavior. Any future migration + // will require a clean check. + writeMigrationIntegrityState(sqlite, { + migrationCount: migrationCountAfter, + allowHistoricalViolations: true, + }); + return { sqlite, db }; + } + if ( + integrityState?.allowHistoricalViolations === true && + integrityState.migrationCount === migrationCountAfter + ) { + return { sqlite, db }; + } + throw new Error( + `Database migration integrity check found foreign key violations: ${JSON.stringify(violations)}`, + ); + } + writeMigrationIntegrityState(sqlite, { + migrationCount: migrationCountAfter, + allowHistoricalViolations: false, + }); + return { sqlite, db }; + } catch (error) { + sqlite.close(); + throw error; + } }; const getStats = (db: DrizzleDb) => { @@ -87,6 +218,7 @@ const initializeState = (options: InitDbOptions = {}) => { mkdirSync(dataDir, { recursive: true }); const dbPath = resolveSqliteDbPath(dataDir); + const databaseExisted = existsSync(dbPath); if (options.reset === true && existsSync(dbPath)) { rmSync(dbPath, { force: true }); rmSync(`${dbPath}-shm`, { force: true }); @@ -102,7 +234,10 @@ const initializeState = (options: InitDbOptions = {}) => { existingState.sqlite.close(); } - const { sqlite, db } = openDatabase(dbPath); + const { sqlite, db } = openDatabase( + dbPath, + databaseExisted && options.reset !== true, + ); const nextState = { sqlite, db, dataDir, dbPath }; dbState = nextState; return nextState; @@ -151,58 +286,3 @@ export const getDb = () => { return initialized.db; }; - -export const replaceData = (data: LegacyStorageData) => { - const db = getDb(); - - db.transaction( - (tx) => { - tx.delete(logs).run(); - tx.delete(instanceWebhooks).run(); - tx.delete(instanceModerations).run(); - tx.delete(userNotices).run(); - tx.delete(apiKeys).run(); - tx.delete(webhooks).run(); - tx.delete(instances).run(); - tx.delete(users).run(); - - for (const user of data.users) { - tx.insert(users).values(user).run(); - } - - for (const webhook of data.webhooks) { - tx.insert(webhooks).values(toWebhookRow(webhook)).run(); - } - - for (const instance of data.instances) { - tx.insert(instances).values(toInstanceRow(instance)).run(); - instance.webhookIds.forEach((webhookId, position) => { - tx.insert(instanceWebhooks) - .values({ - instanceId: instance.id, - webhookId, - position, - }) - .run(); - }); - } - - for (const log of data.logs) { - tx.insert(logs).values(log).run(); - } - - for (const moderation of data.instanceModerations) { - tx.insert(instanceModerations) - .values(toModerationRow(moderation)) - .run(); - } - - for (const notice of data.userNotices) { - tx.insert(userNotices).values(toUserNoticeRow(notice)).run(); - } - }, - { - behavior: "immediate", - }, - ); -}; diff --git a/packages/backend/src/storage/importLegacyDb.spec.ts b/packages/backend/src/storage/importLegacyDb.spec.ts deleted file mode 100644 index dfcee03..0000000 --- a/packages/backend/src/storage/importLegacyDb.spec.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { gzipSync } from "node:zlib"; -import type { - Instance, - InstanceModeration, - Log, - UserNotice, - UserRecord, - Webhook, -} from "shared"; -import { closeDb, initDb, replaceData } from "./db"; -import { maybeAutoImportLegacyDb, runImportLegacyDb } from "./importLegacyDb"; - -const createUser = (): UserRecord => ({ - id: "user-1", - googleId: "google-1", - createdAt: 1, -}); - -const createWebhook = (): Webhook => ({ - id: "webhook-1", - name: "Alerts", - url: "https://discord.com/api/webhooks/123/token", - ownerId: "user-1", - createdAt: 2, -}); - -const createInstance = (): Instance => ({ - id: "inst-1", - ownerId: "user-1", - createdAt: 3, - webhookIds: ["webhook-1"], - public: false, - locked: false, - kind: "static", - raw: "HTTP/1.1 200 OK\r\n\r\nok", -}); - -const createLog = (): Log => ({ - id: "log-1", - instanceId: "inst-1", - type: "http", - timestamp: 4, - address: "127.0.0.1", - raw: "GET / HTTP/1.1", -}); - -const createNotice = (): UserNotice => ({ - id: "notice-1", - userId: "user-1", - kind: "instance_removed_noise", - instanceId: "inst-1", - createdAt: 5, -}); - -const createLegacyData = (): { - users: UserRecord[]; - instances: Instance[]; - logs: Log[]; - webhooks: Webhook[]; - instanceModerations: InstanceModeration[]; - userNotices: UserNotice[]; -} => ({ - users: [createUser()], - instances: [createInstance()], - logs: [createLog()], - webhooks: [createWebhook()], - instanceModerations: [ - { - instanceId: "inst-1", - window5mStartMs: 10, - requestsInWindow5m: 1, - strikeCommittedForWindow: false, - strikeTimestampsMs: [], - window15mStartMs: 10, - requestsInWindow15m: 1, - lastMinuteBucketStartMs: 10, - requestsInCurrentMinute: 1, - }, - ], - userNotices: [createNotice()], -}); - -const writeLegacyJson = async ( - dataDir: string, - data: ReturnType = createLegacyData(), -) => { - await Bun.write(`${dataDir}/db.json`, JSON.stringify(data)); -}; - -const writeLegacyGzip = async ( - dataDir: string, - data: ReturnType = createLegacyData(), -) => { - await Bun.write( - `${dataDir}/db.json.gz`, - gzipSync(JSON.stringify(data)).toString("base64"), - ); -}; - -let dataDir = ""; - -describe("runImportLegacyDb", () => { - beforeEach(() => { - dataDir = mkdtempSync(path.join(tmpdir(), "httpworkbench-import-")); - }); - - afterEach(() => { - closeDb(); - rmSync(dataDir, { recursive: true, force: true }); - }); - - test("imports a plain legacy db.json into sqlite", async () => { - await writeLegacyJson(dataDir); - - const result = await runImportLegacyDb({ dataDir, resetTarget: true }); - - expect(result).toEqual({ - sqlitePath: `${dataDir}/db.sqlite`, - users: 1, - instances: 1, - logs: 1, - webhooks: 1, - instanceModerations: 1, - userNotices: 1, - }); - }); - - test("imports a gzipped legacy db.json.gz into sqlite", async () => { - await writeLegacyGzip(dataDir); - - const result = await runImportLegacyDb({ dataDir, resetTarget: true }); - - expect(result.sqlitePath).toBe(`${dataDir}/db.sqlite`); - expect(result.logs).toBe(1); - expect(result.instances).toBe(1); - }); - - test("imports expired instances without filtering them during verification", async () => { - await writeLegacyJson(dataDir, { - ...createLegacyData(), - instances: [ - { - ...createInstance(), - id: "inst-expired", - expiresAt: 1, - webhookIds: [], - }, - ], - logs: [ - { - ...createLog(), - instanceId: "inst-expired", - }, - ], - instanceModerations: [ - { - instanceId: "inst-expired", - window5mStartMs: 10, - requestsInWindow5m: 1, - strikeCommittedForWindow: false, - strikeTimestampsMs: [], - window15mStartMs: 10, - requestsInWindow15m: 1, - lastMinuteBucketStartMs: 10, - requestsInCurrentMinute: 1, - }, - ], - userNotices: [ - { - ...createNotice(), - instanceId: "inst-expired", - }, - ], - }); - - await expect( - runImportLegacyDb({ dataDir, resetTarget: true }), - ).resolves.toEqual({ - sqlitePath: `${dataDir}/db.sqlite`, - users: 1, - instances: 1, - logs: 1, - webhooks: 1, - instanceModerations: 1, - userNotices: 1, - }); - }); - - test("drops invalid and duplicate legacy references", async () => { - await writeLegacyJson(dataDir, { - ...createLegacyData(), - instances: [ - { - ...createInstance(), - webhookIds: ["webhook-1", "webhook-1", "webhook-missing"], - }, - ], - logs: [ - createLog(), - { - ...createLog(), - id: "log-orphan", - instanceId: "inst-missing", - }, - ], - instanceModerations: [ - { - instanceId: "inst-1", - window5mStartMs: 10, - requestsInWindow5m: 1, - strikeCommittedForWindow: false, - strikeTimestampsMs: [], - window15mStartMs: 10, - requestsInWindow15m: 1, - lastMinuteBucketStartMs: 10, - requestsInCurrentMinute: 1, - }, - { - instanceId: "inst-1", - window5mStartMs: 20, - requestsInWindow5m: 2, - strikeCommittedForWindow: true, - strikeTimestampsMs: Array.of(20), - window15mStartMs: 20, - requestsInWindow15m: 2, - lastMinuteBucketStartMs: 20, - requestsInCurrentMinute: 2, - }, - { - instanceId: "inst-missing", - window5mStartMs: 10, - requestsInWindow5m: 1, - strikeCommittedForWindow: false, - strikeTimestampsMs: [], - window15mStartMs: 10, - requestsInWindow15m: 1, - lastMinuteBucketStartMs: 10, - requestsInCurrentMinute: 1, - }, - ], - }); - - await expect( - runImportLegacyDb({ dataDir, resetTarget: true }), - ).resolves.toEqual({ - sqlitePath: `${dataDir}/db.sqlite`, - users: 1, - instances: 1, - logs: 1, - webhooks: 1, - instanceModerations: 1, - userNotices: 1, - }); - }); - - test("auto-imports legacy data when sqlite is still empty", async () => { - await writeLegacyJson(dataDir); - - await expect(maybeAutoImportLegacyDb({ dataDir })).resolves.toEqual({ - kind: "imported", - result: { - sqlitePath: `${dataDir}/db.sqlite`, - users: 1, - instances: 1, - logs: 1, - webhooks: 1, - instanceModerations: 1, - userNotices: 1, - }, - }); - - expect(initDb({ dataDir })).toEqual({ - kind: "ok", - stats: { - instancesLength: 1, - logsLength: 1, - userNoticesLength: 1, - usersLength: 1, - webhooksLength: 1, - apiKeysLength: 0, - }, - }); - }); - - test("skips auto-import when sqlite already has only notices", async () => { - await writeLegacyJson(dataDir); - - const initResult = initDb({ dataDir, reset: true }); - expect(initResult.kind).toBe("ok"); - replaceData({ - users: [], - instances: [], - logs: [], - webhooks: [], - instanceModerations: [], - userNotices: [createNotice()], - }); - - await expect(maybeAutoImportLegacyDb({ dataDir })).resolves.toEqual({ - kind: "skipped", - reason: "sqlite-has-data", - }); - - expect(initDb({ dataDir })).toEqual({ - kind: "ok", - stats: { - instancesLength: 0, - logsLength: 0, - userNoticesLength: 1, - usersLength: 0, - webhooksLength: 0, - apiKeysLength: 0, - }, - }); - }); - - test("refuses to overwrite an existing sqlite database with only notices without reset", async () => { - await writeLegacyJson(dataDir); - - const initResult = initDb({ dataDir, reset: true }); - expect(initResult.kind).toBe("ok"); - replaceData({ - users: [], - instances: [], - logs: [], - webhooks: [], - instanceModerations: [], - userNotices: [createNotice()], - }); - - await expect( - runImportLegacyDb({ dataDir, resetTarget: false }), - ).rejects.toThrow( - `Target SQLite database already contains data at ${dataDir}/db.sqlite. Set RESET_SQLITE=1 to overwrite it.`, - ); - }); -}); diff --git a/packages/backend/src/storage/importLegacyDb.ts b/packages/backend/src/storage/importLegacyDb.ts deleted file mode 100644 index a3f55d3..0000000 --- a/packages/backend/src/storage/importLegacyDb.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; -import { asc } from "drizzle-orm"; -import { closeDb, getDb, initDb, replaceData, resolveSqliteDbPath } from "./db"; -import { - type LegacyStorageData, - readLegacyStorageData, - resolveDataDir, - resolveLegacyDbPath, -} from "./legacyData"; -import { - toDomainInstance, - toDomainModeration, - toDomainUserNotice, - toDomainWebhook, -} from "./records"; -import { - instanceModerations, - instanceWebhooks, - instances, - logs, - userNotices, - users, - webhooks, -} from "./schema"; - -const normalizeForComparison = (value: T) => { - return JSON.parse(JSON.stringify(value)) as T; -}; - -const compareStrings = (left: string, right: string) => { - return left.localeCompare(right); -}; - -const sortById = (values: T[]) => { - return [...values].sort((left, right) => compareStrings(left.id, right.id)); -}; - -const sortByInstanceId = (values: T[]) => { - return [...values].sort((left, right) => - compareStrings(left.instanceId, right.instanceId), - ); -}; - -const hasStoredData = ({ - instancesLength, - logsLength, - userNoticesLength, - usersLength, - webhooksLength, -}: { - instancesLength: number; - logsLength: number; - userNoticesLength: number; - usersLength: number; - webhooksLength: number; -}) => { - return ( - instancesLength > 0 || - logsLength > 0 || - userNoticesLength > 0 || - usersLength > 0 || - webhooksLength > 0 - ); -}; - -const assertImportedSectionMatches = ( - label: string, - actual: T, - expected: T, -) => { - if ( - !isDeepStrictEqual( - normalizeForComparison(actual), - normalizeForComparison(expected), - ) - ) { - throw new Error(`Imported ${label} mismatch`); - } -}; - -const getWebhookIdsByInstanceId = () => { - const rows = getDb() - .select({ - instanceId: instanceWebhooks.instanceId, - webhookId: instanceWebhooks.webhookId, - }) - .from(instanceWebhooks) - .orderBy(asc(instanceWebhooks.instanceId), asc(instanceWebhooks.position)) - .all(); - - const webhookIdsByInstanceId = new Map(); - for (const row of rows) { - const existing = webhookIdsByInstanceId.get(row.instanceId); - if (existing === undefined) { - webhookIdsByInstanceId.set(row.instanceId, [row.webhookId]); - continue; - } - existing.push(row.webhookId); - } - - return webhookIdsByInstanceId; -}; - -const readImportedInstances = () => { - const webhookIdsByInstanceId = getWebhookIdsByInstanceId(); - return getDb() - .select() - .from(instances) - .all() - .map((row) => - toDomainInstance(row, webhookIdsByInstanceId.get(row.id) ?? []), - ); -}; - -const readImportedLogs = () => { - return getDb() - .select({ - id: logs.id, - instanceId: logs.instanceId, - type: logs.type, - timestamp: logs.timestamp, - address: logs.address, - raw: logs.raw, - }) - .from(logs) - .all(); -}; - -const readImportedModerations = () => { - return getDb() - .select() - .from(instanceModerations) - .all() - .map((row) => toDomainModeration(row)); -}; - -const readImportedUserNotices = () => { - return getDb() - .select() - .from(userNotices) - .all() - .map((row) => toDomainUserNotice(row)); -}; - -type DroppedLegacyRecords = { - duplicateInstanceWebhookReferences: number; - duplicateModerations: number; - missingWebhookReferences: number; - orphanLogs: number; - orphanModerations: number; -}; - -const hasDroppedLegacyRecords = (dropped: DroppedLegacyRecords) => { - return ( - dropped.duplicateInstanceWebhookReferences > 0 || - dropped.duplicateModerations > 0 || - dropped.missingWebhookReferences > 0 || - dropped.orphanLogs > 0 || - dropped.orphanModerations > 0 - ); -}; - -const dedupeByKey = (values: T[], getKey: (value: T) => string) => { - const seen = new Set(); - const deduped: T[] = []; - - for (let index = values.length - 1; index >= 0; index -= 1) { - const value = values[index]; - if (value === undefined) { - continue; - } - - const key = getKey(value); - if (seen.has(key)) { - continue; - } - - seen.add(key); - deduped.unshift(value); - } - - return deduped; -}; - -const sanitizeLegacyStorageData = ( - data: LegacyStorageData, -): { - data: LegacyStorageData; - dropped: DroppedLegacyRecords; -} => { - const existingWebhookIds = new Set( - data.webhooks.map((webhook) => webhook.id), - ); - const existingInstanceIds = new Set( - data.instances.map((instance) => instance.id), - ); - - let missingWebhookReferences = 0; - let duplicateInstanceWebhookReferences = 0; - const instances = data.instances.map((instance) => { - const seenWebhookIds = new Set(); - const webhookIds = instance.webhookIds.filter((webhookId) => { - if (!existingWebhookIds.has(webhookId)) { - missingWebhookReferences += 1; - return false; - } - - if (seenWebhookIds.has(webhookId)) { - duplicateInstanceWebhookReferences += 1; - return false; - } - - seenWebhookIds.add(webhookId); - return true; - }); - - if (webhookIds.length === instance.webhookIds.length) { - return instance; - } - - return { - ...instance, - webhookIds, - }; - }); - - const logs = data.logs.filter((log) => - existingInstanceIds.has(log.instanceId), - ); - const validModerations = data.instanceModerations.filter((moderation) => - existingInstanceIds.has(moderation.instanceId), - ); - const instanceModerations = dedupeByKey( - validModerations, - (moderation) => moderation.instanceId, - ); - - return { - data: { - ...data, - instances, - logs, - instanceModerations, - }, - dropped: { - duplicateInstanceWebhookReferences, - duplicateModerations: - validModerations.length - instanceModerations.length, - missingWebhookReferences, - orphanLogs: data.logs.length - logs.length, - orphanModerations: - data.instanceModerations.length - validModerations.length, - }, - }; -}; - -export const verifyImportedData = ( - data: LegacyStorageData, - dataDir?: string, -) => { - const statsResult = initDb({ dataDir }); - if (statsResult.kind === "error") { - throw statsResult.error; - } - - const { stats } = statsResult; - if (stats.usersLength !== data.users.length) { - throw new Error("Imported users count mismatch"); - } - if (stats.instancesLength !== data.instances.length) { - throw new Error("Imported instances count mismatch"); - } - if (stats.logsLength !== data.logs.length) { - throw new Error("Imported logs count mismatch"); - } - if (stats.webhooksLength !== data.webhooks.length) { - throw new Error("Imported webhooks count mismatch"); - } - if (stats.userNoticesLength !== data.userNotices.length) { - throw new Error("Imported user notices count mismatch"); - } - - assertImportedSectionMatches( - "users", - sortById(getDb().select().from(users).all()), - sortById(data.users), - ); - assertImportedSectionMatches( - "instances", - sortById(readImportedInstances()), - sortById(data.instances), - ); - assertImportedSectionMatches( - "logs", - sortById(readImportedLogs()), - sortById(data.logs), - ); - assertImportedSectionMatches( - "webhooks", - sortById( - getDb() - .select() - .from(webhooks) - .all() - .map((row) => toDomainWebhook(row)), - ), - sortById(data.webhooks), - ); - assertImportedSectionMatches( - "instance moderations", - sortByInstanceId(readImportedModerations()), - sortByInstanceId(data.instanceModerations), - ); - assertImportedSectionMatches( - "user notices", - sortById(readImportedUserNotices()), - sortById(data.userNotices), - ); -}; - -export const runImportLegacyDb = async ({ - dataDir = resolveDataDir(), - resetTarget = Bun.env.RESET_SQLITE === "1", -}: { - dataDir?: string; - resetTarget?: boolean; -} = {}) => { - const sqlitePath = resolveSqliteDbPath(dataDir); - - if (!resetTarget) { - const initResult = initDb({ dataDir }); - if (initResult.kind === "error") { - throw initResult.error; - } - - if (hasStoredData(initResult.stats)) { - throw new Error( - `Target SQLite database already contains data at ${sqlitePath}. Set RESET_SQLITE=1 to overwrite it.`, - ); - } - } - - const legacyData = await readLegacyStorageData(dataDir); - const sanitized = sanitizeLegacyStorageData(legacyData); - const initResult = initDb({ dataDir, reset: resetTarget }); - if (initResult.kind === "error") { - throw initResult.error; - } - - replaceData(sanitized.data); - verifyImportedData(sanitized.data, dataDir); - - if (hasDroppedLegacyRecords(sanitized.dropped)) { - console.warn( - "Skipped invalid legacy references during import", - sanitized.dropped, - ); - } - - return { - sqlitePath, - users: sanitized.data.users.length, - instances: sanitized.data.instances.length, - logs: sanitized.data.logs.length, - webhooks: sanitized.data.webhooks.length, - instanceModerations: sanitized.data.instanceModerations.length, - userNotices: sanitized.data.userNotices.length, - }; -}; - -export const maybeAutoImportLegacyDb = async ({ - dataDir = resolveDataDir(), -}: { - dataDir?: string; -} = {}) => { - if (resolveLegacyDbPath(dataDir) === undefined) { - return { kind: "skipped" as const, reason: "no-legacy-db" as const }; - } - - const initResult = initDb({ dataDir }); - if (initResult.kind === "error") { - throw initResult.error; - } - - if (hasStoredData(initResult.stats)) { - return { kind: "skipped" as const, reason: "sqlite-has-data" as const }; - } - - return { - kind: "imported" as const, - result: await runImportLegacyDb({ dataDir, resetTarget: false }), - }; -}; - -if (import.meta.main) { - runImportLegacyDb() - .then((result) => { - console.log(JSON.stringify(result, null, 2)); - closeDb(); - }) - .catch((error) => { - closeDb(); - console.error(error); - process.exit(1); - }); -} diff --git a/packages/backend/src/storage/index.ts b/packages/backend/src/storage/index.ts index 5e32482..8ad8118 100644 --- a/packages/backend/src/storage/index.ts +++ b/packages/backend/src/storage/index.ts @@ -1,3 +1,2 @@ export * from "./db"; -export * from "./importLegacyDb"; export * from "./repositories"; diff --git a/packages/backend/src/storage/legacyData.ts b/packages/backend/src/storage/legacyData.ts deleted file mode 100644 index 6d024ea..0000000 --- a/packages/backend/src/storage/legacyData.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { gunzipSync } from "node:zlib"; -import { - InstanceModerationSchema, - InstanceSchema, - LogSchema, - UserNoticeSchema, - UserRecordSchema, - WebhookSchema, -} from "shared"; -import z from "zod"; - -const LEGACY_DB_JSON_FILENAME = "db.json"; -const LEGACY_DB_GZIP_FILENAME = "db.json.gz"; - -const LegacyStorageDataSchema = z.object({ - users: z.array(UserRecordSchema), - instances: z.array(InstanceSchema), - logs: z.array(LogSchema), - webhooks: z.array(WebhookSchema), - instanceModerations: z.array(InstanceModerationSchema).default([]), - userNotices: z.array(UserNoticeSchema).default([]), -}); - -export type LegacyStorageData = z.infer; - -export const resolveDataDir = (dataDir?: string) => { - return dataDir ?? Bun.env.DATA_DIR ?? path.join(process.cwd(), "data"); -}; - -export const resolveLegacyDbPath = (dataDir?: string) => { - const resolvedDataDir = resolveDataDir(dataDir); - const gzipPath = path.join(resolvedDataDir, LEGACY_DB_GZIP_FILENAME); - if (existsSync(gzipPath)) { - return gzipPath; - } - - const jsonPath = path.join(resolvedDataDir, LEGACY_DB_JSON_FILENAME); - if (existsSync(jsonPath)) { - return jsonPath; - } - - return undefined; -}; - -export const readLegacyStorageData = async ( - dataDir?: string, -): Promise => { - const legacyDbPath = resolveLegacyDbPath(dataDir); - if (legacyDbPath === undefined) { - throw new Error("Legacy db.json or db.json.gz file not found"); - } - - const encoded = await Bun.file(legacyDbPath).text(); - const decoded = legacyDbPath.endsWith(".gz") - ? gunzipSync(Buffer.from(encoded, "base64")).toString() - : encoded; - const parsed = LegacyStorageDataSchema.safeParse(JSON.parse(decoded)); - if (!parsed.success) { - throw new Error(`Invalid legacy database format: ${parsed.error.message}`); - } - - return parsed.data; -}; diff --git a/packages/backend/src/storage/records.ts b/packages/backend/src/storage/records.ts index bcd051a..a432ad0 100644 --- a/packages/backend/src/storage/records.ts +++ b/packages/backend/src/storage/records.ts @@ -1,10 +1,12 @@ import { InstanceModerationSchema, InstanceSchema, + InstanceSummarySchema, UserNoticeSchema, WebhookSchema, type Instance, type InstanceModeration, + type InstanceSummary, type UserNotice, type Webhook, } from "shared"; @@ -27,23 +29,8 @@ type WebhookRow = typeof webhooks.$inferSelect; export const toDomainInstance = ( row: InstanceRow, webhookIds: string[], -): Instance => { - if (row.kind === "static") { - return InstanceSchema.parse({ - id: row.id, - ownerId: row.ownerId, - createdAt: row.createdAt, - expiresAt: row.expiresAt ?? undefined, - label: row.label ?? undefined, - webhookIds, - public: row.isPublic, - locked: row.isLocked, - kind: "static", - raw: row.raw ?? "", - }); - } - - return InstanceSchema.parse({ +): Instance => + InstanceSchema.parse({ id: row.id, ownerId: row.ownerId, createdAt: row.createdAt, @@ -52,10 +39,22 @@ export const toDomainInstance = ( webhookIds, public: row.isPublic, locked: row.isLocked, - kind: "dynamic", - processors: row.processorsJson ?? [], + raw: row.raw, + }); + +export const toDomainInstanceSummary = ( + row: Omit & { logCount: number }, +): InstanceSummary => + InstanceSummarySchema.parse({ + id: row.id, + ownerId: row.ownerId, + createdAt: row.createdAt, + expiresAt: row.expiresAt ?? undefined, + label: row.label ?? undefined, + public: row.isPublic, + locked: row.isLocked, + logCount: row.logCount, }); -}; export const toInstanceRow = (instance: Instance): InstanceInsertRow => { const parsed = InstanceSchema.parse(instance); @@ -67,9 +66,7 @@ export const toInstanceRow = (instance: Instance): InstanceInsertRow => { label: parsed.label ?? null, isPublic: parsed.public, isLocked: parsed.locked, - kind: parsed.kind, - raw: parsed.kind === "static" ? parsed.raw : null, - processorsJson: parsed.kind === "dynamic" ? parsed.processors : null, + raw: parsed.raw, }; }; diff --git a/packages/backend/src/storage/repositories/apiKeys.ts b/packages/backend/src/storage/repositories/apiKeys.ts index b9694ca..f92d0f7 100644 --- a/packages/backend/src/storage/repositories/apiKeys.ts +++ b/packages/backend/src/storage/repositories/apiKeys.ts @@ -72,6 +72,20 @@ export function getActiveApiKeyByPrefix( return toApiKeySecretRecord(row); } +export function getActiveApiKeyById( + id: string, + now: number, +): ApiKey | undefined { + const row = getDb().select().from(apiKeys).where(eq(apiKeys.id, id)).get(); + if (row === undefined) { + return undefined; + } + if (row.expiresAt !== null && row.expiresAt <= now) { + return undefined; + } + return toApiKey(row); +} + export function getApiKeysByOwner(userId: string): ApiKey[] { return getDb() .select() diff --git a/packages/backend/src/storage/repositories/instances.ts b/packages/backend/src/storage/repositories/instances.ts index e401253..7bb7883 100644 --- a/packages/backend/src/storage/repositories/instances.ts +++ b/packages/backend/src/storage/repositories/instances.ts @@ -1,56 +1,30 @@ -import type { Instance } from "shared"; +import { GUEST_OWNER_ID, type Instance, type InstanceSummary } from "shared"; import { InstanceSchema } from "shared"; -import { and, asc, eq, inArray, isNotNull, lte } from "drizzle-orm"; +import { + and, + asc, + count, + eq, + gt, + inArray, + isNotNull, + isNull, + lte, + or, +} from "drizzle-orm"; import { getDb } from "../db"; -import { toDomainInstance, toInstanceRow } from "../records"; -import { instances, instanceWebhooks } from "../schema"; +import { + toDomainInstance, + toDomainInstanceSummary, + toInstanceRow, +} from "../records"; +import { instances, instanceWebhooks, logs } from "../schema"; -const isInstanceExpired = (instance: Instance, now: number) => { - return instance.expiresAt !== undefined && instance.expiresAt <= now; -}; +const activeInstanceCondition = (now: number) => + or(isNull(instances.expiresAt), gt(instances.expiresAt, now)); type WebhookMutationStore = Pick, "delete" | "insert">; -const getWebhookIdsByInstanceId = (instanceIds: string[]) => { - if (instanceIds.length === 0) { - return new Map(); - } - - const rows = getDb() - .select({ - instanceId: instanceWebhooks.instanceId, - webhookId: instanceWebhooks.webhookId, - }) - .from(instanceWebhooks) - .where(inArray(instanceWebhooks.instanceId, instanceIds)) - .orderBy(asc(instanceWebhooks.instanceId), asc(instanceWebhooks.position)) - .all(); - - const grouped = new Map(); - for (const row of rows) { - const current = grouped.get(row.instanceId); - if (current === undefined) { - grouped.set(row.instanceId, [row.webhookId]); - continue; - } - current.push(row.webhookId); - } - - return grouped; -}; - -const hydrateInstances = ( - rows: Array, -): Instance[] => { - const webhookIdsByInstanceId = getWebhookIdsByInstanceId( - rows.map((row) => row.id), - ); - - return rows.map((row) => - toDomainInstance(row, webhookIdsByInstanceId.get(row.id) ?? []), - ); -}; - const getActiveInstanceById = ( id: string, now: number, @@ -58,13 +32,11 @@ const getActiveInstanceById = ( const row = getDb() .select() .from(instances) - .where(eq(instances.id, id)) + .where(and(eq(instances.id, id), activeInstanceCondition(now))) .get(); - const instance = row === undefined ? undefined : hydrateInstances([row])[0]; - if (instance === undefined) { - return undefined; - } - return isInstanceExpired(instance, now) ? undefined : instance; + return row === undefined + ? undefined + : toDomainInstance(row, getWebhookIdsForInstance(id)); }; const replaceInstanceWebhooks = ( @@ -96,22 +68,174 @@ export function addInstance(instance: Instance): Instance { return parsed; } -export function getInstancesByOwner(ownerId: string): Instance[] { - const now = Date.now(); - return hydrateInstances( +export function addGuestInstance(instance: Instance): Instance { + const parsed = InstanceSchema.parse(instance); + if (parsed.ownerId !== GUEST_OWNER_ID || parsed.webhookIds.length !== 0) { + throw new Error("Invalid guest instance"); + } + getDb().insert(instances).values(toInstanceRow(parsed)).run(); + return parsed; +} + +export function getInstanceSummariesByOwner( + ownerId: string, +): InstanceSummary[] { + const rows = getDb() + .select({ + id: instances.id, + ownerId: instances.ownerId, + createdAt: instances.createdAt, + expiresAt: instances.expiresAt, + label: instances.label, + isPublic: instances.isPublic, + isLocked: instances.isLocked, + logCount: count(logs.id), + }) + .from(instances) + .leftJoin(logs, eq(logs.instanceId, instances.id)) + .where( + and(eq(instances.ownerId, ownerId), activeInstanceCondition(Date.now())), + ) + .groupBy(instances.id) + .orderBy(asc(instances.createdAt), asc(instances.id)) + .all(); + + return rows.map(toDomainInstanceSummary); +} + +export function getInstanceSummariesByIds( + ids: string[], + ownerId: string, +): InstanceSummary[] { + if (ids.length === 0) { + return []; + } + + const rows = getDb() + .select({ + id: instances.id, + ownerId: instances.ownerId, + createdAt: instances.createdAt, + expiresAt: instances.expiresAt, + label: instances.label, + isPublic: instances.isPublic, + isLocked: instances.isLocked, + logCount: count(logs.id), + }) + .from(instances) + .leftJoin(logs, eq(logs.instanceId, instances.id)) + .where( + and( + inArray(instances.id, ids), + eq(instances.ownerId, ownerId), + activeInstanceCondition(Date.now()), + ), + ) + .groupBy(instances.id) + .all(); + + const summariesById = new Map( + rows.map((row) => { + const summary = toDomainInstanceSummary(row); + return [summary.id, summary] as const; + }), + ); + return ids.flatMap((id) => { + const summary = summariesById.get(id); + return summary === undefined ? [] : [summary]; + }); +} + +export function countActiveInstancesByOwner(ownerId: string): number { + return ( getDb() - .select() + .select({ count: count() }) .from(instances) - .where(eq(instances.ownerId, ownerId)) - .orderBy(asc(instances.createdAt), asc(instances.id)) - .all(), - ).filter((instance) => !isInstanceExpired(instance, now)); + .where( + and( + eq(instances.ownerId, ownerId), + activeInstanceCondition(Date.now()), + ), + ) + .get()?.count ?? 0 + ); } export function getInstanceById(id: string): Instance | undefined { return getActiveInstanceById(id, Date.now()); } +export type ServableInstance = Pick; + +export type InstanceAccessMetadata = Pick< + Instance, + "id" | "ownerId" | "createdAt" | "public" +>; + +export function getServableInstanceById( + id: string, +): ServableInstance | undefined { + return getDb() + .select({ id: instances.id, raw: instances.raw }) + .from(instances) + .where(and(eq(instances.id, id), activeInstanceCondition(Date.now()))) + .get(); +} + +export function hasActiveInstance(id: string): boolean { + return ( + getDb() + .select({ id: instances.id }) + .from(instances) + .where(and(eq(instances.id, id), activeInstanceCondition(Date.now()))) + .get() !== undefined + ); +} + +export function getInstanceAccessMetadata( + id: string, +): InstanceAccessMetadata | undefined { + return getDb() + .select({ + id: instances.id, + ownerId: instances.ownerId, + createdAt: instances.createdAt, + public: instances.isPublic, + }) + .from(instances) + .where(and(eq(instances.id, id), activeInstanceCondition(Date.now()))) + .get(); +} + +export function getWebhookIdsForInstance(instanceId: string): string[] { + return getDb() + .select({ webhookId: instanceWebhooks.webhookId }) + .from(instanceWebhooks) + .where(eq(instanceWebhooks.instanceId, instanceId)) + .orderBy(asc(instanceWebhooks.position)) + .all() + .map(({ webhookId }) => webhookId); +} + +export function getActiveWebhookIdsForInstance( + instanceId: string, + now = Date.now(), +): string[] { + return getDb() + .select({ webhookId: instanceWebhooks.webhookId }) + .from(instanceWebhooks) + .innerJoin(instances, eq(instances.id, instanceWebhooks.instanceId)) + .where( + and( + eq(instanceWebhooks.instanceId, instanceId), + activeInstanceCondition(now), + ), + ) + .orderBy(asc(instanceWebhooks.position)) + .all() + .map(({ webhookId }) => webhookId); +} + export function updateInstance( id: string, updater: (instance: Instance) => Instance, diff --git a/packages/backend/src/storage/repositories/logs.spec.ts b/packages/backend/src/storage/repositories/logs.spec.ts index 7243f6c..979aa5d 100644 --- a/packages/backend/src/storage/repositories/logs.spec.ts +++ b/packages/backend/src/storage/repositories/logs.spec.ts @@ -4,30 +4,43 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { count, eq } from "drizzle-orm"; import type { Instance, Log, Webhook } from "shared"; -import { closeDb, getDb, initDb, replaceData } from "../db"; -import { toDomainModeration } from "../records"; -import { instanceModerations, instances, userNotices } from "../schema"; +import { closeDb, getDb, initDb } from "../db"; +import { toDomainModeration, toModerationRow, toWebhookRow } from "../records"; +import { + instanceModerations, + instances, + logs as logsTable, + userNotices, + webhooks as webhooksTable, +} from "../schema"; import { addLog, clearLogsForInstance, flushPendingWebhookNotifications, getLogsForInstance, getLogsForInstancePage, + getRecentLogsForInstance, + getRecentLogsForInstancePage, } from "./logs"; -import { deleteInstance, removeExpiredInstances } from "./instances"; - -type StaticInstance = Extract; - -const createInstance = ( - overrides?: Partial, -): StaticInstance => ({ +import { + addInstance, + countActiveInstancesByOwner, + deleteInstance, + getInstanceAccessMetadata, + getInstanceSummariesByOwner, + getServableInstanceById, + getWebhookIdsForInstance, + hasActiveInstance, + removeExpiredInstances, +} from "./instances"; + +const createInstance = (overrides?: Partial): Instance => ({ id: "inst-1", ownerId: "user-1", createdAt: 1, webhookIds: [], public: false, locked: false, - kind: "static", raw: "HTTP/1.1 200 OK\r\n\r\nok", ...overrides, }); @@ -72,14 +85,21 @@ const seedStorage = ( }>; }>, ) => { - replaceData({ - users: [], - instances: overrides?.instances ?? [], - logs: overrides?.logs ?? [], - webhooks: overrides?.webhooks ?? [], - instanceModerations: overrides?.instanceModerations ?? [], - userNotices: [], - }); + for (const webhook of overrides?.webhooks ?? []) { + getDb().insert(webhooksTable).values(toWebhookRow(webhook)).run(); + } + for (const instance of overrides?.instances ?? []) { + addInstance(instance); + } + for (const log of overrides?.logs ?? []) { + getDb().insert(logsTable).values(log).run(); + } + for (const moderation of overrides?.instanceModerations ?? []) { + getDb() + .insert(instanceModerations) + .values(toModerationRow(moderation)) + .run(); + } }; const getModerationForInstance = (instanceId: string) => { @@ -186,9 +206,31 @@ describe("addLog", () => { expect(getLogsForInstance("inst-1")).toEqual([log]); await Bun.sleep(10); + expect(fetchMock).not.toHaveBeenCalled(); + + await flushPendingWebhookNotifications(); expect(fetchMock).toHaveBeenCalledTimes(1); }); + test("does not deliver webhooks after an instance expires", async () => { + const fetchMock = mock(() => Promise.resolve(new Response(null))); + globalThis.fetch = fetchMock as unknown as typeof fetch; + seedStorage({ + instances: [ + createInstance({ + expiresAt: 999, + webhookIds: ["webhook-logs-test"], + }), + ], + webhooks: [createWebhook()], + }); + + addLog(createLog({ timestamp: 1_000 })); + await flushPendingWebhookNotifications(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + test("flushes pending webhook deliveries before shutdown", async () => { const webhookId = "webhook-logs-flush-test"; let resolveFetch: ((response: Response) => void) | undefined; @@ -208,7 +250,7 @@ describe("addLog", () => { const flushPromise = flushPendingWebhookNotifications(); let flushed = false; - void flushPromise.then(() => { + const trackedFlush = flushPromise.then(() => { flushed = true; }); @@ -217,7 +259,7 @@ describe("addLog", () => { expect(resolveFetch).toBeDefined(); resolveFetch?.(new Response(null)); - await flushPromise; + await trackedFlush; expect(fetchMock).toHaveBeenCalledTimes(1); expect(flushed).toBe(true); @@ -258,6 +300,7 @@ describe("addLog", () => { expect(firstPage.logs.map((log) => log.id)).toEqual(["log-1", "log-2"]); expect(firstPage.nextCursor).toEqual({ seq: 2 }); + expect(firstPage.resumeCursor).toEqual({ seq: 2 }); const secondPage = getLogsForInstancePage({ instanceId: "inst-1", @@ -267,6 +310,49 @@ describe("addLog", () => { expect(secondPage.logs.map((log) => log.id)).toEqual(["log-3"]); expect(secondPage.nextCursor).toBeUndefined(); + expect(secondPage.resumeCursor).toEqual({ seq: 3 }); + + const waitingPage = getLogsForInstancePage({ + instanceId: "inst-1", + limit: 2, + cursor: secondPage.resumeCursor, + }); + expect(waitingPage.logs).toEqual([]); + expect(waitingPage.resumeCursor).toEqual({ seq: 3 }); + }); + + test("loads only the requested number of most recent logs", () => { + seedStorage({ + instances: [createInstance()], + logs: Array.from({ length: 105 }, (_, index) => + createLog({ id: `log-${index + 1}`, timestamp: index + 1 }), + ), + }); + + const recent = getRecentLogsForInstance("inst-1", 100); + + expect(recent).toHaveLength(100); + expect(recent[0]?.id).toBe("log-6"); + expect(recent.at(-1)?.id).toBe("log-105"); + + const firstPage = getRecentLogsForInstancePage({ + instanceId: "inst-1", + limit: 100, + }); + expect(firstPage.olderCursor).toEqual({ seq: 6 }); + const olderPage = getRecentLogsForInstancePage({ + instanceId: "inst-1", + limit: 100, + before: firstPage.olderCursor, + }); + expect(olderPage.logs.map((log) => log.id)).toEqual([ + "log-1", + "log-2", + "log-3", + "log-4", + "log-5", + ]); + expect(olderPage.olderCursor).toBeUndefined(); }); test("filters paginated logs by type and timestamp", () => { @@ -342,4 +428,58 @@ describe("addLog", () => { createLog({ id: "log-2", instanceId: "inst-2" }), ]); }); + + test("filters expired instances and counts active rows in SQL", () => { + seedStorage({ + instances: [ + createInstance({ expiresAt: 10 }), + createInstance({ id: "inst-2", expiresAt: Date.now() + 60_000 }), + createInstance({ id: "inst-3", ownerId: "user-2" }), + ], + }); + + expect( + getInstanceSummariesByOwner("user-1").map((instance) => instance.id), + ).toEqual(["inst-2"]); + expect(countActiveInstancesByOwner("user-1")).toBe(1); + }); + + test("uses lightweight projections for summaries and protocol lookups", () => { + seedStorage({ + instances: [ + createInstance({ webhookIds: ["webhook-logs-test"] }), + createInstance({ id: "expired", expiresAt: 10 }), + ], + webhooks: [createWebhook()], + }); + + const [summary] = getInstanceSummariesByOwner("user-1"); + if (summary === undefined) { + throw new Error("Expected an active instance summary"); + } + expect(summary).toEqual({ + id: "inst-1", + ownerId: "user-1", + createdAt: 1, + public: false, + locked: false, + logCount: 0, + }); + expect("raw" in summary).toBe(false); + expect("webhookIds" in summary).toBe(false); + expect(getServableInstanceById("inst-1")).toEqual({ + id: "inst-1", + raw: "HTTP/1.1 200 OK\r\n\r\nok", + }); + expect(getInstanceAccessMetadata("inst-1")).toEqual({ + id: "inst-1", + ownerId: "user-1", + createdAt: 1, + public: false, + }); + expect(hasActiveInstance("inst-1")).toBe(true); + expect(hasActiveInstance("expired")).toBe(false); + expect(getServableInstanceById("expired")).toBeUndefined(); + expect(getWebhookIdsForInstance("inst-1")).toEqual(["webhook-logs-test"]); + }); }); diff --git a/packages/backend/src/storage/repositories/logs.ts b/packages/backend/src/storage/repositories/logs.ts index a3280f8..42a2f39 100644 --- a/packages/backend/src/storage/repositories/logs.ts +++ b/packages/backend/src/storage/repositories/logs.ts @@ -1,9 +1,12 @@ import type { Log } from "shared"; import { LogSchema } from "shared"; -import { and, asc, desc, eq, gt, gte } from "drizzle-orm"; +import { and, asc, desc, eq, gt, gte, lt } from "drizzle-orm"; import { getDb } from "../db"; -import { sendDiscordNotificationThrottled } from "../../server/webhooks"; -import { getInstanceById } from "./instances"; +import { + flushDiscordNotificationQueue, + queueDiscordNotification, +} from "../../server/webhooks"; +import { getActiveWebhookIdsForInstance } from "./instances"; import { isDiscordMutedForInstance, recordRequestAndMaybeTombstoneInTransaction, @@ -11,33 +14,27 @@ import { import { logs } from "../schema"; import { getWebhooksByIds } from "./webhooks"; -const pendingWebhookNotifications = new Set>(); - -const notifyWebhooks = async (log: Log, now: number): Promise => { +const notifyWebhooks = (log: Log, now: number): void => { try { - const instance = getInstanceById(log.instanceId); - if (instance === undefined || instance.webhookIds.length === 0) { + const webhookIds = getActiveWebhookIdsForInstance(log.instanceId, now); + if (webhookIds.length === 0) { return; } if (isDiscordMutedForInstance(log.instanceId, now)) { return; } - const webhooks = getWebhooksByIds(instance.webhookIds); - await Promise.all( - webhooks.map((webhook) => sendDiscordNotificationThrottled(webhook, log)), - ); + const webhooks = getWebhooksByIds(webhookIds); + for (const webhook of webhooks) { + queueDiscordNotification(webhook, log); + } } catch (error) { console.error("Error sending webhook notifications:", error); } }; -const trackWebhookNotification = (pending: Promise) => { - pendingWebhookNotifications.add(pending); - void pending.finally(() => { - pendingWebhookNotifications.delete(pending); - }); -}; -export function addLog(log: Log): Log { +export type AddLogOutcome = { log: Log; tombstoned: boolean }; + +export function addLogWithOutcome(log: Log): AddLogOutcome { const parsed = LogSchema.parse(log); const now = parsed.timestamp; const { tombstoned } = getDb().transaction( @@ -52,18 +49,20 @@ export function addLog(log: Log): Log { { behavior: "immediate" }, ); if (tombstoned) { - return parsed; + return { log: parsed, tombstoned: true }; } - trackWebhookNotification(notifyWebhooks(parsed, now)); + notifyWebhooks(parsed, now); + + return { log: parsed, tombstoned: false }; +} - return parsed; +export function addLog(log: Log): Log { + return addLogWithOutcome(log).log; } export async function flushPendingWebhookNotifications(): Promise { - while (pendingWebhookNotifications.size > 0) { - await Promise.allSettled(Array.from(pendingWebhookNotifications)); - } + await flushDiscordNotificationQueue(); } export function getLogsForInstance(instanceId: string): Log[] { @@ -86,8 +85,34 @@ export function getRecentLogsForInstance( instanceId: string, limit: number, ): Log[] { + return getRecentLogsForInstancePage({ instanceId, limit }).logs; +} + +type GetRecentLogsPageInput = { + instanceId: string; + limit: number; + before?: LogsPageCursor; + type?: Log["type"]; +}; + +type RecentLogsPage = { + logs: Log[]; + olderCursor?: LogsPageCursor; +}; + +export function getRecentLogsForInstancePage( + input: GetRecentLogsPageInput, +): RecentLogsPage { + const filters = [eq(logs.instanceId, input.instanceId)]; + if (input.before !== undefined) { + filters.push(lt(logs.seq, input.before.seq)); + } + if (input.type !== undefined) { + filters.push(eq(logs.type, input.type)); + } const rows = getDb() .select({ + seq: logs.seq, id: logs.id, instanceId: logs.instanceId, type: logs.type, @@ -96,12 +121,20 @@ export function getRecentLogsForInstance( raw: logs.raw, }) .from(logs) - .where(eq(logs.instanceId, instanceId)) + .where(and(...filters)) .orderBy(desc(logs.seq)) - .limit(limit) + .limit(input.limit + 1) .all(); - return rows.reverse(); + const pageRows = rows.slice(0, input.limit); + const oldestIncluded = pageRows.at(-1); + return { + logs: pageRows.map(({ seq: _seq, ...log }) => log).reverse(), + olderCursor: + rows.length > input.limit && oldestIncluded !== undefined + ? { seq: oldestIncluded.seq } + : undefined, + }; } export type LogsPageCursor = { @@ -119,6 +152,7 @@ type GetLogsPageInput = { type LogsPage = { logs: Log[]; nextCursor?: LogsPageCursor; + resumeCursor?: LogsPageCursor; }; export function getLogsForInstancePage(input: GetLogsPageInput): LogsPage { @@ -154,6 +188,12 @@ export function getLogsForInstancePage(input: GetLogsPageInput): LogsPage { return { logs: pageRows.map(({ seq: _seq, ...log }) => log), + resumeCursor: + lastRow === undefined + ? input.cursor + : { + seq: lastRow.seq, + }, nextCursor: rows.length > input.limit && lastRow !== undefined ? { seq: lastRow.seq } diff --git a/packages/backend/src/storage/schema.ts b/packages/backend/src/storage/schema.ts index ac6ce35..294cfee 100644 --- a/packages/backend/src/storage/schema.ts +++ b/packages/backend/src/storage/schema.ts @@ -1,10 +1,4 @@ -import type { - ApiKeyScope, - InstanceKind, - LogType, - Processor, - UserNoticeKind, -} from "shared"; +import type { ApiKeyScope, LogType, UserNoticeKind } from "shared"; import { index, integer, @@ -34,13 +28,7 @@ export const instances = sqliteTable( label: text("label"), isPublic: integer("isPublic", { mode: "boolean" }).notNull(), isLocked: integer("isLocked", { mode: "boolean" }).notNull(), - kind: text("kind", { enum: ["static", "dynamic"] }) - .$type() - .notNull(), - raw: text("raw"), - processorsJson: text("processorsJson", { mode: "json" }).$type< - Processor[] | null - >(), + raw: text("raw").notNull(), }, (table) => [ index("instances_by_owner").on(table.ownerId), @@ -101,7 +89,6 @@ export const instanceWebhooks = sqliteTable( table.instanceId, table.webhookId, ), - index("instanceWebhooks_by_instance").on(table.instanceId, table.position), ], ); diff --git a/packages/frontend/index.html b/packages/frontend/index.html index b0cdd99..5779d4b 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -4,6 +4,7 @@ + httpworkbench diff --git a/packages/frontend/package.json b/packages/frontend/package.json index b21a306..305cd71 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -5,13 +5,17 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", - "build:skipts": "vite build", + "build": "vue-tsc -b && NODE_ENV=production vite build && bun run verify:production-build", + "build:skipts": "NODE_ENV=production vite build && bun run verify:production-build", "preview": "vite preview", - "typecheck": "vue-tsc -p tsconfig.app.json --noEmit" + "typecheck": "vue-tsc -p tsconfig.app.json --noEmit", + "verify:production-build": "! grep -R -q 'Component that was made reactive' dist/assets/*.js" }, "dependencies": { - "@ai-sdk/vue": "^3.0.168", + "@ai-sdk/anthropic": "^4.0.39", + "@ai-sdk/openai": "^4.0.42", + "@ai-sdk/vue": "^4.0.66", + "@ai-sdk/xai": "^4.0.39", "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-javascript": "^6.2.4", @@ -22,20 +26,19 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "6.38.8", "@lezer/highlight": "^1.2.3", - "@openrouter/ai-sdk-provider": "^2.8.0", + "@openrouter/ai-sdk-provider": "^3.0.0", "@primeuix/themes": "^2.0.3", "@tailwindcss/vite": "^4.2.1", "@tanstack/vue-query": "^5.92.9", "@tanstack/vue-virtual": "^3.13.29", - "@types/dompurify": "^3.2.0", "@types/markdown-it": "^14.1.2", "@vueuse/core": "^14.2.1", - "ai": "^6.0.168", + "ai": "^7.0.66", "codemirror": "^6.0.2", "compare-versions": "^6.1.1", "dayjs": "^1.11.19", - "dompurify": "^3.3.1", - "markdown-it": "^14.1.1", + "dompurify": "^3.4.11", + "markdown-it": "^14.3.0", "pinia": "^3.0.4", "primeicons": "^7.0.0", "primevue": "^4.5.4", @@ -45,7 +48,6 @@ "urlguard": "^1.0.0", "vue": "^3.5.29", "vue-codemirror": "^6.1.1", - "vue-command-palette": "^0.2.3", "vue-router": "^4.6.4", "zod": "^4.3.6" }, diff --git a/packages/frontend/src/App.vue b/packages/frontend/src/App.vue index d2827d9..4870ff6 100644 --- a/packages/frontend/src/App.vue +++ b/packages/frontend/src/App.vue @@ -1,5 +1,6 @@ + + + -
-
-

- Models -

-
-
- -
-
- + +
diff --git a/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/SelectMenu.vue b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/SelectMenu.vue new file mode 100644 index 0000000..aa31693 --- /dev/null +++ b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/SelectMenu.vue @@ -0,0 +1,238 @@ + + + diff --git a/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/types.ts b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/types.ts new file mode 100644 index 0000000..0db30b2 --- /dev/null +++ b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/types.ts @@ -0,0 +1,8 @@ +import type { Component } from "vue"; + +export type SelectMenuOption = { + value: string; + label: string; + description?: string; + icon?: Component; +}; diff --git a/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/useSelector.ts b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/useSelector.ts index 359ff03..71a2eda 100644 --- a/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/useSelector.ts +++ b/packages/frontend/src/components/Assistant/ChatInput/ModelSelector/useSelector.ts @@ -1,15 +1,27 @@ -import { type Component, computed } from "vue"; -import { AnthropicIcon, GoogleIcon, OpenAIIcon, XAIIcon } from "./icons"; -import UnknownIcon from "./icons/Unknown.vue"; +import { type Component, computed, watch } from "vue"; +import { + AnthropicIcon, + GoogleIcon, + OpenAIIcon, + OpenRouterIcon, + UnknownIcon, + XAIIcon, +} from "@/components/BrandIcons"; import { availableModels } from "@/agent/models"; -import { type ModelItem } from "@/agent/types/config"; +import type { ModelItem, ReasoningEffort } from "@/agent/types/config"; import { useAssistantModelStore } from "@/stores"; +import { useAiSettings } from "@/utils/ai"; type AugmentedModelItem = ModelItem & { icon: Component }; const getIcon = (model: ModelItem) => { - const id = model.id.toLowerCase(); + const id = model.modelId.toLowerCase(); + if (model.provider === "anthropic") return AnthropicIcon; + if (model.provider === "openai" || model.provider === "chatgpt") { + return OpenAIIcon; + } + if (model.provider === "xai") return XAIIcon; if (id.startsWith("anthropic/")) return AnthropicIcon; if (id.startsWith("openai/")) return OpenAIIcon; if (id.startsWith("google/")) return GoogleIcon; @@ -18,12 +30,14 @@ const getIcon = (model: ModelItem) => { if (id.includes("gpt") || id.includes("openai")) return OpenAIIcon; if (id.includes("claude") || id.includes("anthropic")) return AnthropicIcon; if (id.includes("gemini") || id.includes("google")) return GoogleIcon; + if (model.provider === "openrouter") return OpenRouterIcon; return UnknownIcon; }; export const useSelector = () => { const assistantModelStore = useAssistantModelStore(); + const { isProviderConfigured } = useAiSettings(); const modelId = computed({ get() { @@ -34,22 +48,57 @@ export const useSelector = () => { }, }); + const reasoningEffort = computed({ + get() { + return assistantModelStore.agentsReasoningEffort; + }, + set(value) { + assistantModelStore.agentsReasoningEffort = value; + }, + }); + const models = computed(() => - availableModels.map((item) => ({ - ...item, - icon: getIcon(item), - })), + availableModels + .filter((item) => isProviderConfigured(item.provider)) + .map((item) => ({ + ...item, + icon: getIcon(item), + })), ); const selectedModel = computed(() => { - const item = availableModels.find((i) => i.id === modelId.value); + const item = models.value.find((i) => i.id === modelId.value); if (!item) return undefined; - return { ...item, icon: getIcon(item) }; + return item; }); + watch( + models, + (configuredModels) => { + if (!configuredModels.some((model) => model.id === modelId.value)) { + modelId.value = configuredModels[0]?.id ?? ""; + } + }, + { immediate: true }, + ); + + watch( + selectedModel, + (model) => { + if ( + model !== undefined && + !model.reasoningEfforts.includes(reasoningEffort.value) + ) { + reasoningEffort.value = model.defaultReasoningEffort; + } + }, + { immediate: true }, + ); + return { modelId, models, + reasoningEffort, selectedModel, }; }; diff --git a/packages/frontend/src/components/Assistant/ChatInput/useChat.ts b/packages/frontend/src/components/Assistant/ChatInput/useChat.ts index e6e9b72..d86c202 100644 --- a/packages/frontend/src/components/Assistant/ChatInput/useChat.ts +++ b/packages/frontend/src/components/Assistant/ChatInput/useChat.ts @@ -2,11 +2,11 @@ import { computed } from "vue"; import { useAgentsStore } from "@/stores"; import { getErrorMessage } from "@/utils/error"; import { isAbsent } from "@/utils/types"; -import { useHasOpenrouterKey } from "@/utils/openrouter"; +import { useAiSettings } from "@/utils/ai"; export const useChat = () => { const agentStore = useAgentsStore(); - const hasOpenrouterKey = useHasOpenrouterKey(); + const { isEnabled, hasConfiguredProvider } = useAiSettings(); const inputMessage = computed({ get: () => agentStore.inputMessage, @@ -24,7 +24,8 @@ export const useChat = () => { const canSendMessage = computed(() => { return ( - hasOpenrouterKey.value && + isEnabled.value && + hasConfiguredProvider.value && isAgentIdle.value && inputMessage.value.trim() !== "" ); diff --git a/packages/frontend/src/components/Assistant/Container.vue b/packages/frontend/src/components/Assistant/Container.vue index d80c322..8580061 100644 --- a/packages/frontend/src/components/Assistant/Container.vue +++ b/packages/frontend/src/components/Assistant/Container.vue @@ -1,18 +1,22 @@ diff --git a/packages/frontend/src/components/Assistant/SetupRequired.vue b/packages/frontend/src/components/Assistant/SetupRequired.vue index c4e8a9b..b7ebabc 100644 --- a/packages/frontend/src/components/Assistant/SetupRequired.vue +++ b/packages/frontend/src/components/Assistant/SetupRequired.vue @@ -4,8 +4,8 @@ import { useRouter } from "vue-router"; const router = useRouter(); -const openSettings = () => { - void router.push({ name: "settings" }); +const openSettings = async () => { + await router.push({ name: "settings" }); }; @@ -21,11 +21,11 @@ const openSettings = () => {

- OpenRouter key required + Connect an AI provider

- Add your OpenRouter API key in Settings to enable the assistant and - send messages. + Add an API key or connect a subscription in Settings to use the + assistant.