diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2303ea..ec46391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -25,6 +25,9 @@ jobs: - name: Type check run: bun run tsc --noEmit + - name: Run unit tests + run: bun run test:unit + - name: Build frontend run: bun run build @@ -54,7 +57,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -71,16 +74,51 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.3 + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run security audit run: | - bun audit || true # Don't fail on audit issues for now + audit_log="$(mktemp)" + last_exit=0 + for attempt in 1 2 3; do + if bun audit >"$audit_log" 2>&1; then + cat "$audit_log" + rm -f "$audit_log" + exit 0 + else + audit_exit=$? + last_exit=$audit_exit + last_attempt=$attempt + if [ "$attempt" -lt 3 ]; then + echo "bun audit failed (attempt $attempt), retrying..." + sleep 5 + fi + fi + done + + audit_output="$(cat "$audit_log")" + echo "bun audit failed after ${last_attempt:-3} attempts with exit code ${last_exit}" + if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' <<< "$audit_output"; then + echo "bun audit failed after retries due to network/registry error: $audit_output" + else + echo "bun audit failed after retries - vulnerabilities detected: $audit_output" + fi + rm -f "$audit_log" + exit 1 - name: Check for secrets - uses: trufflesecurity/trufflehog@main + # Pinned to immutable commit (v3.93.4) for supply-chain safety. + # Maintenance: periodically verify this SHA still corresponds to the intended upstream release. + uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ extra_args: --debug --only-verified - continue-on-error: true docker: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2ddea..8163bb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,6 @@ name: Release on: - push: - branches: [ master ] workflow_dispatch: inputs: version: @@ -31,7 +29,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -73,6 +71,12 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "version_number=${NEW_VERSION#v}" >> $GITHUB_OUTPUT + - name: Type check + run: bun run tsc --noEmit + + - name: Run backend tests + run: bun run test:unit + - name: Build application run: bun run build @@ -191,7 +195,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: master + ref: refs/tags/${{ needs.release.outputs.new_version }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.gitignore b/.gitignore index bf245db..eda8834 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,7 @@ data/.session-secret test-*.sh debug-*.js verify-*.md -.DS_Store +test-results/ # LLM files .claude diff --git a/Dockerfile b/Dockerfile index 65fae40..0052609 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller production image -FROM oven/bun:latest AS build +FROM oven/bun:1.3.3 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # --- Production stage --- -FROM oven/bun:latest AS production +FROM oven/bun:1.3.3 AS production WORKDIR /app diff --git a/bun.lock b/bun.lock index 0dc4d79..f7af0d5 100644 --- a/bun.lock +++ b/bun.lock @@ -24,16 +24,17 @@ "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", + "zod": "^3.25.76", }, "devDependencies": { "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", @@ -41,6 +42,14 @@ }, }, }, + "overrides": { + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6", + "glob": "^10.5.0", + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1", + "undici": "^6.23.0", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -64,55 +73,57 @@ "@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], @@ -270,8 +281,6 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/yaml": ["@types/yaml@1.9.7", "", { "dependencies": { "yaml": "*" } }, "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA=="], - "@vbyte/buff": ["@vbyte/buff@1.0.2", "", {}, "sha512-h/3CU+9H6fWZzAfM9/ar9FpQdRfupYyL5ug3fJ9hofzSuWytojVUAT32pnBtTIatowGVburZ/qj0xSpTXWA8qA=="], "@vbyte/micro-lib": ["@vbyte/micro-lib@1.1.2", "", { "dependencies": { "@noble/curves": "^1.9.6", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", "@vbyte/buff": "^1.0.2", "zod": "4.0.14" } }, "sha512-SYOTaaY4zAxuIZPdzVQAyyxAi83ZGt+LtXsjyrUjYsQuWzipLsTIF0h5fyLccOn7Mi+4tY9fPUrvMZNQJDijQg=="], @@ -280,7 +289,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.17.1", "", { "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-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.18.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-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -296,13 +305,13 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], "better-ajv-errors": ["better-ajv-errors@1.2.0", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "@humanwhocodes/momoa": "^2.0.2", "chalk": "^4.1.2", "jsonpointer": "^5.0.0", "leven": "^3.1.0 < 4" }, "peerDependencies": { "ajv": "4.11.8 - 8" } }, "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -342,8 +351,6 @@ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], @@ -400,7 +407,7 @@ "es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="], - "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "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=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -416,7 +423,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], + "fast-xml-parser": ["fast-xml-parser@5.3.7", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], @@ -428,8 +435,6 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -442,7 +447,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -462,8 +467,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -500,7 +503,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -542,7 +545,7 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -588,8 +591,6 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openapi-sampler": ["openapi-sampler@1.6.2", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" } }, "sha512-NyKGiFKfSWAZr4srD/5WDhInOWDhfml32h/FKUqLpEwKJt0kG0LGUU0MdyNkKrVGuJnw6DuPWq/sHCwAMpiRxg=="], @@ -600,8 +601,6 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -734,7 +733,7 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "styled-components": ["styled-components@6.1.19", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA=="], @@ -774,7 +773,7 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -798,8 +797,6 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -844,8 +841,6 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -870,8 +865,6 @@ "styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - "swagger2openapi/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "swagger2openapi/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -888,16 +881,12 @@ "concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "oas-resolver/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "oas-resolver/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "swagger2openapi/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "swagger2openapi/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/docs/openapi/README.md b/docs/openapi/README.md index d27d77e..b9fecba 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -4,8 +4,8 @@ This directory contains the comprehensive OpenAPI 3.1 specification for the Iglo ## Files -- **`openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format -- **`openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec +- **`docs/openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format +- **`docs/openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec - **`README.md`** - This documentation file ## Accessing the Documentation @@ -102,7 +102,7 @@ This ensures the YAML syntax is correct and the specification is well-formed. When adding or modifying API endpoints: -1. Update the corresponding section in `openapi/openapi.yaml` +1. Update the corresponding section in `docs/openapi/openapi.yaml` 2. Add/update request and response schemas 3. Include relevant examples 4. Validate the specification: `bun run docs:validate` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 0bbca29..2ac19fa 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -419,22 +419,14 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest", + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthStatus" + "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "enabled": true, - "methods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ], - "rateLimiting": true, - "sessionTimeout": 3600 + "error": "Invalid authentication status request" } } } @@ -3008,14 +3000,13 @@ "methods": { "type": "array", "items": { - "type": "string", - "enum": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] - }, + "type": "string", + "enum": [ + "api-key", + "basic-auth", + "session" + ] + }, "description": "Available authentication methods" }, "rateLimiting": { @@ -3526,7 +3517,7 @@ "type": "object", "description": "Client-supplied fields when creating or updating a NIP‑46 session", "properties": { - "client_pubkey": { + "pubkey": { "type": "string", "description": "Client public key (hex encoded)" }, @@ -3554,7 +3545,7 @@ } }, "required": [ - "client_pubkey" + "pubkey" ] }, "Nip46Session": { @@ -4073,13 +4064,12 @@ ] }, "example": { - "error": "Authentication required", - "authMethods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] + "error": "Authentication required", + "authMethods": [ + "api-key", + "basic-auth", + "session" + ] } } } @@ -4189,4 +4179,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index db5e2ca..b654aad 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -327,16 +327,13 @@ paths: schema: $ref: '#/components/schemas/AuthStatus' '400': - $ref: '#/components/responses/BadRequest' + description: Bad request content: application/json: schema: - $ref: '#/components/schemas/AuthStatus' + $ref: '#/components/schemas/ErrorResponse' example: - enabled: true - methods: ["api-key", "bearer", "basic-auth", "session"] - rateLimiting: true - sessionTimeout: 3600 + error: "Invalid authentication status request" /api/auth/login: post: @@ -1960,7 +1957,7 @@ components: type: array items: type: string - enum: ["api-key", "bearer", "basic-auth", "session"] + enum: ["api-key", "basic-auth", "session"] description: Available authentication methods rateLimiting: type: boolean @@ -2323,7 +2320,7 @@ components: type: object description: Client-supplied fields when creating or updating a NIP‑46 session properties: - client_pubkey: + pubkey: type: string description: Client public key (hex encoded) status: @@ -2338,7 +2335,7 @@ components: description: Preferred relays for the session policy: $ref: '#/components/schemas/Nip46Policy' - required: [client_pubkey] + required: [pubkey] Nip46Session: type: object @@ -2631,7 +2628,7 @@ components: type: string example: error: "Authentication required" - authMethods: ["api-key", "bearer", "basic-auth", "session"] + authMethods: ["api-key", "basic-auth", "session"] InternalServerError: description: Internal server error @@ -2720,4 +2717,4 @@ tags: - name: Onboarding description: First-run onboarding and admin validation (database mode) - name: Event Log - description: Persisted UI event log endpoints (database mode only) \ No newline at end of file + description: Persisted UI event log endpoints (database mode only) diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index 7ed4967..df2ba17 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -556,10 +556,43 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav setIsGenerating(true); try { + const parseRelayList = (raw: string): string[] | null => { + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + const relays = parsed + .filter((relay): relay is string => typeof relay === 'string') + .map((relay) => relay.trim()) + .filter((relay) => relay.length > 0); + return relays.length > 0 ? relays : null; + } catch { + return null; + } + }; + + const resolveRelaysToSave = (): string[] => { + if (!isHeadlessMode) { + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); + } + return ["wss://relay.primal.net"]; + } + + if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) { + const parsedRelays = parseRelayList(advancedSettings.RELAYS); + if (parsedRelays) return parsedRelays; + } + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); + } + return ["wss://relay.primal.net"]; + }; + // Save credentials based on mode if (isHeadlessMode) { // Headless mode - save to env - await fetch('/api/env', { + const relaysToSave = resolveRelaysToSave(); + const response = await fetch('/api/env', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -569,16 +602,20 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav SHARE_CRED: share, GROUP_CRED: groupCredential, GROUP_NAME: keysetName, - // Ensure we have at least one valid relay for the server to use - RELAYS: JSON.stringify(["wss://relay.primal.net"]) + RELAYS: JSON.stringify(relaysToSave) }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save headless credentials (${response.status})`); + } + setExistingRelays(relaysToSave); } else { // Database mode - save to user credentials // Preserve existing relays or use default if none exist - const relaysToSave = existingRelays || ["wss://relay.primal.net"]; + const relaysToSave = resolveRelaysToSave(); - await fetch('/api/user/credentials', { + const response = await fetch('/api/user/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -591,6 +628,11 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav relays: relaysToSave }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save credentials (${response.status})`); + } + setExistingRelays(relaysToSave); } setHasExistingCredentials(true); diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index e219b56..0b2eea7 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -382,27 +382,19 @@ const Signer = forwardRef(({ initialData, authHeaders const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = `${protocol}//${window.location.host}/api/events`; - // Add authentication parameters for WebSocket connection - // Since WebSocket doesn't support custom headers during upgrade, - // we need to pass auth info via URL parameters - const params = new URLSearchParams(); - - // Check if we have auth headers and convert them to URL params + // Avoid exposing long-lived credentials in URL query params. + // Prefer WebSocket subprotocol auth hints supported by the backend. + const protocols: string[] = []; const currentAuth = authHeadersRef.current; if (currentAuth['X-API-Key']) { - params.set('apiKey', currentAuth['X-API-Key']); + protocols.push(`api-key.${currentAuth['X-API-Key']}`); } else if (currentAuth['X-Session-ID']) { - params.set('sessionId', currentAuth['X-Session-ID']); + protocols.push(`session.${currentAuth['X-Session-ID']}`); } else if (currentAuth['Authorization'] && currentAuth['Authorization'].startsWith('Basic ')) { - // For basic auth, we'll rely on cookies or handle it server-side - // The server should accept the connection if the user is already authenticated - } - - if (params.toString()) { - wsUrl += '?' + params.toString(); + // For basic auth, rely on existing browser credentials/cookies. } - - ws = new WebSocket(wsUrl); + + ws = protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl); ws.onopen = () => { isConnecting = false; diff --git a/frontend/components/nip46/RelaySettings.tsx b/frontend/components/nip46/RelaySettings.tsx index a3708f0..e4b8206 100644 --- a/frontend/components/nip46/RelaySettings.tsx +++ b/frontend/components/nip46/RelaySettings.tsx @@ -75,7 +75,13 @@ export function RelaySettings({ relays, onAdd, onRemove, loading = false, saving size="sm" icon={} tooltip="Remove relay" - onClick={() => onRemove(relay)} + onClick={async () => { + try { + await onRemove(relay) + } catch (error) { + console.error('[RelaySettings] Failed to remove relay:', error) + } + }} disabled={saving} /> diff --git a/frontend/components/nip46/Requests.tsx b/frontend/components/nip46/Requests.tsx index 17d752c..0cfe2c8 100644 --- a/frontend/components/nip46/Requests.tsx +++ b/frontend/components/nip46/Requests.tsx @@ -29,6 +29,7 @@ interface ParsedRequest { eventKind: number | null eventTemplate: Record | null contentPreview: string | null + contentTruncated: boolean } const DEFAULT_POLICY: PermissionPolicy = { methods: {}, kinds: {} } @@ -43,6 +44,14 @@ const formatTimestamp = (value: string) => { return Number.isNaN(date.getTime()) ? 'N/A' : date.toLocaleString() } +const sanitizePreview = (value: string): string => { + return value + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') + .replace(/[\u202A-\u202E\u2066-\u2069]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + const parseRequest = (record: Nip46RequestApi): ParsedRequest => { let method = record.method let params: any[] = [] @@ -82,9 +91,11 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { } } - const contentPreview = eventTemplate && typeof eventTemplate.content === 'string' - ? eventTemplate.content.trim().slice(0, 160) + const sanitizedContent = eventTemplate && typeof eventTemplate.content === 'string' + ? sanitizePreview(eventTemplate.content) : null + const contentPreview = sanitizedContent ? sanitizedContent.slice(0, 160) : null + const contentTruncated = !!sanitizedContent && sanitizedContent.length > 160 return { record, @@ -96,7 +107,8 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { sessionUrl, eventKind, eventTemplate, - contentPreview + contentPreview, + contentTruncated } } @@ -197,7 +209,7 @@ export function Requests({ {parsedRequests.map(entry => { - const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview } = entry + const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview, contentTruncated } = entry const policy = policies[record.session_pubkey] ?? DEFAULT_POLICY const methodAllowed = policy.methods?.[method] === true const wildcardKind = policy.kinds?.['*'] === true @@ -276,7 +288,7 @@ export function Requests({ {contentPreview ? ( -
{contentPreview}{eventTemplate?.content && eventTemplate.content.length > 160 ? '…' : ''}
+
{contentPreview}{contentTruncated ? '…' : ''}
) : null} diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index 7881a5c..5588b38 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -29,7 +29,7 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

