diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27a0493 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +# Build outputs and local dependencies are recreated in the builder stage. +node_modules +.next +coverage +playwright-report +test-results + +# Local configuration can contain secrets and must never enter an image build. +.env +.env.* + +# Repository/editor metadata and non-production developer artifacts. +.git +.gitignore +*.log +*.md +tests +src/__tests__ diff --git a/.env.local.example b/.env.local.example index ff7f747..02079b3 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,3 +1,5 @@ +# Local development backend. Omit NEXT_PUBLIC_API_URL in a production build so +# browser requests stay on the public origin and Caddy can route `/api/*`. NEXT_PUBLIC_API_URL=http://localhost:5147 # reCAPTCHA v2 site key (public) — get from google.com/recaptcha/admin NEXT_PUBLIC_RECAPTCHA_SITE_KEY=REPLACE_WITH_RECAPTCHA_V2_SITE_KEY diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ef6d312 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + development-dependencies: + dependency-type: development + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4c46b8..40c0279 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,22 +6,26 @@ on: pull_request: branches: ["main"] +# The normal validation job is deliberately read-only. The separate evidence +# attestation job gets its write permissions only after validation succeeds. +permissions: + contents: read + jobs: frontend: - name: Lint, build, test, audit + name: Build, test, package, and container smoke runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - name: Set up Node 20 - uses: actions/setup-node@v4 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: "20" + node-version: "22" cache: "npm" cache-dependency-path: package-lock.json - - name: Install dependencies + - name: Install dependencies reproducibly run: npm ci - name: Type check @@ -30,17 +34,78 @@ jobs: - name: Lint run: npm run lint - - name: Build + - name: Build production frontend run: npm run build env: - NEXT_PUBLIC_API_URL: http://localhost:5147 - NEXT_PUBLIC_RECAPTCHA_SITE_KEY: placeholder - NEXT_PUBLIC_GOOGLE_CLIENT_ID: placeholder + # Deliberately omit NEXT_PUBLIC_API_URL. Production browser traffic + # must stay on the Caddy public origin and use relative /api routes. + NEXT_PUBLIC_RECAPTCHA_SITE_KEY: placeholder-public-key + NEXT_PUBLIC_GOOGLE_CLIENT_ID: placeholder-public-client-id - - name: Tests + - name: Unit and component tests run: npm test - # npm audit may report advisories for transitive dependencies (e.g. PostCSS via Next.js) - # that have no safe upgrade path. Run informational-only so it does not block CI. - - name: npm audit (informational) - run: npm audit --audit-level=critical || true + - name: Fail on high or critical npm vulnerabilities + run: npm audit --audit-level=high + + - name: Generate reproducible CycloneDX SBOM + run: | + mkdir -p artifacts/sbom + npx --yes @cyclonedx/cyclonedx-npm@6.0.0 \ + --package-lock-only \ + --output-reproducible \ + --output-format JSON \ + --output-file artifacts/sbom/frontend.cdx.json + + - name: Build production container image + run: | + docker build \ + --build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=placeholder-public-key \ + --build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=placeholder-public-client-id \ + --tag simple-frontend:${{ github.sha }} . + + - name: Smoke the unprivileged container + shell: bash + run: | + set -euo pipefail + docker run --detach --name simple-frontend-smoke -p 3000:3000 simple-frontend:${{ github.sha }} + for attempt in {1..30}; do + if curl --fail --silent --show-error http://127.0.0.1:3000/ >/dev/null; then + docker exec simple-frontend-smoke id | grep -q 'uid=1001(nextjs)' + exit 0 + fi + sleep 1 + done + docker logs simple-frontend-smoke + exit 1 + + - name: Remove smoke container + if: always() + run: docker rm --force simple-frontend-smoke || true + + - name: Upload CI evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: frontend-evidence-${{ github.sha }} + path: artifacts/sbom/ + if-no-files-found: error + retention-days: 30 + + attest-sbom: + name: Attest frontend SBOM evidence + needs: frontend + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + attestations: write + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: frontend-evidence-${{ github.sha }} + path: evidence + + - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: evidence/frontend.cdx.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..60b127e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,25 @@ +name: CodeQL + +on: + push: + branches: ["main", "feature/**"] + pull_request: + branches: ["main"] + schedule: + - cron: "23 3 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze JavaScript and TypeScript + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: github/codeql-action/init@24ea975727876cf496b1eb0c5b36e96e01600b51 # v4.37.0 + with: + languages: javascript-typescript + build-mode: none + - uses: github/codeql-action/analyze@24ea975727876cf496b1eb0c5b36e96e01600b51 # v4.37.0 diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..ae5e04f --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,57 @@ +name: Publish immutable frontend image + +on: + workflow_dispatch: + +# Run this only for an already-green commit on main. The resulting digest and +# provenance attestation are release evidence; a mutable tag is never enough. +permissions: + contents: read + packages: write + attestations: write + id-token: write + +jobs: + publish: + name: Publish and attest frontend image + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: azure-demo + env: + IMAGE_NAME: ghcr.io/simpleplatform/simple-frontend + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push immutable candidate + id: push + uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6.19.0 + with: + context: . + push: true + tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }} + build-args: | + NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ vars.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }} + NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ vars.NEXT_PUBLIC_GOOGLE_CLIENT_ID }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + + - name: Attest image build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Record digest in job summary + run: | + echo '### Immutable frontend image' >> "$GITHUB_STEP_SUMMARY" + echo "\`${IMAGE_NAME}@${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e71ae61 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS dependencies +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . + +# These are browser-visible values, not secrets. Next.js embeds NEXT_PUBLIC_* +# values into the generated client bundle during this build stage. +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY +ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID +ARG SIMPLE_API_PROXY_TARGET +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY} +ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID} +ENV SIMPLE_API_PROXY_TARGET=${SIMPLE_API_PROXY_TARGET} + +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 8cfad86..def5fbf 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,37 @@ The backend API must be running at `NEXT_PUBLIC_API_URL` (default `http://localh | Variable | Description | |---|---| -| `NEXT_PUBLIC_API_URL` | Backend API base URL | +| `NEXT_PUBLIC_API_URL` | Backend API base URL in local development. Omit it from a production build to use the browser's own origin. | | `NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | Google reCAPTCHA v2 site key | | `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | Google OAuth client ID | +## Container and same-origin delivery + +The production image runs the standalone Next.js server as an unprivileged +user on port `3000`. + +```sh +docker build -t simple-frontend \ + --build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your-public-site-key \ + --build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-public-client-id \ + . +docker run --rm -p 3000:3000 simple-frontend +``` + +`NEXT_PUBLIC_*` values are embedded during `next build`; only use public browser +configuration as build arguments. Never pass server credentials, API keys, or +secrets to this image. + +In production, leave `NEXT_PUBLIC_API_URL` unset. The frontend then calls +relative `/api/*` URLs, allowing the public Caddy gateway to route those +requests to the backend while keeping session cookies first-party. Caddy also +owns future SignalR/WebSocket routes; this frontend does not proxy them. + +For an isolated frontend container smoke test without Caddy, pass the +server-only `SIMPLE_API_PROXY_TARGET` build argument (for example, +`--build-arg SIMPLE_API_PROXY_TARGET=http://backend:8080`). Next.js will +rewrite `/api/*` to that internal target. Do not set it in the Caddy deployment. + ## Running tests ``` diff --git a/next.config.ts b/next.config.ts index df3f3df..8d9ebce 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,14 @@ import type { NextConfig } from "next"; import path from "path"; +/** + * Server-only escape hatch for running the frontend without the public Caddy + * gateway (for example, an isolated container smoke test). In the deployed + * stack, Caddy owns the same-origin `/api/*` route and this is intentionally + * left unset. + */ +const apiProxyTarget = process.env.SIMPLE_API_PROXY_TARGET?.replace(/\/+$/, ""); + const securityHeaders = [ // Prevent browsers from MIME-sniffing away from the declared Content-Type. { key: "X-Content-Type-Options", value: "nosniff" }, @@ -15,7 +23,19 @@ const securityHeaders = [ ]; const nextConfig: NextConfig = { + // Produces the minimal runtime server used by the production Docker image. + output: "standalone", outputFileTracingRoot: path.resolve(__dirname), + async rewrites() { + if (!apiProxyTarget) return []; + + return [ + { + source: "/api/:path*", + destination: `${apiProxyTarget}/api/:path*`, + }, + ]; + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json index 5964f19..6205441 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1315,14 +1315,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1526,9 +1526,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1552,9 +1552,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1569,9 +1569,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1586,9 +1586,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1603,9 +1603,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1620,9 +1620,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1637,9 +1637,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1688,9 +1688,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1705,9 +1705,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1722,9 +1722,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1739,9 +1739,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1756,9 +1756,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1766,18 +1766,52 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1792,9 +1826,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -2211,9 +2245,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5625,10 +5659,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6734,9 +6778,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "dev": true, "funding": [ { @@ -7005,13 +7049,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7021,21 +7065,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/run-parallel": { @@ -7647,9 +7691,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -7957,9 +8001,9 @@ } }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -8053,17 +8097,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8079,7 +8123,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8131,9 +8175,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e872560..c107131 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:coverage": "vitest run --coverage", "test:watch": "vitest", "test:e2e": "playwright test" }, diff --git a/src/__tests__/LobbyPage.test.tsx b/src/__tests__/LobbyPage.test.tsx index e298e01..98b5010 100644 --- a/src/__tests__/LobbyPage.test.tsx +++ b/src/__tests__/LobbyPage.test.tsx @@ -22,6 +22,7 @@ vi.stubGlobal('fetch', mockFetch); beforeEach(() => { mockFetch.mockReset(); mockPush.mockReset(); + sessionStorage.clear(); }); function identity(userId: string, name: string) { @@ -63,12 +64,30 @@ async function renderPage(lobbyId = 'lobby-1') { describe('LobbyPage', () => { it('renders a not-found state on a 404', async () => { + sessionStorage.setItem('lobby-credential:lobby-1', JSON.stringify({ code: 'ABCD', linkToken: 'secret' })); setupFetch(u => { if (u.includes('/api/lobbies/lobby-1') && !u.includes('capabilities')) return jsonResponse(404, { error: { code: 'Lobbies.NotFound', message: 'Not found' } }); return null; }); await renderPage(); await waitFor(() => expect(screen.getByText('Lobby not found.')).toBeInTheDocument()); + expect(sessionStorage.getItem('lobby-credential:lobby-1')).toBeNull(); + }); + + it('clears the revealed credential when leaving a lobby', async () => { + sessionStorage.setItem('lobby-credential:lobby-1', JSON.stringify({ code: 'ABCD', linkToken: 'secret' })); + setupFetch((u, opts) => { + if (u.endsWith('/api/lobbies/lobby-1') && (opts?.method ?? 'GET') === 'GET') { + return jsonResponse(200, lobby({ hostUserId: 'u-1', seats: [seat('u-1', 'Me', { isHost: true })], allowedActions: ['leave'] })); + } + if (u.endsWith('/api/lobbies/lobby-1/leave') && opts?.method === 'POST') return jsonResponse(200, {}); + return null; + }); + await renderPage(); + const leaveButton = await screen.findByRole('button', { name: 'Leave' }); + await act(async () => { fireEvent.click(leaveButton); }); + await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/dashboard')); + expect(sessionStorage.getItem('lobby-credential:lobby-1')).toBeNull(); }); it('renders seats and the Ready toggle for a joined member', async () => { diff --git a/src/__tests__/SidebarFriendBadge.test.tsx b/src/__tests__/SidebarFriendBadge.test.tsx index 2b85c17..2605507 100644 --- a/src/__tests__/SidebarFriendBadge.test.tsx +++ b/src/__tests__/SidebarFriendBadge.test.tsx @@ -32,12 +32,19 @@ vi.mock('@/features/friends/FriendSummaryContext', () => ({ useFriendSummary: () => mockSummaryState, })); +const mockGetMyActive = vi.fn(() => Promise.resolve({ lobby: null, ticketId: null })); +vi.mock('@/features/lobby/lobbyApi', () => ({ + lobbyApi: { getMyActive: () => mockGetMyActive() }, +})); + // ── Tests ──────────────────────────────────────────────────────────────────── beforeEach(() => { mockSummaryState.summary = null; mockSummaryState.loading = false; mockSummaryState.error = null; + mockGetMyActive.mockReset(); + mockGetMyActive.mockResolvedValue({ lobby: null, ticketId: null }); }); async function renderSidebar() { @@ -86,4 +93,12 @@ describe('Sidebar friend badge', () => { rerender(); expect(screen.getByText('3')).toBeInTheDocument(); }); + + it('links Active Lobby only when the authenticated user has one', async () => { + mockGetMyActive.mockResolvedValue({ lobby: { lobbyId: 'lobby-real' }, ticketId: null }); + await renderSidebar(); + const link = await screen.findByRole('link', { name: 'Active Lobby' }); + expect(link).toHaveAttribute('href', '/lobby/lobby-real'); + expect(link).not.toHaveAttribute('href', expect.stringContaining('SP-7F-29')); + }); }); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 830439b..8ceeb2e 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Icon } from '@/components/ui/Icons'; @@ -7,9 +7,9 @@ import { Avatar } from '@/components/ui/Avatar'; import { ROUTES } from '@/lib/routes'; import { useAuth } from '@/features/auth/AuthProvider'; import { useFriendSummary } from '@/features/friends/FriendSummaryContext'; +import { lobbyApi } from '@/features/lobby/lobbyApi'; -const NAV_SESSION = [ - { href: ROUTES.lobby('SP-7F-29'), label: 'Active Lobby', icon: 'controller' }, +const NAV_SESSION_BASE = [ { href: ROUTES.profile('me'), label: 'My Profile', icon: 'user' }, ]; const NAV_META = [ @@ -19,6 +19,18 @@ const NAV_META = [ export function Sidebar() { const pathname = usePathname(); const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/'); + const { user } = useAuth(); + const userId = user?.id; + const [activeLobbyId, setActiveLobbyId] = useState(null); + + useEffect(() => { + let cancelled = false; + if (!userId) return () => { cancelled = true; }; + lobbyApi.getMyActive() + .then((context) => { if (!cancelled) setActiveLobbyId(context.lobby?.lobbyId ?? null); }) + .catch(() => { if (!cancelled) setActiveLobbyId(null); }); + return () => { cancelled = true; }; + }, [userId]); const { summary, loading, error } = useFriendSummary(); const friendBadge = (!loading && !error && summary) ? summary.incomingRequestCount : 0; @@ -29,6 +41,10 @@ export function Sidebar() { { href: ROUTES.friends, label: 'Friends', icon: 'users', badgeCount: friendBadge }, { href: ROUTES.leaderboards, label: 'Leaderboards', icon: 'trophy' }, ]; + const NAV_SESSION = [ + ...(userId && activeLobbyId ? [{ href: ROUTES.lobby(activeLobbyId), label: 'Active Lobby', icon: 'controller' }] : []), + ...NAV_SESSION_BASE, + ]; return (