= ({ )} onClick={toggleExpanded} role="button" + aria-expanded={isExpanded} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -72,4 +73,4 @@ const Collapsible: React.FC = ({ ); }; -export { Collapsible }; \ No newline at end of file +export { Collapsible }; diff --git a/frontend/components/ui/input-with-validation.tsx b/frontend/components/ui/input-with-validation.tsx index 6866ca2..08c58f9 100644 --- a/frontend/components/ui/input-with-validation.tsx +++ b/frontend/components/ui/input-with-validation.tsx @@ -2,7 +2,7 @@ import React, { useId } from 'react'; import { Input } from "./input"; import { cn } from "../../lib/utils"; -interface InputWithValidationProps extends Omit, 'onChange'> { +interface InputWithValidationProps extends Omit, 'onChange' | 'required'> { label?: string | React.ReactNode; value: string; onChange: (value: string) => void; @@ -37,6 +37,7 @@ const InputWithValidation: React.FC = ({ id={inputId} value={value} onChange={(e) => onChange(e.target.value)} + required={isRequired} className={cn( "bg-gray-800/50 border-gray-700/50 text-blue-300 py-2 text-sm w-full", hasError && "border-red-500", @@ -51,4 +52,4 @@ const InputWithValidation: React.FC = ({ ); }; -export { InputWithValidation }; \ No newline at end of file +export { InputWithValidation }; diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 1ccd38f..1d8f717 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef, useId } from 'react'; import { Button } from './button'; import { IconButton } from './icon-button'; import { Badge, type BadgeProps } from './badge'; @@ -166,6 +166,7 @@ const PeerList: React.FC = ({ defaultExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [shouldRenderContent, setShouldRenderContent] = useState(defaultExpanded); const [peers, setPeers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -177,6 +178,8 @@ const PeerList: React.FC = ({ const [policySavingPeers, setPolicySavingPeers] = useState>(new Set()); const [policyPeerErrors, setPolicyPeerErrors] = useState>(new Map()); const hasUserToggledRef = useRef(false); + const panelRef = useRef(null); + const panelId = useId(); useEffect(() => { if (defaultExpanded && !hasUserToggledRef.current) { @@ -184,6 +187,26 @@ const PeerList: React.FC = ({ } }, [defaultExpanded]); + useEffect(() => { + if (isExpanded) { + setShouldRenderContent(true); + } + }, [isExpanded]); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + if (isExpanded) panel.removeAttribute('inert'); + else panel.setAttribute('inert', ''); + }, [isExpanded]); + + const handleCollapseTransitionEnd = useCallback((event: React.TransitionEvent) => { + if (event.target !== event.currentTarget) return; + if (!isExpanded) { + setShouldRenderContent(false); + } + }, [isExpanded]); + const setPolicyBusyState = useCallback((key: string, busy: boolean) => { setPolicySavingPeers(prev => { const next = new Set(prev); @@ -307,7 +330,7 @@ const PeerList: React.FC = ({ // Perform initial ping sweep setIsInitialPingSweep(true); try { - await fetch('/api/peers/ping', { + const pingResponse = await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -315,9 +338,13 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - - // Refresh peer list after ping sweep - await fetchPeers(); + if (!pingResponse.ok) { + const detail = await pingResponse.text().catch(() => '(unreadable)'); + console.debug(`[PeerList] Initial ping sweep failed (${pingResponse.status}): ${detail}`); + } else { + // Refresh peer list after ping sweep + await fetchPeers(); + } } catch (pingError) { console.debug('Initial ping sweep failed:', pingError); // Don't set error state for ping failures @@ -344,7 +371,7 @@ const PeerList: React.FC = ({ return () => { isActive = false; }; - }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers]); + }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers, authHeaders]); // Unified handler for peer status and ping updates const handlePeerUpdate = (event: CustomEvent) => { @@ -434,7 +461,7 @@ const PeerList: React.FC = ({ return newSet; }); } - }, [isSignerRunning]); + }, [authHeaders, isSignerRunning]); const updatePeerPolicy = useCallback(async (peer: PeerStatus, changes: { allowSend?: boolean; allowReceive?: boolean }) => { if (!isSignerRunning || disabled) { @@ -533,15 +560,18 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - - const result = await response.json(); - + if (!response.ok) { + const detail = await response.text().catch(() => '(unreadable)'); + console.warn(`[PeerList] Ping all failed (${response.status}): ${detail}`); + return; + } + // Refresh peer list after pinging all await fetchPeers(); } catch (error) { console.warn('[PeerList] Ping all failed:', error); } - }, [isSignerRunning, peers.length, fetchPeers]); + }, [authHeaders, isSignerRunning, peers.length, fetchPeers]); // Enhanced refresh that includes pinging const handleRefresh = useCallback(async () => { @@ -597,6 +627,8 @@ const PeerList: React.FC = ({ className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-800/50 p-2.5 rounded cursor-pointer hover:bg-gray-800/70 transition-colors gap-2 sm:gap-0" onClick={handleToggle} role="button" + aria-expanded={isExpanded} + aria-controls={panelId} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -611,11 +643,17 @@ const PeerList: React.FC = ({ } Peer List -
e.stopPropagation()}> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> } + focusable + ariaLabel="Peer list help" + trigger={( + + )} content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

} @@ -654,18 +692,25 @@ const PeerList: React.FC = ({ )}
-
e.stopPropagation()} className="flex-shrink-0"> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()} className="flex-shrink-0"> {actions}
{/* Collapsible Content */}
+ {shouldRenderContent && (
{isLoading ? (
@@ -770,10 +815,7 @@ const PeerList: React.FC = ({
Policy: out {outboundPolicy.statusLabel}, in {inboundPolicy.statusLabel} - + {policyBadgeLabel}
@@ -816,6 +858,7 @@ const PeerList: React.FC = ({ width="w-72" triggerClassName="cursor-help" focusable + ariaLabel="Policy controls help" trigger={} content={
@@ -896,6 +939,7 @@ const PeerList: React.FC = ({
)}
+ )}
); diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx index 71bfead..58d9059 100644 --- a/frontend/components/ui/tooltip.tsx +++ b/frontend/components/ui/tooltip.tsx @@ -2,31 +2,48 @@ import React, { useState, ReactNode, useRef, useEffect, useCallback, useId } fro import { createPortal } from 'react-dom'; import { cn } from "../../lib/utils"; -interface TooltipProps { +interface TooltipSharedProps { trigger: ReactNode; content: ReactNode; className?: string; position?: 'top' | 'right' | 'bottom' | 'left'; width?: string; triggerClassName?: string; - focusable?: boolean; } -const Tooltip: React.FC = ({ - trigger, - content, - className, - position = 'left', - width = 'w-72', - triggerClassName, - focusable = false, -}) => { +type TooltipProps = + | (TooltipSharedProps & { + focusable: true; + ariaLabel: string; + }) + | (TooltipSharedProps & { + focusable?: false; + ariaLabel?: string; + }); + +const Tooltip: React.FC = (props) => { + const { + trigger, + content, + className, + position = 'left', + width = 'w-72', + triggerClassName, + } = props; + const focusable = props.focusable ?? false; + const ariaLabel = props.ariaLabel; const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const tooltipId = useId(); const triggerRef = useRef(null); const tooltipRef = useRef(null); + useEffect(() => { + if (focusable && !ariaLabel) { + console.error('[Tooltip] focusable tooltips require ariaLabel for accessibility.'); + } + }, [focusable, ariaLabel]); + const updatePosition = useCallback(() => { if (typeof window === 'undefined' || !triggerRef.current || !tooltipRef.current) { return; @@ -123,6 +140,7 @@ const Tooltip: React.FC = ({ type="button" {...commonProps} className={cn('inline-flex align-middle', triggerClassName)} + aria-label={ariaLabel} > {triggerContent} diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 0563d44..a7d1627 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -124,6 +124,7 @@ export interface UpdateInfo { updateAvailable: boolean; releaseUrl?: string; checkedAt?: string; + source?: 'github-release' | 'github-tags'; error?: string; } diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index f4e19ae..b605e7a 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false` (or `RELAY_ALLOW_BENIGN_SWALLOW`) forces publish errors to surface. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. To force publish errors to surface, set `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false`; if `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset, set `RELAY_ALLOW_BENIGN_SWALLOW=false`. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 8c3b8c9..36d643b 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -66,11 +66,11 @@ These values are set in the store compose and expected by the UI flow: ## Operational Notes - Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. -- The Umbrel store uses a pinned digest to avoid tag caching issues; update the digest on each new release. -- `packages/umbrel/igloo/docker-compose.yml` remains a sideload/dev bundle and still points at `:umbrel-dev` without a digest. +- `igloo-server/docker-compose.yml` (Umbrel store artifact) intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. +- `packages/umbrel/igloo/docker-compose.yml` is a sideload/dev bundle and also points at `:umbrel-dev` but without a pinned digest. ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update `igloo-server-store/igloo-server/docker-compose.yml` to the new image digest. -3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. +2. Bump the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). +3. Revise `igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/package.json b/package.json index aed086c..9fa9da0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "api:test:get:openapi": "bun scripts/api/test-get-openapi-sweep.ts", "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", + "test:unit": "bun test --max-concurrency=1 src tests/routes", "typecheck": "tsc --noEmit", "tsc": "tsc --noEmit" }, @@ -55,20 +56,29 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "zod": "^3.25.76" }, "devDependencies": { "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.3" + }, + "overrides": { + "glob": "^10.5.0", + "minimatch": "^10.2.1", + "js-yaml": "^4.1.1", + "undici": "^6.23.0", + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6" } } diff --git a/scripts/patch-zod-compat.mjs b/scripts/patch-zod-compat.mjs index 5006721..00736a8 100644 --- a/scripts/patch-zod-compat.mjs +++ b/scripts/patch-zod-compat.mjs @@ -9,7 +9,12 @@ const ZOD_DIR = join(PROJECT_ROOT, 'node_modules', 'zod') const NOSTR_SCHEMA_DIR = join(PROJECT_ROOT, 'node_modules', '@cmdcode', 'nostr-connect', 'dist', 'schema') function ensureFile(path, content) { - const current = readFileSync(path, 'utf8') + let current = '' + try { + current = readFileSync(path, 'utf8') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } if (current !== content) { writeFileSync(path, content) } diff --git a/scripts/release.sh b/scripts/release.sh index 107ff4f..c2c57b0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -28,8 +28,12 @@ echo "📥 Pulling latest changes..." git pull origin dev # Run tests and build -echo "🔨 Building project..." +echo "🧪 Running type checks and backend tests..." bun install +bun run typecheck +bun run test:unit + +echo "🔨 Building project..." bun run build echo "✅ Testing server startup..." @@ -69,13 +73,9 @@ for i in {1..5}; do fi done -if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server failed to respond after 5 attempts" -fi - # Cleanup will be handled by trap, just check if we should fail if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server startup test failed - cannot proceed with release" + echo "❌ Server failed to respond after 5 attempts - cannot proceed with release" exit 1 fi diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts new file mode 100644 index 0000000..58877f2 --- /dev/null +++ b/src/class/relay.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'bun:test'; +import { NostrRelay } from './relay.js'; + +type FakeSocket = { + data: unknown; + sent: string[]; + closed: boolean; + send: (message: string) => void; + close: () => void; +}; + +type RelayHandler = ReturnType; +type HandlerSocket = Parameters>[0]; + +function asHandlerSocket(socket: FakeSocket): HandlerSocket { + return socket as unknown as HandlerSocket; +} + +function createFakeSocket(): FakeSocket { + return { + data: null, + sent: [], + closed: false, + send(message: string) { + this.sent.push(message); + }, + close() { + this.closed = true; + }, + }; +} + +function decodeSent(socket: FakeSocket): unknown[][] { + return socket.sent.map((message) => JSON.parse(message) as unknown[]); +} + +describe('NostrRelay REQ handling', () => { + it('normalizes wrapped nostr-tools REQ filter arrays into subscriptions', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-1'); + expect(sub?.filters).toHaveLength(1); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-1']); + }); + + it('rejects REQ with an empty wrapped filter array', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty', []])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + + it('rejects REQ with no filters', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty-no-filters'])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + + it('accepts canonical multi-filter REQ payloads and creates a subscription', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + const authorHex = 'f'.repeat(64); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-multi', { kinds: [1] }, { authors: [authorHex] }])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-multi'); + expect(sub?.filters).toHaveLength(2); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + expect((sub?.filters[1] as { authors?: string[] }).authors).toEqual([authorHex]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-multi']); + }); + + it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + handler.message?.(ws, JSON.stringify(['CLOSE', 'sub-close'])); + expect(relay.subs.size).toBe(0); + + handler.message?.(ws, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + const closeHandler = handler.close as unknown as ((socketArg: HandlerSocket, code: number) => void) | undefined; + closeHandler?.(ws, 1000); + expect(relay.subs.size).toBe(0); + expect(socket.closed).toBe(true); + }); + + it('applies filter.limit to matched events only', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + const unmatched = { + id: 'u'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 1, + kind: 9, + tags: [], + content: '', + sig: 'b'.repeat(128), + } as Parameters[0]; + const matchedA = { + id: 'c'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 2, + kind: 1, + tags: [], + content: '', + sig: 'd'.repeat(128), + } as Parameters[0]; + const matchedB = { + id: 'e'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 3, + kind: 1, + tags: [], + content: '', + sig: 'f'.repeat(128), + } as Parameters[0]; + + relay.store(unmatched); + relay.store(matchedA); + relay.store(matchedB); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-limit', { kinds: [1], limit: 1 }])); + + const messages = decodeSent(socket); + const eventMessages = messages.filter((msg) => msg[0] === 'EVENT'); + expect(eventMessages).toHaveLength(1); + expect(eventMessages[0]?.[1]).toBe('sub-limit'); + expect((eventMessages[0]?.[2] as { kind?: number }).kind).toBe(1); + expect(messages).toContainEqual(['EOSE', 'sub-limit']); + }); +}); diff --git a/src/class/relay.ts b/src/class/relay.ts index e849402..b7f2904 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -152,7 +152,23 @@ class RelaySession { switch (verb) { case 'REQ': + // Normalize nostr-tools 2.x format where filters are wrapped in an extra array: + // New format: ["REQ", "sub_id", [{filter1}, {filter2}]] + // NIP-01 format: ["REQ", "sub_id", {filter1}, {filter2}] + if (payload.length === 2 && Array.isArray(payload[1]) && payload[1].length === 0) { + this.log.info('ignoring REQ with empty filter array') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } + if (payload.length === 2 && Array.isArray(payload[1])) { + payload = [payload[0], ...payload[1]] + } const [ id, ...filters ] = sub_schema.parse(payload) + if (filters.length === 0) { + this.log.info('ignoring REQ with no filters') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } return this._onreq(id, filters) case 'EVENT': const event = Nostr.parse_event(payload.at(0), this.relay.config.debug) @@ -185,7 +201,7 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.debug('event failed validation:', event) + this.log.debug(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } @@ -210,7 +226,7 @@ class RelaySession { this.log.client('received subscription request:', sub_id) this.log.debug('filters:', filters) // Add the subscription to our set. - this.addSub(sub_id, filters) + this.addSub(sub_id, ...filters) // For each filter: for (const filter of filters) { // Set the limit count, if any. @@ -225,9 +241,12 @@ class RelaySession { this.send(['EVENT', sub_id, event]) this.log.client(`event matched in cache: ${event.id}`) this.log.client(`event matched subscription: ${sub_id}`) + // Decrement only when we actually sent a matching event. + if (limit_count !== undefined) { + limit_count -= 1 + if (limit_count === 0) break + } } - // Update the limit count. - if (limit_count !== undefined) limit_count -= 1 } } } @@ -254,7 +273,7 @@ class RelaySession { } remSub (subId : string) { - this.relay.subs.delete(subId) + this.relay.subs.delete(`${this.sid}/${subId}`) this._subs.delete(subId) } diff --git a/src/config/crypto.ts b/src/config/crypto.ts index d72e59b..56d6f36 100644 --- a/src/config/crypto.ts +++ b/src/config/crypto.ts @@ -5,7 +5,7 @@ // PBKDF2 Configuration for Key Derivation export const PBKDF2_CONFIG = { - ITERATIONS: 200000, // Number of iterations (higher = more secure but slower) + ITERATIONS: 600000, // OWASP-aligned baseline for PBKDF2-HMAC-SHA256 KEY_LENGTH: 32, // 256 bits ALGORITHM: 'sha256', // Hash algorithm } as const; @@ -60,4 +60,4 @@ export function isPasswordValid(pwd: string): boolean { return false; } return VALIDATION.PASSWORD_REGEX.test(pwd); -} \ No newline at end of file +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index b0d2f9e..987516c 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -28,7 +28,7 @@ export function runMigrations(migrationsDirRel = 'src/db/migrations', opts?: { s // Security: Ensure migrations directory is within project boundaries const projectRoot = path.resolve(process.cwd()) - if (!dir.startsWith(projectRoot + path.sep) && dir !== projectRoot) { + if (!dir.startsWith(projectRoot + path.sep)) { throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 2a8e4e0..5f39256 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -126,13 +126,17 @@ export async function handleAdminRoute( // Check rate limit before admin authentication to prevent brute force attacks const rate = await checkRateLimit(req, 'auth', { clientIp: _context.clientIp }); if (!rate.allowed) { + const fallbackRetryAfterSeconds = Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString(); + const retryAfterSeconds = typeof rate.resetAt === 'number' + ? Math.max(1, Math.ceil((rate.resetAt - Date.now()) / 1000)).toString() + : fallbackRetryAfterSeconds; return Response.json( { error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + 'Retry-After': retryAfterSeconds } } ); @@ -272,7 +276,7 @@ export async function handleAdminRoute( createdAt: key.createdAt, updatedAt: key.updatedAt, lastUsedAt: key.lastUsedAt, - lastUsedIp: key.lastUsedIp, + lastUsedIp: null, revokedAt: key.revokedAt, revokedReason: key.revokedReason, createdByUserId: key.createdByUserId, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d496161..9649fcf 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -453,7 +453,7 @@ export async function checkRateLimit( req: Request, bucket: string = 'auth', opts?: { windowMs?: number; max?: number; clientIp?: string } -): Promise<{ allowed: boolean; remaining: number }> { +): Promise<{ allowed: boolean; remaining: number; resetAt?: number }> { if (!AUTH_CONFIG.RATE_LIMIT_ENABLED) { return { allowed: true, remaining: AUTH_CONFIG.RATE_LIMIT_MAX }; } @@ -469,7 +469,8 @@ export async function checkRateLimit( return { allowed: result.allowed, - remaining: result.remaining + remaining: result.remaining, + resetAt: result.resetAt }; } @@ -1009,7 +1010,7 @@ export async function handleLogin(req: Request): Promise { return Response.json({ success: false, error: 'Database temporarily unavailable. Please try again.' - }, { status: 503 }); // 503 Service Unavailable + }, { status: 503, headers: baseHeaders }); // 503 Service Unavailable } // For unexpected errors, log but don't expose details @@ -1093,12 +1094,29 @@ export function handleLogout(req: Request): Response { 'Vary': mergedVary, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Session-ID', + }; + + const logoutHeaders = { + ...headers, 'Set-Cookie': `session=; HttpOnly; Path=/; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Strict; Max-Age=0` }; if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers }); } + + if (req.method !== 'POST') { + return Response.json( + { error: 'Method not allowed' }, + { + status: 405, + headers: { + ...headers, + 'Allow': 'POST, OPTIONS' + } + } + ); + } const sessionId = req.headers.get('x-session-id') || extractSessionFromCookie(req); @@ -1113,7 +1131,7 @@ export function handleLogout(req: Request): Response { try { zeroizeVaultEntryAndDelete(sessionId) } catch {} } - return Response.json({ success: true }, { headers }); + return Response.json({ success: true }, { headers: logoutHeaders }); } // Authentication middleware wrapper (deprecated - use explicit auth parameters instead) diff --git a/src/routes/env.ts b/src/routes/env.ts index cdd2e64..861f17c 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -92,9 +92,17 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // but we keep the broader classification for clarity. const isWrite = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; // Resolve authenticated DB user id (database mode only) - const authenticatedNumericUserId = (!HEADLESS && auth?.authenticated && ( - typeof auth.userId === 'number' || (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) - )) ? BigInt(auth!.userId as any) : null; + const authenticatedNumericUserId = (() => { + if (HEADLESS || !auth?.authenticated) return null; + if (typeof auth.userId === 'number') { + if (!Number.isInteger(auth.userId) || auth.userId <= 0) return null; + return BigInt(auth.userId); + } + if (typeof auth.userId === 'string' && /^[1-9]\d*$/.test(auth.userId)) { + return BigInt(auth.userId); + } + return null; + })(); const isRoleAdmin = await (async () => { try { if (authenticatedNumericUserId === null) return false; @@ -148,6 +156,18 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged hasValidHeadlessApiKey(r) || hasValidHeadlessBasic(r) ); + const extractNonEmptyAdminSecret = (r: Request): string | undefined => { + const headerSecret = r.headers.get('X-Admin-Secret')?.trim(); + if (headerSecret && headerSecret.length > 0) return headerSecret; + + const authHeader = r.headers.get('Authorization'); + if (!authHeader) return undefined; + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (!bearerMatch) return undefined; + const bearerToken = bearerMatch[1]?.trim(); + return bearerToken && bearerToken.length > 0 ? bearerToken : undefined; + }; + const isHeadlessReadAuthorized = (r: Request, a?: RequestAuth | null): boolean => { // If global auth is enabled and a session is present, allow; otherwise require API key or Basic. if (AUTH_CONFIG.ENABLED && a?.authenticated) return true; @@ -276,18 +296,11 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged const env = await readEnvFile(); const { validKeys, invalidKeys: rejectedKeys } = validateEnvKeys(Object.keys(body)); - if (validKeys.includes('RELAYS') && body.RELAYS !== undefined) { - const relayValidation = validateRelayUrls(body.RELAYS); - if (!relayValidation.valid) { - return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); - } - } - // DB mode privilege gate for env writes (no legacy fallback): // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or // - allow when the authenticated DB user has role=admin. // validateAdminSecret() returns false when the header is missing; there is no bypass. - const adminSecret = req.headers.get('X-Admin-Secret') ?? req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); + const adminSecret = extractNonEmptyAdminSecret(req); const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( @@ -296,6 +309,30 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } + if (validKeys.includes('RELAYS') && body.RELAYS !== undefined) { + const relayValidation = validateRelayUrls(body.RELAYS); + if (!relayValidation.valid) { + return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); + } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } + } + + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } + } + for (const key of validKeys) { if (body[key] !== undefined) { env[key] = body[key]; @@ -322,14 +359,6 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged return Response.json({ success: false, message: 'Failed to update .env file' }, { status: 500, headers }); } - // Headless writes must be authorized by API key or Basic (sessions are not sufficient) - if (HEADLESS && !hasHeadlessWriteAuthorization(req)) { - return Response.json( - { error: 'Authentication required' }, - { status: 401, headers } - ); - } - let body; try { body = await parseJsonRequestBody(req); @@ -348,6 +377,23 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (!relayValidation.valid) { return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } + } + + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } } for (const key of validKeys) { @@ -361,7 +407,7 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (updatingCredentials) { // Set the timestamp explicitly here to avoid relying on downstream helpers // for correctness, then perform a single write. - (env as any).CREDENTIALS_SAVED_AT = new Date().toISOString(); + env.CREDENTIALS_SAVED_AT = new Date().toISOString(); } const writeOk = await writeEnvFile(env); @@ -629,8 +675,7 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } if (!HEADLESS) { - const adminSecret = req.headers.get('X-Admin-Secret') ?? - req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); + const adminSecret = extractNonEmptyAdminSecret(req); const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( diff --git a/src/routes/index.ts b/src/routes/index.ts index 8183c72..5dac5cd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -190,147 +190,143 @@ export async function handleRequest( // Admin endpoints have their own ADMIN_SECRET authentication const isAdminEndpoint = url.pathname.startsWith('/api/admin'); - // Authentication check for API endpoints (skip public endpoints, status, and admin) - if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { - const authResult = await authenticate(req); - - if (authResult.rateLimited) { - const response = Response.json({ - error: 'Rate limit exceeded. Try again later.' - }, { - status: 429, - headers: { - ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() - } - }); - finalizeAuth(); - return response; - } - - if (!authResult.authenticated) { - // Don't set WWW-Authenticate header to avoid browser's native auth dialog - // The frontend will handle authentication through its own UI - const response = Response.json({ - error: authResult.error || 'Authentication required', - authMethods: getAuthStatus() - }, { - status: 401, - headers - }); - finalizeAuth(); - return response; - } - - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword - }); - } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { - // Special handling for /api/status: attempt authentication if headers are present - // but don't require it (allow unauthenticated health checks) - try { + try { + // Authentication check for API endpoints (skip public endpoints, status, and admin) + if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { const authResult = await authenticate(req); - // Only use auth info if authentication actually succeeded (not rate limited or failed) - if (authResult.authenticated && !authResult.rateLimited) { - // Create auth info with secure ephemeral storage for secrets - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword + if (authResult.rateLimited) { + return Response.json({ + error: 'Rate limit exceeded. Try again later.' + }, { + status: 429, + headers: { + ...headers, + 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + } }); } - // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) - } catch (error) { - // If authentication throws an error, allow unauthenticated access - // Authentication attempt failed, allowing unauthenticated access for health checks - } - } - - // Note: Authentication is now handled above for all non-public API endpoints - - // Handle user routes (database mode only) - if (!HEADLESS && url.pathname.startsWith('/api/user')) { - const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); - if (userResult) { - finalizeAuth(); - return userResult; - } - } - - // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, - // but when a valid session exists for an admin user we allow that too. - if (!HEADLESS && url.pathname.startsWith('/api/admin')) { - // Attempt optional authentication for admin endpoints to support session-admin access. - // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. - if (AUTH_CONFIG.ENABLED && !authInfo) { + + if (!authResult.authenticated) { + // Don't set WWW-Authenticate header to avoid browser's native auth dialog + // The frontend will handle authentication through its own UI + return Response.json({ + error: authResult.error || 'Authentication required', + authMethods: getAuthStatus() + }, { + status: 401, + headers + }); + } + + authInfo = createRequestAuth({ + userId: authResult.userId, + authenticated: true, + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword + }); + } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { + // Special handling for /api/status: attempt authentication if headers are present + // but don't require it (allow unauthenticated health checks) try { - const adminAuth = await authenticate(req); - if (adminAuth.authenticated && !adminAuth.rateLimited) { + const authResult = await authenticate(req); + + // Only use auth info if authentication actually succeeded (not rate limited or failed) + if (authResult.authenticated && !authResult.rateLimited) { + // Create auth info with secure ephemeral storage for secrets authInfo = createRequestAuth({ - userId: adminAuth.userId, + userId: authResult.userId, authenticated: true, - derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, - sessionId: adminAuth.sessionId, - hasPassword: adminAuth.hasPassword + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword }); } - } catch {} + // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) + } catch (error) { + // If authentication throws an error, allow unauthenticated access + // Authentication attempt failed, allowing unauthenticated access for health checks + } } - const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); - if (adminResult) { - finalizeAuth(); - return adminResult; + // Note: Authentication is now handled above for all non-public API endpoints + + // Handle user routes (database mode only) + if (!HEADLESS && url.pathname.startsWith('/api/user')) { + const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); + if (userResult) { + return userResult; + } } - } - - // Handle privileged routes separately - if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { - const result = await handleEnvRoute(req, url, privilegedContext, authInfo); - if (result) { - finalizeAuth(); - return result; + + // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, + // but when a valid session exists for an admin user we allow that too. + if (!HEADLESS && url.pathname.startsWith('/api/admin')) { + // Attempt optional authentication for admin endpoints to support session-admin access. + // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. + if (AUTH_CONFIG.ENABLED && !authInfo) { + try { + const adminAuth = await authenticate(req); + if (adminAuth.authenticated && !adminAuth.rateLimited) { + authInfo = createRequestAuth({ + userId: adminAuth.userId, + authenticated: true, + derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, + sessionId: adminAuth.sessionId, + hasPassword: adminAuth.hasPassword + }); + } + } catch {} + } + + const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); + if (adminResult) { + return adminResult; + } + } + + // Handle privileged routes separately + if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { + const result = await handleEnvRoute(req, url, privilegedContext, authInfo); + if (result) { + return result; + } } - } - if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { - const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); - if (nip46Result) { - finalizeAuth(); - return nip46Result; + if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { + const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); + if (nip46Result) { + return nip46Result; + } } - } - // Try each non-privileged route handler in order - // Note: These handlers now accept auth as an optional parameter - const routeHandlers = [ - handleStatusRoute, // Allow unauthenticated for health checks - handleUpdateRoute, - handleEventLogRoute, - handlePeersRoute, - handleSignRoute, - handleNip44Route, - handleNip04Route, - handleRecoveryRoute, - ]; + // Try each non-privileged route handler in order + // Note: These handlers now accept auth as an optional parameter + const routeHandlers = [ + handleStatusRoute, // Allow unauthenticated for health checks + handleUpdateRoute, + handleEventLogRoute, + handlePeersRoute, + handleSignRoute, + handleNip44Route, + handleNip04Route, + handleRecoveryRoute, + ]; - for (const handler of routeHandlers) { - const result = await handler(req, url, context, authInfo); - if (result) { - finalizeAuth(); - return result; + for (const handler of routeHandlers) { + const result = await handler(req, url, context, authInfo); + if (result) { + return result; + } } - } - // If no route matched, return 404 - const notFound = new Response('Not Found', { status: 404 }); - finalizeAuth(); - return notFound; + // If no route matched, return 404 + if (url.pathname.startsWith('/api/')) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }); + } + return new Response('Not Found', { status: 404 }); + } finally { + finalizeAuth(); + } } diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index 9592a0e..c9ab656 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -79,14 +79,20 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon if (!isContentLengthWithin(req, DEFAULT_MAX_JSON_BODY)) { return Response.json({ error: 'Request too large' }, { status: 413, headers }) } + const authContext = _auth ?? context.auth + if (!authContext?.authenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401, headers }) + } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }) - // Separate bucket for e2e crypto ops + // Separate bucket for crypto operations const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) + const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900 return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, - headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() } + headers: { ...headers, 'Retry-After': retryAfter.toString() } }) } diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index 3947620..9c0e07c 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -34,8 +34,8 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }); - // Basic rate limit for e2e crypto ops - // Separate bucket for e2e crypto ops + // Basic rate limit for crypto operations + // Use a dedicated bucket separate from signing traffic. const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { @@ -65,17 +65,15 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon const mode = url.pathname.endsWith('/encrypt') ? 'encrypt' : url.pathname.endsWith('/decrypt') ? 'decrypt' : null; if (!mode) return Response.json({ error: 'Unknown operation' }, { status: 404, headers }); - // Platform-agnostic hex to Uint8Array conversion - const hexBytes = secretHex.match(/.{1,2}/g); - if (!hexBytes) { - throw new Error('Invalid hex string format'); + const key = Uint8Array.from(Buffer.from(secretHex, 'hex')); + if (key.length !== 32) { + throw new Error('Invalid shared secret length'); } - const key = new Uint8Array(hexBytes.map(byte => parseInt(byte, 16))); if (mode === 'encrypt') { - const ciphertext = await nip44.encrypt(content, key); + const ciphertext = nip44.encrypt(content, key); return Response.json({ result: ciphertext }, { status: 200, headers }); } else { - const plaintext = await nip44.decrypt(content, key); + const plaintext = nip44.decrypt(content, key); return Response.json({ result: plaintext }, { status: 200, headers }); } } catch (e: any) { diff --git a/src/routes/nip46.ts b/src/routes/nip46.ts index 75f1d6a..88c7a5d 100644 --- a/src/routes/nip46.ts +++ b/src/routes/nip46.ts @@ -192,10 +192,6 @@ export async function handleNip46Route( return Response.json({ error: 'NIP-46 persistence unavailable in headless mode' }, { status: 404 }) } - // Ensure database is initialized before processing any NIP46 requests - // This prevents race conditions where routes are accessed before migrations complete - await initializeNip46DB() - const corsHeaders = getSecureCorsHeaders(req) const mergedVary = mergeVaryHeaders(corsHeaders) const headers = { @@ -206,6 +202,16 @@ export async function handleNip46Route( 'Vary': mergedVary, } + // Ensure database is initialized before processing any NIP46 requests. + // This prevents race conditions where routes are accessed before migrations complete. + try { + await initializeNip46DB() + } catch (error) { + console.error('[NIP46] Failed to initialize DB:', error) + const message = error instanceof Error ? error.message : 'Failed to initialize NIP-46 database' + return Response.json({ error: 'DB_INIT_FAILED', message }, { status: 500, headers }) + } + if (req.method === 'OPTIONS') return new Response(null, { status: 200, headers }) // Require authenticated DB user @@ -336,33 +342,33 @@ export async function handleNip46Route( const result = typeof body?.result === 'string' ? body.result : null const errorMessage = typeof body?.error === 'string' ? body.error : null - const policyPatch = parsePolicyPatch(body?.policy) - let existingRecord = policyPatch ? getNip46RequestById(id) : null - if (policyPatch) { - if (!existingRecord) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - const recordUserId = typeof existingRecord.user_id === 'bigint' - ? existingRecord.user_id.toString() - : String(existingRecord.user_id) - const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) - if (recordUserId !== requestUserId) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - - const session = getSession(userId, existingRecord.session_pubkey) - if (!session) { - return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) - } - - try { - const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) - updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update policy' - return Response.json({ error: message }, { status: 400, headers }) - } - } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + + const policyPatch = parsePolicyPatch(body?.policy) + if (policyPatch) { + const session = getSession(userId, existingRecord.session_pubkey) + if (!session) { + return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) + } + + try { + const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) + updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update policy' + return Response.json({ error: message }, { status: 400, headers }) + } + } const record = updateNip46RequestStatus(id, status, { result, error: errorMessage }) if (!record) { @@ -388,6 +394,18 @@ export async function handleNip46Route( return Response.json({ error: 'Field "id" is required' }, { status: 400, headers }) } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + deleteNip46Request(id) return Response.json({ ok: true }, { headers }) } @@ -603,6 +621,9 @@ export async function handleNip46Route( if (url.pathname.startsWith('/api/nip46/sessions/') && req.method === 'DELETE') { const pubkey = parsePubkeyFromPath(url.pathname) if (!pubkey || !isValidHex(pubkey)) return Response.json({ error: 'Invalid pubkey' }, { status: 400, headers }) + if (url.pathname !== `/api/nip46/sessions/${pubkey}`) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }) + } const ok = deleteSession(userId, pubkey.toLowerCase()) return Response.json({ ok }, { headers }) } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index a3d6efa..0c88ade 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from 'crypto'; +import { createHash, timingSafeEqual } from 'crypto'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { ADMIN_SECRET, HEADLESS, SKIP_ADMIN_SECRET_VALIDATION } from '../const.js'; @@ -267,8 +267,9 @@ const UNIFORM_AUTH_ERROR = { error: 'Authentication failed' }; // - Lowercase letter // - Digit // - Special character (at least one of @$!%*?&, but allows any special chars) -// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])\S*$/; +// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH. +// Whitespace is allowed and preserved by policy. +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/; /** * Validates the admin secret in a timing-safe manner @@ -289,21 +290,15 @@ export async function validateAdminSecret(adminSecret: string | undefined): Prom try { // Coerce to string to prevent type errors const adminSecretStr = String(adminSecret); - const providedSecret = Buffer.from(adminSecretStr); - const expectedSecret = Buffer.from(ADMIN_SECRET); - - // Timing-safe comparison - if (providedSecret.length !== expectedSecret.length) { - return false; - } - - return timingSafeEqual(providedSecret, expectedSecret); + const providedDigest = createHash('sha256').update(adminSecretStr).digest(); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + return timingSafeEqual(providedDigest, expectedDigest); } catch { // On any error, perform dummy comparison to maintain consistent timing - const expectedSecret = Buffer.from(String(ADMIN_SECRET)); - const dummySecret = Buffer.alloc(expectedSecret.length); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + const dummyDigest = Buffer.alloc(expectedDigest.length); try { - timingSafeEqual(dummySecret, expectedSecret); + timingSafeEqual(dummyDigest, expectedDigest); } catch {} return false; } diff --git a/src/routes/status.ts b/src/routes/status.ts index e70593b..2ca85b5 100644 --- a/src/routes/status.ts +++ b/src/routes/status.ts @@ -59,7 +59,7 @@ export async function handleStatusRoute(req: Request, url: URL, context: RouteCo // Lazy-load DB only in non-headless, authenticated path const { userHasStoredCredentials } = await import('../db/database.js'); // Convert to bigint for database operation - const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : parsedUserId; + const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : BigInt(parsedUserId); hasStoredCredentials = userHasStoredCredentials(dbUserId); } } diff --git a/src/routes/user.ts b/src/routes/user.ts index 05ab10c..4d32ba8 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -283,13 +283,16 @@ export async function handleUserRoute( if ('relays' in body) { // Validate relays format - if (body.relays === null || - (Array.isArray(body.relays) && - body.relays.every((r: any) => typeof r === 'string'))) { - updates.relays = body.relays; + if (body.relays === null) { + updates.relays = null; + } else if ( + Array.isArray(body.relays) && + body.relays.every((r: unknown): r is string => typeof r === 'string' && isValidWebSocketUrl(r)) + ) { + updates.relays = body.relays.map((relay: string) => relay.trim()); } else { return Response.json( - { error: 'Invalid relays format. Must be an array of strings or null.' }, + { error: 'Invalid relay URLs. Must use ws:// or wss://' }, { status: 400, headers } ); } diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 6236e1e..cf0c64a 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,5 +1,16 @@ import { describe, expect, it } from 'bun:test'; -import { getValidRelays } from './utils.js'; +import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; + +async function withEnv(key: string, value: string, fn: () => Promise | T): Promise { + const previous = process.env[key]; + process.env[key] = value; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +} describe('getValidRelays', () => { it('returns default relay when fallback is enabled and input is empty', () => { @@ -20,4 +31,111 @@ describe('getValidRelays', () => { it('filters invalid relays and returns empty when fallback disabled', () => { expect(getValidRelays('["not-a-relay","ftp://example.com"]', { fallbackToDefault: false })).toEqual([]); }); + + it('filters IPv6 localhost relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters localhost hostname relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters localhost hostname with trailing dot when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://localhost.:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters localhost subdomain relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://relay.localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters IPv4-mapped IPv6 hex relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters IPv4-mapped IPv6 ::ffff:0: relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:0:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters expanded IPv6 loopback relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:0:0:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters expanded IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) + .toEqual(['ws://127.0.0.1:18002']); + }); + }); +}); + +describe('normalizeRelayListForEcho', () => { + it('filters localhost relays when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect( + normalizeRelayListForEcho([ + 'ws://127.0.0.1:18002', + 'ws://[::1]:18002', + 'ws://[::ffff:127.0.0.1]:18002', + 'ws://localhost:18002', + 'wss://relay.example.com' + ]) + ).toEqual(['wss://relay.example.com']); + }); + }); + + it('keeps localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); + }); + }); + + it('keeps localhost hostname in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); + }); + }); + + it('keeps IPv6 localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::1]:18002'])).toEqual(['ws://[::1]:18002']); + }); + }); + + it('keeps IPv4-mapped IPv6 relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::ffff:127.0.0.1]:18002'])).toEqual(['ws://[::ffff:127.0.0.1]:18002']); + }); + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 732859d..9798f9b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -32,6 +32,56 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { return hex.toLowerCase(); } +function isValidIpv4Address(hostname: string): boolean { + const octets = hostname.split('.'); + if (octets.length !== 4) return false; + return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); +} + +function decodeMappedIpv4(segment: string): string | null { + if (isValidIpv4Address(segment)) return segment; + const mappedHex = segment.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (!mappedHex) return null; + const hi = Number.parseInt(mappedHex[1], 16); + const lo = Number.parseInt(mappedHex[2], 16); + const decoded = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + return isValidIpv4Address(decoded) ? decoded : null; +} + +function extractIpv4MappedIpv6(hostname: string): string | null { + const mappedPrefixes = [ + /^::ffff:(.+)$/i, + /^::ffff:0:(.+)$/i, + ]; + for (const pattern of mappedPrefixes) { + const match = hostname.match(pattern); + if (!match) continue; + const decoded = decodeMappedIpv4(match[1]); + if (decoded) return decoded; + } + return null; +} + +function isLoopbackRelayHost(hostname: string): boolean { + let normalized = hostname.replace(/\.+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase(); + if ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' + ) return true; + + const mappedIpv4 = extractIpv4MappedIpv6(normalized); + if (mappedIpv4) { + normalized = mappedIpv4; + } + + const octets = normalized.split('.'); + if (octets.length !== 4) return false; + if (octets[0] !== '127') return false; + return isValidIpv4Address(normalized); +} + // Helper function to get valid relay URLs export function getValidRelays( envRelays?: string, @@ -60,11 +110,13 @@ export function getValidRelays( } // Validate each relay URL and exclude localhost to avoid conflicts + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const validRelays = relayList.filter(relay => { try { const url = new URL(relay); // Exclude localhost relays to avoid conflicts with our server - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + // (unless explicitly allowed, e.g. for testing) + if (!allowLocalhost && isLoopbackRelayHost(url.hostname)) { console.warn(`Excluding localhost relay to avoid conflicts: ${relay}`); return false; } @@ -96,7 +148,9 @@ export function getValidRelays( } // Helper functions for .env file management -const ENV_FILE_PATH = '.env'; +function getEnvFilePath(): string { + return process.env.ENV_FILE_PATH?.trim() || '.env'; +} // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only @@ -246,8 +300,9 @@ function stringifyEnvFile(env: Record): string { export async function readEnvFile(): Promise> { try { - await fs.access(ENV_FILE_PATH); - const content = await fs.readFile(ENV_FILE_PATH, 'utf-8'); + const envFilePath = getEnvFilePath(); + await fs.access(envFilePath); + const content = await fs.readFile(envFilePath, 'utf-8'); const fileEnv = parseEnvFile(content); // Merge with actual environment variables as fallback @@ -290,7 +345,7 @@ function getEnvVarsFromProcess(): Record { // Get the modification time of the environment file export async function getEnvFileModTime(): Promise { try { - const stats = await fs.stat(ENV_FILE_PATH); + const stats = await fs.stat(getEnvFilePath()); return stats.mtime.toISOString(); } catch (error) { // File doesn't exist or error accessing it @@ -354,7 +409,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr } const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); @@ -365,7 +420,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr export async function writeEnvFile(env: Record): Promise { try { const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); @@ -795,14 +850,15 @@ export function validateRelayUrls(relays: any): { valid: boolean; urls?: string[ export function normalizeRelayListForEcho(relays: any): string[] | undefined { const validation = validateRelayUrls(relays); if (!validation.valid || !validation.urls || validation.urls.length === 0) return undefined; + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const filtered = validation.urls .map((r) => r.trim()) .filter((r) => r.length > 0) .filter((r) => { try { const u = new URL(r); - return (u.protocol === 'ws:' || u.protocol === 'wss:') && - u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '::1'; + if (!allowLocalhost && isLoopbackRelayHost(u.hostname)) return false; + return true; } catch { return false; } diff --git a/src/server.ts b/src/server.ts index d720965..5fdf3db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -415,12 +415,14 @@ async function initializeDatabase(): Promise { } } -// Initialize database with single exit point -initializeDatabase().catch((err) => { +// Initialize database before starting relay/node setup +try { + await initializeDatabase(); +} catch (err) { console.error('❌ Fatal initialization error:'); console.error(' ', err instanceof Error ? err.message : String(err)); process.exit(1); -}); +} // Create the Nostr relay const relay = new NostrRelay(); diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 8334059..8670b23 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -182,7 +182,8 @@ export class PersistentRateLimiter { } } - throw new RateLimiterUnavailableError(); + // Defensive fallback for type completeness; loop paths above should always return or throw. + return this.checkMemoryLimit(identifier, config, Date.now()); } /** @@ -264,14 +265,13 @@ export class PersistentRateLimiter { if (this.db) { try { - this.db + const result = this.db .prepare('DELETE FROM rate_limits WHERE last_attempt < ?') .run(cutoff); // Only log if entries were deleted - const changes = this.db.query('SELECT changes() as c').get() as { c: number } | null; - if (changes && changes.c > 0) { - console.log(`[RateLimiter] Cleaned up ${changes.c} expired entries`); + if (result.changes > 0) { + console.log(`[RateLimiter] Cleaned up ${result.changes} expired entries`); } } catch (error) { console.error('[RateLimiter] Cleanup failed:', error); diff --git a/tests/routes/admin.whoami.session.spec.ts b/tests/routes/admin.whoami.session.spec.ts index a95d8ce..76761c0 100644 --- a/tests/routes/admin.whoami.session.spec.ts +++ b/tests/routes/admin.whoami.session.spec.ts @@ -28,7 +28,9 @@ describe('admin whoami with DB-backed session', () => { const sessionId = auth.createSession(1, '203.0.113.7') expect(sessionId).toBeString() - database.default.exec("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = '" + sessionId + "'") + database.default + .prepare("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = ?") + .run(sessionId) const req = new Request('http://localhost/api/admin/whoami', { headers: { diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 2b331fd..85a87a5 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,6 +1,17 @@ import { describe, expect, test } from 'bun:test'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +function normalizeOptionalEnv(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +const TEST_KEYSET_SECRET = + normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? + normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { const script = ` @@ -77,11 +88,19 @@ describe('DB-mode /api/env behavior', () => { updateNode: () => {} }; + // Generate real FROSTR credentials so validateGroup/validateShare pass. + // Resolve from project root because this script runs from a temp directory. + const { createRequire } = await import('module'); + const requireFromRoot = createRequire(root + 'package.json'); + const iglooCorePath = requireFromRoot.resolve('@frostr/igloo-core'); + const { generateKeysetWithSecret } = await import(iglooCorePath); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, ${JSON.stringify(TEST_KEYSET_SECRET)}); + const headers = new Headers({ 'Content-Type': 'application/json', 'X-Admin-Secret': 'test-admin-secret' }); - const body = { GROUP_CRED: 'group-cred-stub', SHARE_CRED: 'share-cred-stub' }; + const body = { GROUP_CRED: groupCredential, SHARE_CRED: shareCredentials[0] }; const req = new Request('http://localhost/api/env', { method: 'POST', headers, body: JSON.stringify(body) }); const res = await handleEnvRoute(req, new URL(req.url), context, { authenticated: true, userId: 2 }); @@ -100,7 +119,6 @@ describe('DB-mode /api/env behavior', () => { const out = runRouteScript(script); expect(out.hasStamp).toBeTrue(); - // Status may be 200 on success or 500 if restart failed; accept either - expect([200, 500]).toContain(out.status); + expect(out.status).toBe(200); }, { timeout: 10000 }); }); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts new file mode 100644 index 0000000..b14f9c4 --- /dev/null +++ b/tests/routes/helpers/script-runner.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildScriptEnv, + ISOLATED_ENV_KEYS, + ISOLATED_ENV_PREFIXES, +} from './script-runner'; + +describe('buildScriptEnv', () => { + test('keeps forced values and blocks reserved override keys', () => { + const env = buildScriptEnv( + { + NODE_ENV: 'production', + DB_PATH: '/tmp/attacker.db', + ENV_FILE_PATH: '/tmp/attacker.env', + ADMIN_SECRET: 'nope', + CUSTOM_FLAG: '1', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env.NODE_ENV).toBe('test'); + expect(env.DB_PATH).toBe('/tmp/forced.db'); + expect(env.ENV_FILE_PATH).toBe('/tmp/forced.env'); + expect(env.ADMIN_SECRET).toBeUndefined(); + expect(env.CUSTOM_FLAG).toBe('1'); + }); + + test('blocks override keys that match isolated prefixes', () => { + const reservedPrefix = ISOLATED_ENV_PREFIXES[0]; + const env = buildScriptEnv( + { + [`${reservedPrefix}WINDOW`]: '123', + [`${reservedPrefix}MAX`]: '999', + SAFE_KEY: 'ok', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env[`${reservedPrefix}WINDOW`]).toBeUndefined(); + expect(env[`${reservedPrefix}MAX`]).toBeUndefined(); + expect(env.SAFE_KEY).toBe('ok'); + }); + + test('removes reserved keys inherited from process.env', () => { + const forcedKeys = new Set(['NODE_ENV', 'DB_PATH', 'ENV_FILE_PATH']); + const reservedKey = ISOLATED_ENV_KEYS.find((key) => !forcedKeys.has(key)); + expect(reservedKey).toBeDefined(); + if (!reservedKey) throw new Error('Expected at least one reserved key other than NODE_ENV'); + const preserved = process.env[reservedKey]; + process.env[reservedKey] = 'should-not-leak'; + try { + const env = buildScriptEnv( + {}, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + expect(env[reservedKey]).toBeUndefined(); + } finally { + if (preserved === undefined) delete process.env[reservedKey]; + else process.env[reservedKey] = preserved; + } + }); +}); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 7eea3ae..1c38aaf 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -5,34 +5,136 @@ import { pathToFileURL } from 'url'; export const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; -export function runRouteScript(code: string, env: Record = {}) { +export const ISOLATED_ENV_KEYS = [ + 'NODE_ENV', + 'HEADLESS', + 'AUTH_ENABLED', + 'API_KEY', + 'BASIC_AUTH_USER', + 'BASIC_AUTH_PASS', + 'GROUP_CRED', + 'SHARE_CRED', + 'GROUP_NAME', + 'RELAYS', + 'PEER_POLICIES', + 'DB_PATH', + 'ADMIN_SECRET', + 'SESSION_SECRET', + 'ALLOWED_ORIGINS', + 'TRUST_PROXY', + 'AUTO_ADMIN_SECRET', + 'SKIP_ADMIN_SECRET_VALIDATION', + 'ENV_FILE_PATH', +] as const; + +export const ISOLATED_ENV_PREFIXES = [ + 'RATE_LIMIT_', +] as const; + +const ERROR_PREVIEW_MAX_CHARS = 200; + +function isBlockedEnvKey(key: string): boolean { + return ISOLATED_ENV_KEYS.includes(key as (typeof ISOLATED_ENV_KEYS)[number]) || + ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); +} + +function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string { + const compact = raw.replace(/\s+/g, ' ').trim(); + if (!compact) return '(empty)'; + const redacted = compact + .replace( + /(["']?(?:admin_secret|session_secret|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, + '$1' + ) + .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); + return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...(truncated)` : redacted; +} + +function sanitizeOverrides(overrides: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (isBlockedEnvKey(key)) continue; + sanitized[key] = value; + } + return sanitized; +} + +export function buildScriptEnv( + overrides: Record, + forced: { dbPath: string; envFilePath: string } +): Record { + const nextEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + nextEnv[key] = value; + } + } + + for (const key of Object.keys(nextEnv)) { + if (isBlockedEnvKey(key)) { + delete nextEnv[key]; + } + } + + const sanitizedOverrides = sanitizeOverrides(overrides); + + return { + ...nextEnv, + ...sanitizedOverrides, + NODE_ENV: 'test', + DB_PATH: forced.dbPath, + ENV_FILE_PATH: forced.envFilePath, + }; +} + +/** + * Runs route code in an isolated Bun subprocess and returns the parsed @@RESULT@@ JSON payload. + */ +export function runRouteScript>(code: string, env: Record = {}): T { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); writeFileSync(runner, code, 'utf8'); + const isolatedEnv = buildScriptEnv(env, { + envFilePath: path.join(tmpDir, '.env'), + dbPath: path.join(tmpDir, 'igloo.db'), + }); + const result = Bun.spawnSync({ - cmd: ['bun', 'run', runner], + cmd: ['bun', '--no-env-file', 'run', runner], cwd: process.cwd(), - env: { ...process.env, ...env }, + env: isolatedEnv, stdout: 'pipe', stderr: 'pipe', timeout: 15000, }); if (result.exitCode !== 0) { + const stderrPreview = toSafePreview(result.stderr.toString()); + const stdoutPreview = toSafePreview(result.stdout.toString()); throw new Error( - `route script failed: status=${result.exitCode} stderr="${result.stderr.toString()}" stdout="${result.stdout.toString()}"` + `route script failed: status=${result.exitCode} stderr_preview="${stderrPreview}" stdout_preview="${stdoutPreview}"` ); } const marker = '@@RESULT@@'; const stdout = result.stdout.toString().trim(); - const line = stdout.split('\n').findLast(l => l.includes(marker)); + const line = stdout.split('\n').reverse().find(l => l.includes(marker)); if (!line) { - throw new Error(`route script missing result marker: ${stdout}`); + throw new Error(`route script missing result marker; stdout_preview="${toSafePreview(stdout)}"`); + } + const rawJson = line.slice(line.indexOf(marker) + marker.length); + try { + const parsed = JSON.parse(rawJson) as unknown; + return parsed as T; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `route script returned invalid JSON marker payload: ${detail}; ` + + `raw_preview="${toSafePreview(rawJson)}"; stdout_preview="${toSafePreview(stdout)}"` + ); } - return JSON.parse(line.slice(line.indexOf(marker) + marker.length)); } finally { rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/tests/routes/status-env.spec.ts b/tests/routes/status-env.test.ts similarity index 100% rename from tests/routes/status-env.spec.ts rename to tests/routes/status-env.test.ts