diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d46b0d..fb920768 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,33 @@ jobs: - name: Unit tests run: yarn test:unit + cloudflare: + # SSC-31: production is a static export plus a thin Worker. A server + # action, force-dynamic page, or new dynamic route without + # generateStaticParams breaks the export; catch it here, not at deploy. + name: Cloudflare static build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build static shell + run: yarn cf:build + env: + # Any well-formed key; the build only embeds it. + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_Y2xlcmsuZXhhbXBsZS5jb20k + + - name: Bundle the app Worker + run: npx wrangler deploy --dry-run --outdir .wrangler/dry-run + e2e: name: E2E Tests runs-on: ubuntu-latest diff --git a/.github/workflows/open-next-worker.yml b/.github/workflows/cloudflare-app-worker.yml similarity index 77% rename from .github/workflows/open-next-worker.yml rename to .github/workflows/cloudflare-app-worker.yml index 640e6228..192e672a 100644 --- a/.github/workflows/open-next-worker.yml +++ b/.github/workflows/cloudflare-app-worker.yml @@ -1,5 +1,9 @@ -name: OpenNext Worker (reusable) +name: Cloudflare app Worker (reusable) +# SSC-31: builds the static Next.js export (`yarn cf:build` -> out/) and +# deploys it with the app Worker (workers/app) that serves /api/*, /ingest/* +# and dynamic pages. No OpenNext, no Next.js server, no SSR. +# # Shared by production and staging. Secrets already on the Worker persist # across `wrangler deploy` — do not `secret put` here. Each secret put # publishes a new Worker version; doing that after every commit is what @@ -57,8 +61,10 @@ jobs: CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - - name: Build (OpenNext Cloudflare adapter) - run: npx opennextjs-cloudflare build + - name: Build static shell and check the Worker + run: | + yarn cf:build + npx vitest run workers env: NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} NEXT_PUBLIC_CLERK_SIGN_IN_URL: ${{ vars.NEXT_PUBLIC_CLERK_SIGN_IN_URL }} @@ -73,6 +79,16 @@ jobs: posthog_api_key: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} posthog_project_id: ${{ vars.POSTHOG_PROJECT_ID }} posthog_region: US Cloud + NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.VAPID_PUBLIC_KEY }} + + # SSC-37/SSC-39: creates the KV namespace and job queues on first run and + # writes the namespace id into wrangler.jsonc. The API token needs + # Workers KV Storage: Edit and Queues: Edit. + - name: Ensure KV namespace and queues + run: node scripts/cloudflare/ensure-resources.mjs ${{ inputs.wrangler_env_args }} + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - uses: cloudflare/wrangler-action@v3 with: @@ -84,7 +100,10 @@ jobs: # with "Not enough arguments following: env" (broke prod deploy on # 2026-09-17, see run 35190488025). Plain `deploy` still targets the # top-level env correctly; it just logs a harmless warning. - command: ${{ inputs.wrangler_command }} + # The publishable key is public; the Worker derives the Clerk issuer + # (JWKS) from it to verify session JWTs locally. + # The VAPID public key is public too; queued push jobs need it to sign. + command: ${{ inputs.wrangler_command }} --var NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} ${{ secrets.VAPID_PUBLIC_KEY && format('--var NEXT_PUBLIC_VAPID_PUBLIC_KEY:{0}', secrets.VAPID_PUBLIC_KEY) || '' }} - name: Confirm Worker secrets already exist run: | diff --git a/.github/workflows/cutover-vercel-domain.yml b/.github/workflows/cutover-vercel-domain.yml deleted file mode 100644 index eb828f61..00000000 --- a/.github/workflows/cutover-vercel-domain.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Cut over Star Sailors domain to Vercel - -on: - workflow_dispatch: - inputs: - target: - description: Domain to cut over after its Vercel deployment passed - required: true - type: choice - options: [staging, production] - -jobs: - cutover: - runs-on: ubuntu-latest - environment: production - permissions: - contents: read - env: - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - steps: - - name: Validate cutover credentials - run: | - for name in CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID VERCEL_TOKEN; do - if [ -z "${!name}" ]; then - echo "::error::Missing required repository secret: $name" - exit 1 - fi - done - - - name: Detach the OpenNext Worker domain and point DNS at Vercel - env: - TARGET: ${{ inputs.target }} - run: | - if [ "$TARGET" = staging ]; then - domain=staging.starsailors.space - worker=starsailors-client-staging - else - domain=starsailors.space - worker=starsailors-client - fi - - # Register before changing DNS. The deploy workflow has already - # assigned the staging alias (or --prod assigned production), but - # this makes a missing project-domain configuration fail early. - if ! npx --yes vercel@latest domains inspect "$domain" --scope "$VERCEL_ORG_ID" >/dev/null 2>&1; then - npx --yes vercel@latest domains add "$domain" "$VERCEL_PROJECT_ID" --scope "$VERCEL_ORG_ID" - fi - - domains="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains?hostname=$domain")" - domain_id="$(echo "$domains" | jq -r --arg domain "$domain" --arg worker "$worker" '.result[] | select(.hostname == $domain and .service == $worker) | .id' | head -1)" - if [ -n "$domain_id" ] && [ "$domain_id" != null ]; then - curl --fail-with-body --silent --show-error -X DELETE \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains/$domain_id" | jq -e '.success == true' - fi - - zone_id="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones?name=starsailors.space" | jq -r '.result[0].id')" - test -n "$zone_id" && test "$zone_id" != null - record_id="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=CNAME&name=$domain" | jq -r '.result[0].id // empty')" - payload="$(jq -nc --arg name "$domain" '{type:"CNAME",name:$name,content:"cname.vercel-dns.com",ttl:1,proxied:false}')" - if [ -n "$record_id" ]; then - curl --fail-with-body --silent --show-error -X PUT \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" | jq -e '.success == true' - else - curl --fail-with-body --silent --show-error -X POST \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" | jq -e '.success == true' - fi diff --git a/.github/workflows/deploy-api-worker.yml b/.github/workflows/deploy-api-worker.yml deleted file mode 100644 index 044a4d8e..00000000 --- a/.github/workflows/deploy-api-worker.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Deploy API Worker (SSC-35) - -# Manual only. The API Worker (workers/api) serves /api/v1/* same-origin and -# must stay inside the Workers Free plan. Configuration comes from GitHub: -# vars: CLERK_ISSUER, CLERK_AUTHORIZED_PARTIES(_STAGING) -# secrets: POCKETBASE_URL, POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD -# Existing Worker secrets survive `wrangler deploy`; each secret write publishes -# a new Worker version, so only tick sync_secrets when a secret actually changed. -on: - workflow_dispatch: - inputs: - target: - description: Which Worker to deploy - type: choice - options: - - staging - - production - default: staging - sync_secrets: - description: Write the PocketBase secrets (first deploy or after rotation) - type: boolean - default: false - -concurrency: - group: deploy-api-worker-${{ inputs.target }} - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: production - permissions: - contents: read - env: - TARGET_ARGS: ${{ inputs.target == 'staging' && '--env staging' || '' }} - PARTIES: ${{ inputs.target == 'staging' && vars.CLERK_AUTHORIZED_PARTIES_STAGING || vars.CLERK_AUTHORIZED_PARTIES }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: yarn - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Validate configuration - run: | - missing=() - for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CLERK_ISSUER PARTIES; do - [ -n "${!name}" ] || missing+=("$name") - done - if [ "${#missing[@]}" -gt 0 ]; then - echo "::error::Missing GitHub secrets/variables: ${missing[*]}" - exit 1 - fi - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLERK_ISSUER: ${{ vars.CLERK_ISSUER }} - - - name: Test and typecheck the Worker - run: | - npx vitest run workers/api - npx tsc --noEmit -p . - - - name: Deploy and write secrets - if: inputs.sync_secrets - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: workers/api - secrets: | - POCKETBASE_URL - POCKETBASE_ADMIN_EMAIL - POCKETBASE_ADMIN_PASSWORD - command: deploy ${{ env.TARGET_ARGS }} --var CLERK_ISSUER:${{ vars.CLERK_ISSUER }} --var CLERK_AUTHORIZED_PARTIES:${{ env.PARTIES }} - env: - POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} - POCKETBASE_ADMIN_EMAIL: ${{ secrets.POCKETBASE_ADMIN_EMAIL }} - POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} - - - name: Deploy (keep existing secrets) - if: ${{ !inputs.sync_secrets }} - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - workingDirectory: workers/api - command: deploy ${{ env.TARGET_ARGS }} --var CLERK_ISSUER:${{ vars.CLERK_ISSUER }} --var CLERK_AUTHORIZED_PARTIES:${{ env.PARTIES }} diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index 5f678d50..78c1ac57 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -1,7 +1,7 @@ -name: Deploy to Cloudflare Workers (Legacy Staging) +name: Deploy staging to Cloudflare Workers -# Constant preview target for signal-k/client: same OpenNext Cloudflare -# adapter as production, deployed to the wrangler.jsonc `env.staging` +# Constant staging target for signal-k/client: same static shell + app +# Worker build as production, deployed to the wrangler.jsonc `env.staging` # Worker at the constant https://staging.starsailors.space custom domain. # That's a real subdomain of the apex (not *.workers.dev) on purpose: # production's Clerk instance uses a custom Frontend API proxy domain @@ -11,13 +11,24 @@ name: Deploy to Cloudflare Workers (Legacy Staging) # Clerk instance as production (not a separate staging backend) so # accounts/data stay one ecosystem; only the frontend Worker differs. on: - # SSC-31 moves the staging host to Vercel Node hosting. Keep this workflow - # dispatch-only until the Cloudflare Worker domain is detached, so a staging - # push cannot accidentally reclaim staging.starsailors.space. - workflow_dispatch: {} - # Edge API rollout check: deploys only from this one branch, never `staging`. push: - branches: [ssc-edge-api-staging] + branches: [staging, ssc-edge-api-staging] + paths: + - "src/**" + - "public/**" + - "package.json" + - "yarn.lock" + - "next.config.*" + - "wrangler.jsonc" + - "workers/**" + - "scripts/cloudflare/**" + - "tsconfig.json" + - "postcss.config.*" + - "tailwind.config.*" + - "components.json" + - ".github/workflows/deploy-cloudflare-staging.yml" + - ".github/workflows/cloudflare-app-worker.yml" + workflow_dispatch: {} concurrency: group: cloudflare-staging @@ -25,7 +36,7 @@ concurrency: jobs: deploy: - uses: ./.github/workflows/open-next-worker.yml + uses: ./.github/workflows/cloudflare-app-worker.yml secrets: inherit with: wrangler_command: deploy --env staging @@ -34,6 +45,18 @@ jobs: # Edge API rollout: after the edge-branch deploy, play the signed-in game on it. playtest: needs: deploy - if: github.ref == 'refs/heads/ssc-edge-api-staging' + if: github.ref == 'refs/heads/ssc-edge-api-staging' || github.ref == 'refs/heads/staging' uses: ./.github/workflows/staging-playtest.yml secrets: inherit + + # SSC-38: record CPU, subrequests, size and errors per route on the deployed + # staging Worker. Independent of the playtest so one failure can't hide the other. + # Runs after the playtest (pass or fail) so its secret changes don't land + # mid-measurement. + budget: + needs: [deploy, playtest] + if: always() && needs.deploy.result == 'success' + uses: ./.github/workflows/measure-cloudflare-budget.yml + secrets: inherit + with: + target: staging diff --git a/.github/workflows/deploy-cloudflare.yml b/.github/workflows/deploy-cloudflare.yml index 9714bb8c..0f54ce03 100644 --- a/.github/workflows/deploy-cloudflare.yml +++ b/.github/workflows/deploy-cloudflare.yml @@ -1,8 +1,7 @@ name: Deploy to Cloudflare Workers -# Client is a full Next.js app (API routes, server actions, server-side -# Pocketbase admin auth) -- not a static SPA like Atlas, so this deploys via -# the OpenNext Cloudflare adapter (Workers + Assets), not cloudflare/pages-action. +# SSC-31: static Next.js export on Workers Static Assets plus the thin app +# Worker (workers/app) for /api/*. See docs/runbooks/cloudflare-cutover.md. # # Frequent main commits are expected. Cancel superseded runs so only the latest # code publishes, and never `wrangler secret put` here (that publishes an extra @@ -17,13 +16,14 @@ on: - "yarn.lock" - "next.config.*" - "wrangler.jsonc" - - "open-next.config.*" + - "workers/**" + - "scripts/cloudflare/**" - "tsconfig.json" - "postcss.config.*" - "tailwind.config.*" - "components.json" - ".github/workflows/deploy-cloudflare.yml" - - ".github/workflows/open-next-worker.yml" + - ".github/workflows/cloudflare-app-worker.yml" workflow_dispatch: {} concurrency: @@ -32,7 +32,7 @@ concurrency: jobs: deploy: - uses: ./.github/workflows/open-next-worker.yml + uses: ./.github/workflows/cloudflare-app-worker.yml secrets: inherit with: wrangler_command: deploy diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml deleted file mode 100644 index 935f6aeb..00000000 --- a/.github/workflows/deploy-vercel.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: Deploy Star Sailors to Vercel - -on: - push: - branches: [main, staging] - paths: - - "src/**" - - "public/**" - - "package.json" - - "yarn.lock" - - "next.config.*" - - "tsconfig.json" - - "postcss.config.*" - - "tailwind.config.*" - - ".github/workflows/deploy-vercel.yml" - workflow_dispatch: {} - -concurrency: - group: vercel-${{ github.ref_name }} - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "22" - cache: yarn - - - name: Validate Vercel deployment credentials - run: | - for name in VERCEL_ORG_ID VERCEL_PROJECT_ID VERCEL_TOKEN; do - if [ -z "${!name}" ]; then - echo "::error::Missing required repository secret: $name" - exit 1 - fi - done - - - name: Validate staging cutover credentials - if: github.ref_name == 'staging' - run: | - for name in CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN; do - if [ -z "${!name}" ]; then - echo "::error::Missing required repository secret: $name" - exit 1 - fi - done - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Pull production configuration - if: github.ref_name == 'main' - run: npx --yes vercel@latest pull --yes --environment=production --scope "$VERCEL_ORG_ID" - - - name: Pull staging preview configuration - if: github.ref_name == 'staging' - run: npx --yes vercel@latest pull --yes --environment=preview --git-branch=staging --scope "$VERCEL_ORG_ID" - - - name: Build Vercel output - run: npx --yes vercel@latest build --scope "$VERCEL_ORG_ID" - - - name: Deploy production - if: github.ref_name == 'main' - run: | - deployment_url="$(npx --yes vercel@latest deploy --prebuilt --prod --scope "$VERCEL_ORG_ID")" - echo "deployment_url=$deployment_url" >> "$GITHUB_STEP_SUMMARY" - - - name: Deploy staging preview and assign its stable alias - if: github.ref_name == 'staging' - run: | - deployment_url="$(npx --yes vercel@latest deploy --prebuilt --scope "$VERCEL_ORG_ID")" - domain=staging.starsailors.space - - # Vercel must own the domain before the DNS move. If it is already - # registered, inspect succeeds and this remains a no-op. - if ! npx --yes vercel@latest domains inspect "$domain" --scope "$VERCEL_ORG_ID" >/dev/null 2>&1; then - domain_add_exit=0 - npx --yes vercel@latest domains add "$domain" "$VERCEL_PROJECT_ID" --scope "$VERCEL_ORG_ID" || domain_add_exit=$? - # Vercel returns 4 after successfully registering a domain whose - # DNS has not yet moved. The following Cloudflare step is what - # resolves that expected pending-verification state. - if [ "$domain_add_exit" -ne 0 ] && [ "$domain_add_exit" -ne 4 ]; then - exit "$domain_add_exit" - fi - fi - - domains="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains?hostname=$domain")" - domain_id="$(echo "$domains" | jq -r --arg domain "$domain" '.result[] | select(.hostname == $domain and .service == "starsailors-client-staging") | .id' | head -1)" - if [ -n "$domain_id" ] && [ "$domain_id" != null ]; then - curl --fail-with-body --silent --show-error -X DELETE \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains/$domain_id" | jq -e '.success == true' - fi - - zone_id="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones?name=starsailors.space" | jq -r '.result[0].id')" - test -n "$zone_id" && test "$zone_id" != null - record_id="$(curl --fail-with-body --silent --show-error \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records?type=CNAME&name=$domain" | jq -r '.result[0].id // empty')" - payload="$(jq -nc --arg name "$domain" '{type:"CNAME",name:$name,content:"cname.vercel-dns.com",ttl:1,proxied:false}')" - if [ -n "$record_id" ]; then - curl --fail-with-body --silent --show-error -X PUT \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records/$record_id" | jq -e '.success == true' - else - curl --fail-with-body --silent --show-error -X POST \ - -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ - -H "Content-Type: application/json" \ - --data "$payload" \ - "https://api.cloudflare.com/client/v4/zones/$zone_id/dns_records" | jq -e '.success == true' - fi - - npx --yes vercel@latest alias set "$deployment_url" staging.starsailors.space --scope "$VERCEL_ORG_ID" - echo "deployment_url=$deployment_url" >> "$GITHUB_STEP_SUMMARY" - echo "staging_url=https://staging.starsailors.space" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/measure-cloudflare-budget.yml b/.github/workflows/measure-cloudflare-budget.yml new file mode 100644 index 00000000..703d6b32 --- /dev/null +++ b/.github/workflows/measure-cloudflare-budget.yml @@ -0,0 +1,79 @@ +name: Measure Cloudflare Free-plan budget (SSC-38) + +# Manual. Sends the representative anonymous, authenticated read, mutation and +# refresh requests to a deployed Worker while `wrangler tail` records each +# invocation's CPU time and outcome. Writes the table to the job summary and +# uploads the raw JSON. Fails when any route errors, hits Error 1102, uses more +# than 10 ms CPU or more than 50 subrequests. +# +# Authenticated flows need `session_id`: an active Clerk session of a test +# account (in the browser console on the target host: `Clerk.session.id`). +# The job mints a fresh session JWT per request with CLERK_SECRET_KEY. +on: + # workflow_call lets the staging deploy run the measurement after each + # deploy, since workflow_dispatch only works for files on the default branch. + workflow_call: + inputs: + target: + type: string + default: staging + session_id: + type: string + required: false + samples: + type: string + default: "5" + workflow_dispatch: + inputs: + target: + description: Which deployment to measure + type: choice + options: + - staging + - production + default: staging + session_id: + description: Clerk session id of a test account (optional; authenticated flows are skipped without it) + type: string + required: false + samples: + description: Requests per route + type: string + default: "5" + +permissions: + contents: read + +jobs: + measure: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Measure + run: | + node scripts/cloudflare/measure-budget.mjs \ + --base "${{ inputs.target == 'staging' && 'https://staging.starsailors.space' || 'https://starsailors.space' }}" \ + ${{ inputs.target == 'staging' && '--env staging' || '' }} \ + --tail --samples "${{ inputs.samples }}" --out budget.json + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + BUDGET_SESSION_ID: ${{ inputs.session_id }} + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: budget-${{ inputs.target }} + path: budget.json + if-no-files-found: ignore diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index 4fc026c1..ebd8cce9 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -1,6 +1,6 @@ name: Staging playtest lifecycle (SSC-33) -# Runs on pushes to ssc-33-playtest-ci or manually. Deploys this ref's API Worker (workers/api) to staging, enables the guarded +# Runs on pushes to ssc-33-playtest-ci or manually. Enables the guarded # playtest endpoint for the duration of the run, plays the browser flow with a # throwaway Clerk account, and always removes the endpoint secrets afterwards. # Staging shares the production Clerk/PocketBase instance, so the endpoint must @@ -44,10 +44,10 @@ jobs: - name: Test the Worker run: npx vitest run workers/api - - name: Deploy the staging API Worker with the playtest endpoint enabled - working-directory: workers/api + # The API is part of the staging app Worker (root wrangler.jsonc). The + # staging deploy job already shipped this ref; only the secrets change here. + - name: Enable the playtest endpoint on the staging Worker run: | - npx wrangler deploy --env staging --var CLERK_ISSUER:${{ vars.CLERK_ISSUER }} --var CLERK_AUTHORIZED_PARTIES:${{ vars.CLERK_AUTHORIZED_PARTIES_STAGING }} for name in POCKETBASE_URL POCKETBASE_ADMIN_EMAIL POCKETBASE_ADMIN_PASSWORD CLERK_SECRET_KEY STAGING_PLAYTEST_AUTH_SECRET; do printf '%s' "${!name}" | npx wrangler secret put "$name" --env staging done @@ -62,8 +62,8 @@ jobs: - name: Disable the playtest endpoint if: always() run: | - cd workers/api - for name in STAGING_PLAYTEST_AUTH_ENABLED STAGING_PLAYTEST_AUTH_SECRET CLERK_SECRET_KEY; do + # CLERK_SECRET_KEY stays: it is a real runtime secret of the app Worker. + for name in STAGING_PLAYTEST_AUTH_ENABLED STAGING_PLAYTEST_AUTH_SECRET; do npx wrangler secret delete "$name" --env staging < <(yes) done diff --git a/.github/workflows/sync-cloudflare-secrets.yml b/.github/workflows/sync-cloudflare-secrets.yml index 939bc5a3..23573ada 100644 --- a/.github/workflows/sync-cloudflare-secrets.yml +++ b/.github/workflows/sync-cloudflare-secrets.yml @@ -41,6 +41,12 @@ jobs: if [ -n "$CLERK_WEBHOOK_SIGNING_SECRET" ]; then printf '%s' "$CLERK_WEBHOOK_SIGNING_SECRET" | npx wrangler secret put CLERK_WEBHOOK_SIGNING_SECRET fi + # SSC-39: queued push notifications and the operator job endpoints. + for name in VAPID_PRIVATE_KEY INTERNAL_JOBS_TOKEN; do + if [ -n "${!name}" ]; then + printf '%s' "${!name}" | npx wrangler secret put "$name" + fi + done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -49,6 +55,8 @@ jobs: POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} CLERK_WEBHOOK_SIGNING_SECRET: ${{ secrets.CLERK_WEBHOOK_SIGNING_SECRET }} + VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} + INTERNAL_JOBS_TOKEN: ${{ secrets.INTERNAL_JOBS_TOKEN }} - name: Put staging secrets if: inputs.target == 'staging' || inputs.target == 'both' @@ -60,6 +68,12 @@ jobs: if [ -n "$CLERK_WEBHOOK_SIGNING_SECRET" ]; then printf '%s' "$CLERK_WEBHOOK_SIGNING_SECRET" | npx wrangler secret put CLERK_WEBHOOK_SIGNING_SECRET --env staging fi + # SSC-39: queued push notifications and the operator job endpoints. + for name in VAPID_PRIVATE_KEY INTERNAL_JOBS_TOKEN; do + if [ -n "${!name}" ]; then + printf '%s' "${!name}" | npx wrangler secret put "$name" --env staging + fi + done env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} @@ -68,3 +82,5 @@ jobs: POCKETBASE_ADMIN_PASSWORD: ${{ secrets.POCKETBASE_ADMIN_PASSWORD }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} CLERK_WEBHOOK_SIGNING_SECRET: ${{ secrets.CLERK_WEBHOOK_SIGNING_SECRET }} + VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }} + INTERNAL_JOBS_TOKEN: ${{ secrets.INTERNAL_JOBS_TOKEN }} diff --git a/.gitignore b/.gitignore index 36cc9cb5..ca107503 100644 --- a/.gitignore +++ b/.gitignore @@ -66,8 +66,9 @@ yarn-error.log* # vercel .vercel -# cloudflare / opennextjs +# cloudflare static export (scripts/cloudflare/build-static.mjs) .open-next/ +.cf-export-stash/ .wrangler/ cloudflare-env.d.ts .dev.vars diff --git a/Makefile b/Makefile index 90dd4eae..16670bf1 100644 --- a/Makefile +++ b/Makefile @@ -114,4 +114,4 @@ sync-surveys: @export $$(cat .env.posthog | xargs) && node --experimental-strip-types scripts/sync-posthog-surveys.ts deploy-test: - docker-compose -f ops/compose/compose.yml build && yarn build && vercel + yarn cf:preview diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index e79cdbca..a6f5418b 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -48,6 +48,14 @@ if (enabled) { cy.request({ url: `/api/v1/classifications?author=${encodeURIComponent(userId as string)}&limit=5`, headers: auth }) .its('status') .should('eq', 200) + + // SSC-38: repeat the signed-in reads so the per-route CPU measurement has warm-isolate + // samples, not just the one cold call. Statuses are not asserted here. + for (let i = 0; i < 5; i++) { + for (const path of ['/api/v1/research/summary', '/api/gameplay/hub/bootstrap', '/api/gameplay/active-planet']) { + cy.request({ url: path, headers: auth, failOnStatusCode: false }) + } + } }) cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') diff --git a/docs/runbooks/cloudflare-cutover.md b/docs/runbooks/cloudflare-cutover.md new file mode 100644 index 00000000..c8db5ca3 --- /dev/null +++ b/docs/runbooks/cloudflare-cutover.md @@ -0,0 +1,292 @@ +# Cloudflare-native hosting: cutover, rollback and smoke tests + +Tickets: SSC-31 (architecture), SSC-37 (precomputed public data), SSC-39 +(background jobs), SSC-38 (Free-plan budget and cutover gate). + +## Architecture + +Workers Free allows 10 ms of CPU per request. OpenNext ran the Next.js server +(SSR, Clerk middleware and route handlers) on every request, and the Worker +returned Error 1102. Production no longer runs a Next.js server at all: + +| Request | Served by | Worker invoked? | +| --- | --- | --- | +| Every page (`/`, `/auth`, `/game`, …) and `/_next/*`, `/assets/*` | Workers Static Assets, from the static export in `out/` | No | +| Dynamic pages (`/posts/123`, `/structures/balloon/clouds/an-1/one`, …) | Worker returns the page's exported placeholder HTML (`/posts/__static__`); the page reads its params from the URL (`useRouteParams`) | Yes, no React render and no subrequests | +| `/api/v1/*` | SSC-35 JSON API (`workers/api`), mounted in the app Worker | Yes | +| `/api/*` | The existing `src/app/api/**/route.ts` handlers, bundled into the Worker with small shims for `next/server`, `next/cache` and `@clerk/nextjs/server` (`workers/app/src/shims`) | Yes | +| `/api/actions/[name]` | The former server actions (`src/server/actions`), now called with `fetch` from `src/app/actions/*` | Yes | +| `/ingest/*` | PostHog reverse proxy (formerly a `next.config` rewrite) | Yes, 1 subrequest | +| `/api/public/*`, `/api/gameplay/leaderboards/sunspots`, `/api/community-activity` | Precomputed snapshots read from Workers KV (SSC-37) | Yes, no PocketBase reads | +| Anything else | `404.html`, status 404 | Yes | +| Cron `*/5 * * * *` | Recomputes one public snapshot per tick (rotating) into KV | Yes (cron) | +| Cron `0 17 * * *` (production only) | Starts the daily discovery-reminder fan-out | Yes (cron) | +| Queue `starsailors-jobs` (+ `-dlq`) | Push notifications, server-side PostHog events, fan-out (SSC-39) | Yes (queue consumer) | + +Identity comes from Clerk's session JWT, either the `__session` cookie or an +`Authorization: Bearer` header. The Worker verifies it locally against the +Clerk JWKS, which is cached per isolate, so there are no Clerk API calls per +request. A mutation authenticated by cookie must carry an allowed `Origin`, as +CSRF protection. Middleware redirects moved to the browser: `GameShell` sends +signed-out visitors to `/auth`, and `LandingSignedInRedirect` sends signed-in +visitors to `/game`. + +`next dev` / `yarn build && yarn start` still run the full Next.js server with +middleware and route handlers, for local development and the Cypress suite. + +### Build and deploy + +- `yarn cf:build` → `scripts/cloudflare/build-static.mjs`: checks the generated + route tables, type-checks, then runs `next build` with `NEXT_STATIC_EXPORT=1` + while `src/app/api` and `src/middleware.ts` are set aside (always restored). +- `yarn cf:routes` regenerates `workers/app/src/generated/*` after adding or + removing an API route or a dynamic page. CI fails when they are stale. +- `yarn cf:preview` builds and serves the result with `wrangler dev` on + `http://localhost:8787`. Put the Worker secrets in `.dev.vars` (below). +- CI (`ci.yml` → "Cloudflare static build") runs the export and a Worker bundle + dry-run on every PR. A new server action, `force-dynamic` page, or dynamic + route without `generateStaticParams` fails there instead of at deploy time. +- Deploys: pushing to `main` runs `deploy-cloudflare.yml` (production), and + pushing to `staging` runs `deploy-cloudflare-staging.yml`. Both call + `cloudflare-app-worker.yml`. Nothing deploys to Vercel. + +New dynamic pages must export +`generateStaticParams() { return placeholderParams(...) }` from +`@/src/lib/routing/staticParams` and read params with +`useRouteParams("/path/[param]")`, not `useParams()`, which returns the +placeholder in the export. + +## Public snapshots (SSC-37) + +Shared, non-user-specific data is computed on the cron trigger, never on a +request, and published to the `PUBLIC_DATA` KV namespace as one bundle +(`public-snapshots:v1`). Code: `src/server/snapshots/`. + +| Snapshot | Contents | Read by | +| --- | --- | --- | +| `landing-stats` | Total classifications, last 24 h count, active sailors (24 h), active projects and per-project counts (7 d) | Landing page (`/api/public/snapshots/landing-stats`) | +| `sunspot-leaderboard` | Top 10 probe launchers and sunspot classifiers | `/leaderboards/sunspots` | +| `community-activity` | Latest 24 classifications of the last day (user ids stripped on read) | Garden launches, hub vehicles | +| `hub-top-profiles` | Top 5 profiles by classification points | Hub leaderboard (`/api/gameplay/page-data`; the caller's own rank is still read live) | + +- **Versioning.** The KV key carries the bundle version. Each section also + stores its own `schema`. Bump a section's `schema` in `store.ts` when its + shape changes; older stored data then reads as `missing` until the next + refresh. +- **Fresh / stale / missing.** Each section records `generatedAt`, + `lastAttemptAt` and `lastError`. Data older than 30 minutes (three missed + refreshes) is served but marked `stale`. Never-generated data is `missing` + (the generic endpoint returns 503; community activity returns `[]`). + Responses carry `x-snapshot-status` and `x-snapshot-generated-at`. The + landing page and leaderboard show "updated N min ago" or an out-of-date + notice. +- **Refresh failure.** A failing producer keeps its previous data and records + the error. The other snapshots still update. +- **Health.** `GET /api/public/status` lists each snapshot's status, age and + last error. `healthy: false` means at least one is not fresh. +- **Budget.** Each refresh is one KV write, so the 10-minute cron uses 144 + writes/day per environment (Free: 1,000/day per account, shared with job + receipts). Reads are memoised per isolate for 30 s. +- **Refresh now** (for example straight after the first deploy): + `curl -X POST -H "authorization: Bearer $INTERNAL_JOBS_TOKEN" https://starsailors.space/api/internal/snapshots/refresh` +- **Local dev.** Without Cloudflare (`next dev`, Cypress), snapshots are + built in memory on first read. + +## Background jobs (SSC-39) + +Non-critical work leaves the request path through the `JOBS` queue. Code: +`src/server/jobs/`. The request returns once the queue has accepted the +message (for example `POST /api/notify-my-discoveries` → 202). + +| Job | Queued by | Idempotency | +| --- | --- | --- | +| `analytics.capture` | `captureServerEvent()` (classification submitted) | PostHog dedupes on the job id (`uuid`) | +| `push.user` | `/api/notify-my-discoveries` (the signed-in user only), broadcasts, retries | KV receipt per job id; retries target only failed endpoints | +| `push.broadcast` | `/api/send-test-notification` (operator token) | Child ids derive from the run id | +| `reminders.discoveries` → `reminders.discovery-user` | Daily cron, or `/api/auto-notify-discoveries` (operator token) | Per-day ids (`reminder::`) plus a receipt: one reminder per user per day | + +- **Push delivery.** Web Push (VAPID + aes128gcm) runs on WebCrypto + (`webpush.ts`), which is tested against the RFC 8291 vector. + - A 404 or 410 deletes that subscription. + - A 429, a 5xx or a network error re-queues only the failing endpoints with + backoff (1, 2, 4, 8 min). + - Other 4xx responses are logged and not retried. + - Duplicate deliveries of a reminder share a `Topic`, so the push service + collapses any that are undelivered. +- **Retries.** A retryable failure calls `message.retry()` with exponential + backoff. On attempt 5, or on a permanent error (for example missing VAPID + keys) or an invalid message, the job is **parked**: stored in KV at + `jobs:dead:` with the error, kept for 14 days, and logged as + `[jobs] parked …`. The dead-letter queue catches anything that escapes + (such as a crashed batch) and parks it the same way. +- **Recover.** Fix the cause, then: + - `GET /api/internal/jobs` lists parked jobs. + - `POST /api/internal/jobs` with `{"action":"replay"}` (optionally + `"ids":[…]`) re-queues them under their original ids. + + Both need `Authorization: Bearer $INTERNAL_JOBS_TOKEN`. Receipts stop a job + whose push already went out from sending again. +- **Fallback.** If the queue is unbound or refuses a send (for example the + Free plan's daily limit), jobs run after the response via `ctx.waitUntil`. + A failure there is parked too. +- **Budget.** + - A batch holds at most 4 jobs, and each user gets at most 5 devices per + job, which keeps a batch under 50 subrequests. + - Queues Free allows 10,000 operations/day, about 3 per message. + - Cron triggers: 2 in production and 1 in staging, out of 5 per account. +- **Security.** `/api/notify-my-discoveries` used to push to any `userId` in + the body without authentication. `/api/send-test-notification` and + `/api/auto-notify-discoveries` were open to anyone. They now require a + session or the operator token. + +## Configuration + +| Name | Kind | Where | Notes | +| --- | --- | --- | --- | +| `POCKETBASE_URL`, `POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD` | Worker secret | "Sync Cloudflare Worker secrets" workflow | Unchanged | +| `CLERK_SECRET_KEY` | Worker secret | same | Only routes that call the Clerk Backend API use it (guest conversion, playtests, hub bootstrap email fallback) | +| `CLERK_WEBHOOK_SIGNING_SECRET` | Worker secret | same | `/api/webhooks/clerk` | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | build env + Worker var | GitHub secret; the deploy passes it with `--var` | The Worker derives the Clerk issuer/JWKS URL from it | +| `CLERK_ISSUER`, `CLERK_JWKS_URL` | Worker var (optional) | `--var` | Override the derived issuer | +| `CLERK_AUTHORIZED_PARTIES` | Worker var (optional) | `--var` | Comma-separated origins. Defaults to the request's own origin, which is right for `starsailors.space`, `www.starsailors.space` and `staging.starsailors.space` | +| `VAPID_PUBLIC_KEY` → `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | build env + Worker var | GitHub secret `VAPID_PUBLIC_KEY`; the deploy passes it with `--var` | Browser subscription prompt and VAPID signing | +| `VAPID_PRIVATE_KEY` | Worker secret (optional) | "Sync Cloudflare Worker secrets" | Without it, push jobs are parked with "VAPID … not configured" (replay after adding it) | +| `INTERNAL_JOBS_TOKEN` | Worker secret (optional) | same | Enables `/api/internal/*`, `/api/send-test-notification` and `/api/auto-notify-discoveries`; they answer 503 without it | +| `PUBLIC_DATA` (KV), `JOBS` (queue) | Bindings | `wrangler.jsonc`; created by `scripts/cloudflare/ensure-resources.mjs` in the deploy workflow | Namespace `starsailors-public-data[-staging]`, queues `starsailors-jobs[-staging]` and `-dlq` | + +The deploy workflow's "Confirm Worker secrets already exist" step fails fast if +a secret is missing. Never `wrangler secret put` on every deploy: each put +publishes a Worker version (the 2026-09-18 1102 storm). + +`.dev.vars` for `yarn cf:preview`: + +``` +POCKETBASE_URL=http://127.0.0.1:8090 +POCKETBASE_ADMIN_EMAIL=... +POCKETBASE_ADMIN_PASSWORD=... +CLERK_SECRET_KEY=sk_test_... +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... +``` + +## Cutover + +Preconditions: + +1. The Cloudflare API token in `CLOUDFLARE_API_TOKEN` has **Account → Workers + Scripts: Edit, Workers KV Storage: Edit, Queues: Edit** and **Zone + (starsailors.space) → Workers Routes: Edit, DNS: Edit**. The token used on 2026-09-21 returned 403 for Workers domains and + zones, so it could not attach `staging.starsailors.space`. +2. Clerk production instance → Domains: `starsailors.space` is the application + domain, and `staging.starsailors.space` is allowed as a satellite/subdomain + origin. The Frontend API proxy `clerk.starsailors.space` only accepts + `*.starsailors.space` origins (see the `wrangler.jsonc` staging note). +3. Clerk → Webhooks still points at `https://starsailors.space/api/webhooks/clerk`. +4. Any leftover Vercel project/alias for `staging.starsailors.space` is + removed, and the DNS record is not a CNAME to Vercel. Custom domains need + the hostname to have no conflicting record; `wrangler deploy` creates it. + +Steps: + +1. Merge to `staging`, or run "Deploy staging to Cloudflare Workers" manually. + Confirm the run is green. On its first run, "Ensure KV namespace and queues" + creates `starsailors-public-data-staging` and the staging queues. Once the + deploy is green, check `/api/public/status`: the snapshots become fresh + within 10 minutes, or immediately after a + `POST /api/internal/snapshots/refresh`. +2. Smoke-test staging (next section), including sign-in with a test account. +3. Run **Measure Cloudflare Free-plan budget** with `target: staging` and the + test account's `session_id`. It must pass; attach the job summary to SSC-38. +4. Merge to `main`, or run "Deploy to Cloudflare Workers" manually. Note the + previous version id first: `npx wrangler deployments list`. +5. Smoke-test production and run the budget workflow with `target: production`. +6. Delete the standalone SSC-35 Worker, now served by the app Worker, if it + was ever deployed: `npx wrangler delete --name starsailors-api` and + `--name starsailors-api-staging`. Its `/api/v1/*` routes would otherwise + keep shadowing the app Worker. + +## Smoke test + +Run against `https://staging.starsailors.space` or `https://starsailors.space`. + +```sh +node scripts/cloudflare/measure-budget.mjs --base https://staging.starsailors.space --samples 3 +``` + +The script checks the anonymous rows and, with `BUDGET_SESSION_TOKEN` or +`CLERK_SECRET_KEY` + `BUDGET_SESSION_ID`, the authenticated ones. Then, in a +browser: + +- `/` renders the landing page; signed in, it moves to `/game?from=landing`. +- `/auth` → sign in → `/game` loads the garden hub (`/api/gameplay/hub/bootstrap` 200). +- Reload `/game`: no Error 1102, and the hub state persists. +- Open a post (`/posts/`) and a structure mission page directly by URL, + then navigate between them client-side. +- Submit one classification or comment, which exercises `/api/actions/*`. +- PostHog: the network tab shows `/ingest/*` 200s. +- `/api/public/status` reports `healthy: true`. The landing stats show + "Updated N min ago". +- Deploy a telescope: the confirmation appears immediately. The Worker logs + then show `{"invocation":"queue starsailors-jobs",…,"done":1}`, and + `GET /api/internal/jobs` shows no parked jobs. +- `/api/webhooks/clerk`: Clerk dashboard → Webhooks → send a test event → 200. + +## Rollback + +Worker versions include their static assets, so a rollback restores the +previous pages and API together: + +```sh +npx wrangler deployments list # find the last good version +npx wrangler rollback # production +npx wrangler rollback --env staging +``` + +To roll back the code instead, revert the SSC-31 commit on `main`. The deploy +workflow then rebuilds the previous version. Before SSC-31 that was OpenNext, +which hits Error 1102 on Workers Free, so a code revert is only useful on +Workers Paid, with `limits.cpu_ms` restored in `wrangler.jsonc`. + +Secrets and DNS are not touched by either path. + +## Budget evidence (SSC-38) + +`x-ssc-subrequests` on every Worker response is the number of outbound +fetches that invocation made. CPU time and outcome come from `wrangler tail` +in the budget workflow. + +Local run, 2026-09-25: `wrangler dev` (workerd) with a stub PocketBase and a +locally signed Clerk session JWT, 3 samples per route. workerd does not report +CPU time; that column comes from the deployed workflow run. + +| Flow | Route | Status | Size | Subrequests max | +| --- | --- | --- | --- | --- | +| anonymous | `GET /` | 200 | 47.5 kB | static asset | +| anonymous | `GET /auth` | 200 | 13.8 kB | static asset | +| anonymous | `GET /game` | 200 | 13.1 kB | static asset | +| anonymous | `GET /posts/1` | 200 | 18.0 kB | 0 | +| anonymous | `GET /api/auth/session` | 401 | 23 B | 0 | +| anonymous | `GET /budget-missing-page` | 404 | 12.1 kB | 0 | +| authenticated read | `GET /api/auth/session` | 200 | 22 B | 0 | +| authenticated read | `GET /api/v1/me` | 200 | 39 B | 1 (3 on a cold isolate: JWKS + PocketBase auth + read) | +| authenticated read | `GET /api/gameplay/hub/bootstrap` | 200 | 505 B | 6 | +| authenticated read | `GET /api/gameplay/profile/me` | 200 | 89 B | 1 | +| mutation | `POST /api/gameplay/profile/ensure` | 200 | 16 B | 2 | +| mutation | `POST /api/actions/getCurrentProfileAction` | 200 | 34 B | 3 | +| refresh | `GET /game` + `GET /api/gameplay/hub/bootstrap` | 200 | 13.1 kB + 505 B | 6 | + +Background invocations, from the same local setup (2026-09-25, `wrangler dev +--test-scheduled` with local KV and Queues, and a mock push service that +decrypts each payload and verifies its VAPID signature): + +| Invocation | Result | Subrequests | +| --- | --- | --- | +| Cron `*/5 * * * *` | 1 of 4 snapshots per tick, one KV write | see staging measurements | +| `GET /api/public/snapshots/landing-stats` (after the cron) | 200 `fresh` (503 `missing` before it) | 0 | +| `POST /api/notify-my-discoveries` | 202 in 12 ms | 0 warm (1 when it had to fetch the JWKS) | +| Queue: `push.user`, 3 devices (201 / 410 / 503) | 1 sent, 1 subscription deleted, 1 retried after 60 s | 6 | +| Cron `0 17 * * *` → `reminders.discoveries` → `reminders.discovery-user` | Reminder named the one unclassified discovery | 0 → 1 → 6 | + +Error rate 0% on every route. The Worker bundle is 1.76 MB (326 kB gzip), +within the 3 MB Free limit. After SSC-37/39 it is 1.38 MB (256 kB gzip), because +`web-push` is no longer bundled. Production and staging CPU / cold-start figures: +_pending the first "Measure Cloudflare Free-plan budget" run._ diff --git a/next.config.mjs b/next.config.mjs index 727f7929..2b17248e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; + const configuredPosthogRegion = ( process.env.posthog_region || process.env.POSTHOG_REGION || @@ -11,9 +13,54 @@ const posthogAssetsHost = useEUPosthog ? "https://eu-assets.i.posthog.com" : "https://us-assets.i.posthog.com"; +async function headers() { + return [ + { + source: "/service-worker.js", + headers: [ + { key: "Cache-Control", value: "public, max-age=0, must-revalidate" }, + { key: "Service-Worker-Allowed", value: "/" }, + { key: "Content-Type", value: "application/javascript; charset=utf-8" }, + ], + }, + ]; +} + +async function rewrites() { + return [ + { + source: "/citizen/:path*", + destination: + process.env.NODE_ENV === "development" + ? "http://flask:5001/:path*" + : "/citizen/:path*", + }, + // Only proxy specific API routes to external service, not all /api/* + // Most /api/gameplay/* routes are handled by Next.js itself + { + source: "/api/external/:path*", + destination: + process.env.NODE_ENV === "development" + ? "http://127.0.0.1:5328/api/:path*" + : "/api/external/:path*", + }, + { source: "/ingest/static/:path*", destination: `${posthogAssetsHost}/static/:path*` }, + { source: "/ingest/:path*", destination: `${posthogIngestHost}/:path*` }, + { source: "/ingest/flags", destination: `${posthogIngestHost}/flags` }, + ]; +} + +// SSC-31: `yarn cf:build` (scripts/cloudflare/build-static.mjs) sets +// NEXT_STATIC_EXPORT=1 and exports every page to static HTML (`out/`) for +// Workers Static Assets. It sets src/app/api and src/middleware.ts aside for +// the export: the Worker (workers/app) serves those routes, and the +// middleware's redirects happen in the browser. `next dev` keeps both. +const staticExport = process.env.NEXT_STATIC_EXPORT === "1"; + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + ...(staticExport ? { output: "export" } : {}), experimental: { optimizePackageImports: ["lucide-react", "date-fns"], }, @@ -23,43 +70,38 @@ const nextConfig = { eslint: { ignoreDuringBuilds: true, }, - skipTrailingSlashRedirect: true, - async headers() { - return [ - { - source: "/service-worker.js", - headers: [ - { key: "Cache-Control", value: "public, max-age=0, must-revalidate" }, - { key: "Service-Worker-Allowed", value: "/" }, - { key: "Content-Type", value: "application/javascript; charset=utf-8" }, - ], - }, - ]; + // The export build runs with src/app/api set aside, which would break the + // Worker's imports of it; build-static.mjs type-checks the whole tree first. + typescript: { + ignoreBuildErrors: staticExport, }, - async rewrites() { - return [ - { - source: "/citizen/:path*", - destination: - process.env.NODE_ENV === "development" - ? "http://flask:5001/:path*" - : "/citizen/:path*", - }, - // Only proxy specific API routes to external service, not all /api/* - // Most /api/gameplay/* routes are handled by Next.js itself - { - source: "/api/external/:path*", - destination: - process.env.NODE_ENV === "development" - ? "http://127.0.0.1:5328/api/:path*" - : "/api/external/:path*", - }, - { source: "/ingest/static/:path*", destination: `${posthogAssetsHost}/static/:path*` }, - { source: "/ingest/:path*", destination: `${posthogIngestHost}/:path*` }, - { source: "/ingest/flags", destination: `${posthogIngestHost}/flags` }, - ]; - }, - webpack: (config, { isServer }) => { + skipTrailingSlashRedirect: true, + // Static exports cannot use headers()/rewrites(); in production the same + // rules live in public/_headers and workers/app/src/index.ts (/ingest). + ...(staticExport ? {} : { headers, rewrites }), + webpack: (config, { isServer, webpack }) => { + if (staticExport) { + // @clerk/nextjs ships two internal server-action modules; a static + // export rejects any server action, so swap in client no-ops. + const stubs = { + "server-actions.js": "clerk-server-actions.static.js", + "keyless-actions.js": "clerk-keyless-actions.static.js", + }; + config.plugins.push( + new webpack.NormalModuleReplacementPlugin( + /@clerk[\\/]nextjs[\\/]dist[\\/]esm[\\/]app-router[\\/](server|keyless)-actions\.js$/, + (resource) => { + const file = resource.createData?.resource ?? resource.resource ?? ""; + const stub = stubs[file.split(/[\\/]/).pop()]; + if (!stub) return; + const target = fileURLToPath(new URL(`./src/lib/cloudflare/${stub}`, import.meta.url)); + if (resource.createData) resource.createData.resource = target; + else resource.resource = target; + }, + ), + ); + } + config.resolve = { ...config.resolve, alias: { diff --git a/open-next.config.ts b/open-next.config.ts deleted file mode 100644 index ffd98878..00000000 --- a/open-next.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { defineCloudflareConfig } from "@opennextjs/cloudflare"; - -export default defineCloudflareConfig(); diff --git a/package.json b/package.json index ece78382..4a307a6c 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,10 @@ "docker:test:all": "scripts/tests/run-docker-suite.sh all", "docker:test:clean": "docker-compose -f ops/compose/docker-compose.test.yml down --volumes --remove-orphans", "ssr:check": "scripts/check-ssr-data-boundaries.sh", - "cf:preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", - "cf:deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", + "cf:routes": "node scripts/cloudflare/generate-routes.mjs", + "cf:build": "node scripts/cloudflare/build-static.mjs", + "cf:preview": "yarn cf:build && wrangler dev --local-upstream localhost:8787", + "cf:deploy": "yarn cf:build && wrangler deploy", "cf:typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { @@ -110,7 +112,6 @@ }, "devDependencies": { "@clerk/backend": "^3.10.0", - "@opennextjs/cloudflare": "^1.20.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", diff --git a/public/_headers b/public/_headers new file mode 100644 index 00000000..70ebdfd1 --- /dev/null +++ b/public/_headers @@ -0,0 +1,9 @@ +# Workers Static Assets response headers (SSC-31). Replaces next.config headers(), +# which a static export cannot use. +/service-worker.js + Cache-Control: public, max-age=0, must-revalidate + Service-Worker-Allowed: / + Content-Type: application/javascript; charset=utf-8 + +/_next/static/* + Cache-Control: public, max-age=31536000, immutable diff --git a/scripts/cloudflare/build-static.mjs b/scripts/cloudflare/build-static.mjs new file mode 100644 index 00000000..8d324b49 --- /dev/null +++ b/scripts/cloudflare/build-static.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Builds the Cloudflare static shell (SSC-31): `next build` with +// NEXT_STATIC_EXPORT=1 writes every page to out/ for Workers Static Assets. +// +// A static export cannot contain route handlers or middleware, so they are +// moved into .cf-export-stash/ for the build and always moved back, including +// after a failed or interrupted build. A stash left by a killed run is +// restored first. +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(fileURLToPath(import.meta.url), "..", "..", ".."); +const stash = join(root, ".cf-export-stash"); +const moves = [ + ["src/app/api", "api"], + ["src/middleware.ts", "middleware.ts"], +]; + +function restore() { + if (!existsSync(stash)) return; + for (const [source, name] of moves) { + const parked = join(stash, name); + if (!existsSync(parked)) continue; + if (existsSync(join(root, source))) { + throw new Error(`Both ${source} and .cf-export-stash/${name} exist; resolve by hand.`); + } + renameSync(parked, join(root, source)); + } + if (readdirSync(stash).length === 0) rmSync(stash, { recursive: true }); +} + +function run(command, args, env = {}) { + const result = spawnSync(command, args, { cwd: root, stdio: "inherit", env: { ...process.env, ...env } }); + return result.status ?? 1; +} + +restore(); + +let status = run("node", ["scripts/cloudflare/generate-routes.mjs", "--check"]); +if (status !== 0) process.exit(status); +// Type-check with the route handlers in place (the export build skips it). +status = run("npx", ["tsc", "--noEmit", "-p", "."]); +if (status !== 0) process.exit(status); + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + restore(); + process.exit(130); + }); +} + +try { + mkdirSync(stash, { recursive: true }); + for (const [source, name] of moves) { + if (existsSync(join(root, source))) renameSync(join(root, source), join(stash, name)); + } + rmSync(join(root, ".next"), { recursive: true, force: true }); + rmSync(join(root, "out"), { recursive: true, force: true }); + status = run("npx", ["next", "build"], { NEXT_STATIC_EXPORT: "1" }); +} finally { + restore(); +} + +process.exit(status); diff --git a/scripts/cloudflare/ensure-resources.mjs b/scripts/cloudflare/ensure-resources.mjs new file mode 100644 index 00000000..2269362b --- /dev/null +++ b/scripts/cloudflare/ensure-resources.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// SSC-37 / SSC-39: make sure the Worker's KV namespace and queues exist, then +// write the namespace id into wrangler.jsonc in place of its placeholder, so +// `wrangler deploy` can bind them. Idempotent; runs before every deploy. +// +// node scripts/cloudflare/ensure-resources.mjs [--env staging] [--check] +// +// Needs CLOUDFLARE_API_TOKEN (Account → Workers KV Storage: Edit, Queues: +// Edit) and CLOUDFLARE_ACCOUNT_ID. --check only reports what is missing. +import { readFileSync, writeFileSync } from "node:fs"; + +const args = process.argv.slice(2); +const envIndex = args.indexOf("--env"); +const target = envIndex === -1 ? "production" : args[envIndex + 1]; +const checkOnly = args.includes("--check"); + +const RESOURCES = { + production: { + kv: { title: "starsailors-public-data", placeholder: "PUBLIC_DATA_KV_ID" }, + queues: ["starsailors-jobs", "starsailors-jobs-dlq"], + }, + staging: { + kv: { title: "starsailors-public-data-staging", placeholder: "PUBLIC_DATA_KV_ID_STAGING" }, + queues: ["starsailors-jobs-staging", "starsailors-jobs-staging-dlq"], + }, +}; + +const resources = RESOURCES[target]; +if (!resources) { + console.error(`Unknown --env ${target}; expected ${Object.keys(RESOURCES).join(" or ")}`); + process.exit(2); +} + +const { CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account } = process.env; +if (!token || !account) { + console.error("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are required"); + process.exit(2); +} + +async function api(path, init = {}) { + const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}${path}`, { + ...init, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...init.headers }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok || body.success === false) { + const errors = (body.errors ?? []).map((e) => `${e.code}: ${e.message}`).join("; "); + throw new Error(`${init.method ?? "GET"} ${path} → ${res.status} ${errors}`); + } + return body; +} + +async function listAll(path) { + const items = []; + for (let page = 1; page < 50; page++) { + const body = await api(`${path}${path.includes("?") ? "&" : "?"}page=${page}&per_page=100`); + items.push(...(body.result ?? [])); + const info = body.result_info; + if (!info || page >= (info.total_pages ?? 1) || (body.result ?? []).length === 0) break; + } + return items; +} + +const missing = []; + +// KV namespace +const namespaces = await listAll("/storage/kv/namespaces"); +let namespace = namespaces.find((n) => n.title === resources.kv.title); +if (!namespace) { + if (checkOnly) missing.push(`KV namespace ${resources.kv.title}`); + else { + namespace = (await api("/storage/kv/namespaces", { method: "POST", body: JSON.stringify({ title: resources.kv.title }) })).result; + console.log(`created KV namespace ${resources.kv.title} (${namespace.id})`); + } +} else { + console.log(`KV namespace ${resources.kv.title}: ${namespace.id}`); +} + +// Queues +const queues = await listAll("/queues"); +for (const name of resources.queues) { + if (queues.some((q) => q.queue_name === name)) { + console.log(`queue ${name}: exists`); + } else if (checkOnly) { + missing.push(`queue ${name}`); + } else { + await api("/queues", { method: "POST", body: JSON.stringify({ queue_name: name }) }); + console.log(`created queue ${name}`); + } +} + +if (missing.length) { + console.error(`missing: ${missing.join(", ")}`); + process.exit(1); +} + +if (namespace && !checkOnly) { + const file = new URL("../../wrangler.jsonc", import.meta.url); + const config = readFileSync(file, "utf8"); + const quoted = `"${resources.kv.placeholder}"`; + if (config.includes(quoted)) { + writeFileSync(file, config.replace(quoted, `"${namespace.id}"`)); + console.log(`wrangler.jsonc: ${resources.kv.placeholder} → ${namespace.id}`); + } else if (!config.includes(`"${namespace.id}"`)) { + console.error(`wrangler.jsonc has neither ${quoted} nor the namespace id ${namespace.id}`); + process.exit(1); + } +} diff --git a/scripts/cloudflare/generate-routes.mjs b/scripts/cloudflare/generate-routes.mjs new file mode 100644 index 00000000..f6600fb7 --- /dev/null +++ b/scripts/cloudflare/generate-routes.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +// Generates the Worker's route tables from src/app (SSC-31): +// workers/app/src/generated/api-routes.ts every src/app/api/**/route.ts +// workers/app/src/generated/page-routes.ts every dynamic page.tsx, mapped +// to its exported placeholder HTML +// `--check` exits 1 when the committed files are stale (run in CI). +import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(fileURLToPath(import.meta.url), "..", "..", ".."); +const appDir = join(root, "src", "app"); +const outDir = join(root, "workers", "app", "src", "generated"); +const PLACEHOLDER = "__static__"; + +function walk(dir, name) { + const found = []; + for (const entry of readdirSync(dir).sort()) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) found.push(...walk(full, name)); + else if (entry === name) found.push(full); + } + return found; +} + +// Route groups "(x)" and private folders "_x" never appear in the URL. +function urlSegments(file) { + return relative(appDir, file) + .split(sep) + .slice(0, -1) + .filter((s) => !(s.startsWith("(") && s.endsWith(")"))); +} + +function toPattern(segments) { + return segments.map((s) => { + const catchAll = s.match(/^\[\.\.\.(\w+)\]$/); + if (catchAll) return { kind: "rest", name: catchAll[1] }; + const param = s.match(/^\[(\w+)\]$/); + if (param) return { kind: "param", name: param[1] }; + return { kind: "literal", value: s }; + }); +} + +// Most literal segments first, so /planets/edit/:id beats /planets/:a/:b. +const specificity = (p) => p.filter((s) => s.kind === "literal").length * 100 - p.filter((s) => s.kind === "rest").length; + +const header = "// Generated by scripts/cloudflare/generate-routes.mjs. Do not edit.\n"; + +const apiRoutes = walk(join(appDir, "api"), "route.ts") + .map((file) => ({ file, pattern: toPattern(urlSegments(file)) })) + .sort((a, b) => specificity(b.pattern) - specificity(a.pattern) || a.file.localeCompare(b.file)); + +let api = header + 'import type { RouteModule } from "../routeTypes";\n'; +apiRoutes.forEach((r, i) => { + const spec = relative(join(outDir), r.file).split(sep).join("/").replace(/\.ts$/, ""); + api += `import * as r${i} from "${spec}";\n`; +}); +api += "\nexport const apiRoutes: Array<{ pattern: string; segments: RouteSegment[]; module: RouteModule }> = [\n"; +apiRoutes.forEach((r, i) => { + api += ` { pattern: ${JSON.stringify("/" + urlSegments(r.file).join("/"))}, segments: ${JSON.stringify(r.pattern)}, module: r${i} as unknown as RouteModule },\n`; +}); +api += "];\n\nexport type RouteSegment =\n | { kind: \"literal\"; value: string }\n | { kind: \"param\"; name: string }\n | { kind: \"rest\"; name: string };\n"; + +const dynamicPages = walk(appDir, "page.tsx") + .map((file) => ({ file, segments: urlSegments(file) })) + .filter((p) => p.segments.some((s) => s.startsWith("["))) + .map((p) => ({ ...p, pattern: toPattern(p.segments) })) + .sort((a, b) => specificity(b.pattern) - specificity(a.pattern) || a.file.localeCompare(b.file)); + +let pages = header + 'import type { RouteSegment } from "./api-routes";\n\n'; +pages += `export const STATIC_PARAM_PLACEHOLDER = ${JSON.stringify(PLACEHOLDER)};\n\n`; +pages += "// Each dynamic page is exported once, with every param set to the placeholder.\n"; +pages += "export const dynamicPages: Array<{ pattern: string; segments: RouteSegment[]; asset: string }> = [\n"; +for (const p of dynamicPages) { + const asset = "/" + p.segments.map((s) => (s.startsWith("[") ? PLACEHOLDER : s)).join("/"); + pages += ` { pattern: ${JSON.stringify("/" + p.segments.join("/"))}, segments: ${JSON.stringify(p.pattern)}, asset: ${JSON.stringify(asset)} },\n`; +} +pages += "];\n"; + +const outputs = { "api-routes.ts": api, "page-routes.ts": pages }; +const check = process.argv.includes("--check"); +let stale = false; +mkdirSync(outDir, { recursive: true }); +for (const [name, content] of Object.entries(outputs)) { + const target = join(outDir, name); + let current = null; + try { + current = readFileSync(target, "utf8"); + } catch {} + if (current === content) continue; + if (check) { + console.error(`${relative(root, target)} is stale; run yarn cf:routes`); + stale = true; + } else { + writeFileSync(target, content); + console.log(`wrote ${relative(root, target)}`); + } +} +process.exit(stale ? 1 : 0); diff --git a/scripts/cloudflare/measure-budget.mjs b/scripts/cloudflare/measure-budget.mjs new file mode 100644 index 00000000..7bb261b0 --- /dev/null +++ b/scripts/cloudflare/measure-budget.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +// SSC-38 Free-plan budget evidence for a deployed Star Sailors Worker. +// +// node scripts/cloudflare/measure-budget.mjs --base https://staging.starsailors.space \ +// [--env staging] [--tail] [--samples 5] [--out budget.json] +// +// Sends the representative anonymous, authenticated read, mutation and refresh +// requests and records per route: status, response size, latency (first +// request vs warm), `x-ssc-subrequests`, Error 1102s and the error rate. With +// --tail (needs CLOUDFLARE_API_TOKEN) it also runs `wrangler tail` and reads +// each request's CPU/wall time and outcome, matched by a unique query marker. +// Requests with no tail event were served by Workers Static Assets and never +// invoked the Worker. +// +// Authenticated flows need a Clerk session: BUDGET_SESSION_TOKEN (a session +// JWT, valid ~60s), or CLERK_SECRET_KEY + BUDGET_SESSION_ID to mint a fresh +// token before each authenticated request. Without either they are skipped. +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { writeFileSync } from "node:fs"; + +const args = process.argv.slice(2); +const flag = (name, fallback) => { + const i = args.indexOf(`--${name}`); + if (i === -1) return fallback; + const next = args[i + 1]; + return next && !next.startsWith("--") ? next : true; +}; + +const base = String(flag("base", "")).replace(/\/$/, ""); +if (!base) { + console.error("usage: measure-budget.mjs --base [--env staging] [--tail] [--samples N] [--out file.json]"); + process.exit(2); +} +const samples = Number(flag("samples", 5)); +const wranglerEnv = flag("env", ""); +const useTail = flag("tail", false) === true; +const outFile = flag("out", ""); + +const CPU_BUDGET_MS = 10; // Workers Free +const SUBREQUEST_BUDGET = 50; // Workers Free + +// `expect` is the status that counts as success for that flow. +const routes = [ + { flow: "anonymous", name: "Landing", method: "GET", path: "/", expect: 200 }, + { flow: "anonymous", name: "Sign-in page", method: "GET", path: "/auth", expect: 200 }, + { flow: "anonymous", name: "Garden hub shell", method: "GET", path: "/game", expect: 200 }, + { flow: "anonymous", name: "Dynamic page (placeholder)", method: "GET", path: "/posts/1", expect: 200 }, + { flow: "anonymous", name: "Signed-out API read", method: "GET", path: "/api/auth/session", expect: 401 }, + { flow: "anonymous", name: "Unknown path (404 page)", method: "GET", path: "/budget-missing-page", expect: 404 }, + // SSC-37: precomputed public data, read from KV only. + { flow: "anonymous", name: "Landing stats snapshot", method: "GET", path: "/api/public/snapshots/landing-stats", expect: 200 }, + { flow: "anonymous", name: "Sunspot leaderboard snapshot", method: "GET", path: "/api/gameplay/leaderboards/sunspots", expect: 200 }, + { flow: "anonymous", name: "Community activity snapshot", method: "GET", path: "/api/community-activity", expect: 200 }, + { flow: "anonymous", name: "Snapshot health", method: "GET", path: "/api/public/status", expect: 200 }, + { flow: "authenticated read", name: "Session check", method: "GET", path: "/api/auth/session", expect: 200, auth: true }, + { flow: "authenticated read", name: "SSC-35 /api/v1/me", method: "GET", path: "/api/v1/me", expect: 200, auth: true }, + { flow: "authenticated read", name: "Hub bootstrap", method: "GET", path: "/api/gameplay/hub/bootstrap", expect: 200, auth: true }, + { flow: "authenticated read", name: "Profile", method: "GET", path: "/api/gameplay/profile/me", expect: 200, auth: true }, + { flow: "mutation", name: "Ensure profile (idempotent)", method: "POST", path: "/api/gameplay/profile/ensure", expect: 200, auth: true }, + { flow: "mutation", name: "Former server action", method: "POST", path: "/api/actions/getCurrentProfileAction", body: { args: [] }, expect: 200, auth: true }, + // SSC-39: answers 202 once the push is queued; the consumer shows up under "Background invocations". + { flow: "mutation", name: "Queue a push to self", method: "POST", path: "/api/notify-my-discoveries", body: { customMessage: { title: "Budget check", body: "Star Sailors budget measurement", url: "/game" } }, expect: 202, auth: true }, + { flow: "refresh", name: "Hub reload: shell", method: "GET", path: "/game", expect: 200, auth: true }, + { flow: "refresh", name: "Hub reload: bootstrap", method: "GET", path: "/api/gameplay/hub/bootstrap", expect: 200, auth: true }, +]; + +async function sessionToken() { + const { CLERK_SECRET_KEY, BUDGET_SESSION_ID, BUDGET_SESSION_TOKEN } = process.env; + if (CLERK_SECRET_KEY && BUDGET_SESSION_ID) { + const res = await fetch(`https://api.clerk.com/v1/sessions/${encodeURIComponent(BUDGET_SESSION_ID)}/tokens`, { + method: "POST", + headers: { authorization: `Bearer ${CLERK_SECRET_KEY}` }, + }); + if (!res.ok) throw new Error(`Clerk token mint failed: ${res.status} ${await res.text()}`); + return (await res.json()).jwt; + } + return BUDGET_SESSION_TOKEN || null; +} + +function startTail() { + const events = []; + const tailArgs = ["wrangler", "tail", "--format", "json", ...(wranglerEnv ? ["--env", String(wranglerEnv)] : [])]; + const child = spawn("npx", tailArgs, { stdio: ["ignore", "pipe", "inherit"] }); + let buffer = ""; + child.stdout.on("data", (chunk) => { + buffer += chunk; + // wrangler prints one (pretty-printed) JSON object per event; split on + // balanced top-level braces, ignoring braces inside strings. + let depth = 0; + let start = -1; + let inString = false; + let consumed = 0; + for (let i = 0; i < buffer.length; i++) { + const c = buffer[i]; + if (inString) { + if (c === "\\") i++; + else if (c === '"') inString = false; + } else if (c === '"') { + inString = true; + } else if (c === "{") { + if (depth++ === 0) start = i; + } else if (c === "}" && depth > 0 && --depth === 0) { + try { + events.push(JSON.parse(buffer.slice(start, i + 1))); + } catch {} + consumed = i + 1; + } + } + buffer = buffer.slice(consumed); + }); + return { events, stop: () => child.kill("SIGINT") }; +} + +async function measure(route, marker, token) { + const url = new URL(route.path, base); + url.searchParams.set("__budget", marker); + const headers = {}; + if (token) headers.authorization = `Bearer ${token}`; + if (route.body) headers["content-type"] = "application/json"; + const started = performance.now(); + const res = await fetch(url, { + method: route.method, + headers, + body: route.body ? JSON.stringify(route.body) : undefined, + redirect: "manual", + }); + const ttfb = performance.now() - started; + const body = new Uint8Array(await res.arrayBuffer()); + const total = performance.now() - started; + const text = res.status >= 500 ? new TextDecoder().decode(body.slice(0, 4096)) : ""; + return { + marker, + status: res.status, + ok: res.status === route.expect, + error1102: /1102|exceeded resource limits/i.test(text), + bytes: body.byteLength, + ttfbMs: Math.round(ttfb), + totalMs: Math.round(total), + subrequests: res.headers.has("x-ssc-subrequests") ? Number(res.headers.get("x-ssc-subrequests")) : null, + ray: res.headers.get("cf-ray"), + }; +} + +const median = (xs) => { + const s = xs.filter((x) => x != null).sort((a, b) => a - b); + return s.length ? s[Math.floor(s.length / 2)] : null; +}; +const max = (xs) => { + const s = xs.filter((x) => x != null); + return s.length ? Math.max(...s) : null; +}; + +const tail = useTail ? startTail() : null; +if (tail) await new Promise((r) => setTimeout(r, 8000)); // let the tail session attach + +const results = []; +for (const route of routes) { + const runs = []; + for (let i = 0; i < samples; i++) { + let token = null; + if (route.auth) { + token = await sessionToken(); + if (!token) break; + } + try { + runs.push(await measure(route, randomUUID(), token)); + } catch (error) { + runs.push({ marker: null, status: 0, ok: false, error1102: false, bytes: 0, ttfbMs: null, totalMs: null, subrequests: null, error: String(error) }); + } + } + results.push({ ...route, runs, skipped: runs.length === 0 }); +} + +if (tail) { + await new Promise((r) => setTimeout(r, 10000)); // tail delivery lag + tail.stop(); + for (const result of results) { + for (const run of result.runs) { + const event = tail.events.find((e) => e?.event?.request?.url?.includes(`__budget=${run.marker}`)); + if (!event) { + run.worker = false; + continue; + } + run.worker = true; + run.outcome = event.outcome; + run.cpuMs = event.cpuTime ?? null; + run.wallMs = event.wallTime ?? null; + } + } +} + +// Cron and queue invocations seen by the tail during the run (SSC-37/39). +// A cron fires every 10 minutes, so a short run may not see one. +const backgroundRows = []; +if (tail) { + const groups = new Map(); + for (const e of tail.events) { + const label = e?.event?.cron ? `cron ${e.event.cron}` : e?.event?.queue ? `queue ${e.event.queue}` : null; + if (!label) continue; + groups.set(label, [...(groups.get(label) ?? []), e]); + } + for (const [label, events] of groups) { + const cpu = events.map((e) => e.cpuTime ?? null); + const exceeded = events.some((e) => e.outcome === "exceededCpu" || e.outcome === "exceededResources"); + backgroundRows.push({ + invocation: label, + count: events.length, + outcomes: [...new Set(events.map((e) => e.outcome))].join("/"), + cpuMsMax: max(cpu), + cpuMsMedian: median(cpu), + withinBudget: !exceeded && (max(cpu) == null || max(cpu) <= CPU_BUDGET_MS), + }); + } +} + +const rows = results.map((r) => { + const runs = r.runs; + const failures = runs.filter((x) => !x.ok).length; + const cpu = runs.map((x) => x.cpuMs); + const sub = runs.map((x) => x.subrequests); + const exceeded = runs.some((x) => x.error1102 || x.outcome === "exceededCpu" || x.outcome === "exceededResources"); + const invoked = !useTail ? "?" : runs.some((x) => x.worker) ? "Worker" : "asset"; + return { + flow: r.flow, + name: r.name, + request: `${r.method} ${r.path}`, + skipped: r.skipped, + served: invoked, + statuses: [...new Set(runs.map((x) => x.status))].join("/"), + errorRate: runs.length ? failures / runs.length : null, + error1102: exceeded, + bytes: median(runs.map((x) => x.bytes)), + coldMs: runs[0]?.totalMs ?? null, + warmMs: median(runs.slice(1).map((x) => x.totalMs)), + cpuMsMax: max(cpu), + cpuMsMedian: median(cpu), + subrequestsMax: max(sub), + withinBudget: + !exceeded && + (max(cpu) == null || max(cpu) <= CPU_BUDGET_MS) && + (max(sub) == null || max(sub) <= SUBREQUEST_BUDGET), + }; +}); + +const fmt = (v, suffix = "") => (v == null ? "–" : `${v}${suffix}`); +const lines = [ + `# Star Sailors Free-plan budget: ${base}`, + "", + `${new Date().toISOString()} · ${samples} samples per route · CPU source: ${useTail ? "wrangler tail" : "not collected (run with --tail)"}`, + "", + "| Flow | Route | Served by | Status | Error rate | 1102 | Size | Cold | Warm (median) | CPU max / median | Subrequests max | Within budget |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ...rows.map((r) => + r.skipped + ? `| ${r.flow} | ${r.name} (\`${r.request}\`) | skipped: no session | | | | | | | | | |` + : `| ${r.flow} | ${r.name} (\`${r.request}\`) | ${r.served} | ${r.statuses} | ${Math.round(r.errorRate * 100)}% | ${r.error1102 ? "YES" : "no"} | ${fmt(r.bytes, " B")} | ${fmt(r.coldMs, " ms")} | ${fmt(r.warmMs, " ms")} | ${fmt(r.cpuMsMax, " ms")} / ${fmt(r.cpuMsMedian, " ms")} | ${fmt(r.subrequestsMax)} | ${r.withinBudget ? "yes" : "NO"} |`, + ), + "", + "### Background invocations (cron, queue)", + "", + ...(backgroundRows.length + ? [ + "| Invocation | Count | Outcome | CPU max / median | Within budget |", + "| --- | --- | --- | --- | --- |", + ...backgroundRows.map((b) => `| ${b.invocation} | ${b.count} | ${b.outcomes} | ${fmt(b.cpuMsMax, " ms")} / ${fmt(b.cpuMsMedian, " ms")} | ${b.withinBudget ? "yes" : "NO"} |`), + ] + : [useTail ? "None observed during this run (the snapshot cron fires every 10 minutes; see Workers Logs)." : "Not collected (run with --tail)."]), + "", + `Budget: CPU ≤ ${CPU_BUDGET_MS} ms and ≤ ${SUBREQUEST_BUDGET} subrequests per Worker invocation (Workers Free). "Cold" is the first request of the run; run straight after a deploy for a true cold start.`, +]; +const report = lines.join("\n"); +console.log(report); +if (outFile) writeFileSync(String(outFile), JSON.stringify({ base, samples, tail: useTail, at: new Date().toISOString(), rows, backgroundRows, results }, null, 2)); +if (process.env.GITHUB_STEP_SUMMARY) writeFileSync(process.env.GITHUB_STEP_SUMMARY, report + "\n", { flag: "a" }); + +const failed = rows.some((r) => !r.skipped && (!r.withinBudget || r.errorRate > 0)) || backgroundRows.some((b) => !b.withinBudget); +process.exit(failed ? 1 : 0); diff --git a/src/app/(landing)/_components/LandingSignedInRedirect.tsx b/src/app/(landing)/_components/LandingSignedInRedirect.tsx new file mode 100644 index 00000000..a0b277c0 --- /dev/null +++ b/src/app/(landing)/_components/LandingSignedInRedirect.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useAuth } from "@clerk/nextjs"; +import { useEffect } from "react"; + +/** + * Signed-in visitors go straight to the garden hub. Middleware did this on the + * server; the Cloudflare static export (SSC-31) has no middleware, so the + * browser does it once Clerk has loaded. + */ +export function LandingSignedInRedirect() { + const { isLoaded, userId } = useAuth(); + + useEffect(() => { + if (isLoaded && userId) window.location.replace("/game?from=landing"); + }, [isLoaded, userId]); + + return null; +} diff --git a/src/app/(landing)/_components/LandingStats.tsx b/src/app/(landing)/_components/LandingStats.tsx index 4a3454b7..9acb5ac8 100644 --- a/src/app/(landing)/_components/LandingStats.tsx +++ b/src/app/(landing)/_components/LandingStats.tsx @@ -1,26 +1,31 @@ -import { getActiveSailors, getTotalDiscoveries, getActiveProjects } from "@/src/lib/server/stats"; +"use client"; -export const revalidate = 300; +import { useEffect, useState } from "react"; + +import type { LandingStats as LandingStatsData } from "@/src/server/snapshots/compute"; + +// SSC-37: the landing page is static HTML, so these figures come from the +// cron-published `landing-stats` snapshot rather than a render-time query. + +type SnapshotResponse = { + status: "fresh" | "stale" | "missing"; + generatedAt: string | null; + data: LandingStatsData | null; +}; function fmt(n: number): string { if (n >= 1000) return `${Math.floor(n / 1000)}k+`; return n > 0 ? `${n}+` : "—"; } -export async function LandingStats() { - const [contributors, discoveries, projects] = await Promise.all([ - getActiveSailors().catch(() => 0), - getTotalDiscoveries().catch(() => 0), - getActiveProjects().catch(() => 0), - ]); - - const stats = [ - { value: fmt(projects) || "11+", label: "Science projects" }, - { value: fmt(discoveries) || "100k+", label: "Classifications" }, - { value: fmt(contributors) || "—", label: "Active Contributors (24h)" }, - { value: "1", label: "Open Source" }, - ]; +const FALLBACK = [ + { value: "11+", label: "Science projects" }, + { value: "100k+", label: "Classifications" }, + { value: "—", label: "Active Contributors (24h)" }, + { value: "1", label: "Open Source" }, +]; +function StatsGrid({ stats, note }: { stats: typeof FALLBACK; note?: string | null }) { return (
{stats.map((s) => ( @@ -33,26 +38,55 @@ export async function LandingStats() {
))} + {note ? ( +

+ {note} +

+ ) : null} ); } -/** Fallback for Suspense — static values, no spinner */ -export function LandingStatsFallback() { - const stats = [ - { value: "11+", label: "Science projects" }, - { value: "100k+", label: "Classifications" }, - { value: "—", label: "Active Contributors (24h)" }, - { value: "1", label: "Open Source" }, - ]; +function updatedNote(snapshot: SnapshotResponse): string | null { + if (!snapshot.generatedAt) return null; + const minutes = Math.max(0, Math.round((Date.now() - Date.parse(snapshot.generatedAt)) / 60_000)); + const ago = minutes < 1 ? "just now" : minutes < 120 ? `${minutes} min ago` : `${Math.round(minutes / 60)} h ago`; + return snapshot.status === "stale" ? `Figures may be out of date · updated ${ago}` : `Updated ${ago}`; +} + +export function LandingStats() { + const [snapshot, setSnapshot] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("/api/public/snapshots/landing-stats") + .then((res) => res.json() as Promise) + .then((body) => { + if (!cancelled) setSnapshot(body); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const data = snapshot?.data; + if (!snapshot || !data) return ; + return ( -
- {stats.map((s) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
+ ); } + +/** Shown before the snapshot loads, or when none is published yet. */ +export function LandingStatsFallback() { + return ; +} diff --git a/src/app/actions/classification-actions.ts b/src/app/actions/classification-actions.ts index 38dd3fb4..9123c794 100644 --- a/src/app/actions/classification-actions.ts +++ b/src/app/actions/classification-actions.ts @@ -1,82 +1,8 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { z } from "zod"; -import { getRouteUser } from "@/lib/server/routeAuth"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { mapClassificationToRow } from "@/lib/pocketbase/legacyShapes"; - -const ClassificationSchema = z.object({ - anomaly: z.number().nullable().optional(), - classificationtype: z.string().min(1), - content: z.string().nullable().optional(), - media: z.any().optional(), - classificationConfiguration: z.any().optional(), - classificationParent: z.union([z.number(), z.string()]).nullable().optional(), -}); - -export type CreateClassificationInput = z.infer; - -export async function createClassificationAction(payload: CreateClassificationInput) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const { - anomaly, - classificationtype, - content, - media, - classificationConfiguration, - classificationParent - } = ClassificationSchema.parse(payload); - - if (!classificationtype) { - throw new Error("Classification type is required"); - } - - const anomalyId = anomaly ? Number(anomaly) : null; - void classificationParent; // not persisted — not part of the classifications collection - - const pb = await createPocketbaseAdminClient(); - - // legacyId assignment mirrors the old autoincrement PK: one past the - // current max. Matches the pattern in /api/gameplay/classifications POST. - const latest = await pb - .collection("ss_classifications") - .getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - const classification = await pb.collection("ss_classifications").create({ - legacyId: nextLegacyId, - createdAt: new Date().toISOString(), - author: user.id, - anomaly: anomalyId, - classificationtype, - content: content || "", - media: media ?? null, - classificationConfiguration: classificationConfiguration ?? null, - }); - - // Revalidate paths - revalidatePath("/game"); - revalidatePath("/research"); - revalidatePath("/viewports/satellite"); - revalidatePath("/viewports/solar"); - revalidatePath("/viewports/rover"); - revalidatePath(`/next/${nextLegacyId}`); - - return { success: true, data: mapClassificationToRow(classification) }; - - } catch (error) { - console.error("[Create Classification] Error:", error); - if (error instanceof z.ZodError) { - return { error: "Invalid input data", details: error.flatten() }; - } - return { error: "Failed to create classification" }; - } -} - - +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/classification-actions.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/classification-actions"; +export type { CreateClassificationInput } from "@/src/server/actions/classification-actions"; + +export const createClassificationAction = (...args: Parameters) => + callAction>>("createClassificationAction", args); diff --git a/src/app/actions/deploy-actions.ts b/src/app/actions/deploy-actions.ts index d544bb6e..9d40014d 100644 --- a/src/app/actions/deploy-actions.ts +++ b/src/app/actions/deploy-actions.ts @@ -1,335 +1,23 @@ -"use server"; +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/deploy-actions.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/deploy-actions"; +export type { DeploymentType } from "@/src/server/actions/deploy-actions"; -import { revalidatePath } from "next/cache"; -import { z } from "zod"; -import { getRouteUser } from "@/lib/server/routeAuth"; -import { hasResearchedTech } from "@/lib/server/researched"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { mapAnomalyToRow } from "@/lib/pocketbase/legacyShapes"; -import { withVisibleRecords } from "@/lib/pocketbase/sscVisibility"; +export const getTelescopeStatus = (...args: Parameters) => + callAction>>("getTelescopeStatus", args); -// Schema for deployment input -const DeploySchema = z.object({ - deploymentType: z.enum(["stellar", "planetary"]), - anomalyIds: z.array(z.number()), -}); +export const getTelescopeAnomalies = (...args: Parameters) => + callAction>>("getTelescopeAnomalies", args); -export type DeploymentType = z.infer["deploymentType"]; +export const getTelescopeSkillProgress = (...args: Parameters) => + callAction>>("getTelescopeSkillProgress", args); -// Helper to determine which anomaly sets to fetch -function computeSetsToFetch( - deploymentType: DeploymentType, - options: { includeActiveAsteroids: boolean; includeNgts: boolean } -) { - if (deploymentType === "stellar") { - return ["diskDetective", "superwasp-variable", "telescope-superwasp-variable"]; - } +export const deployTelescopeAction = (...args: Parameters) => + callAction>>("deployTelescopeAction", args); - const sets = ["telescope-tess", "telescope-minorPlanet"]; - if (options.includeActiveAsteroids) { - sets.push("active-asteroids"); - } - if (options.includeNgts) { - sets.push("telescope-ngts"); - } - return sets; -} +export const getLinkedAnomaly = (...args: Parameters) => + callAction>>("getLinkedAnomaly", args); -async function countOthersInteractions( - pb: Awaited>, - collection: "ss_comments" | "votes", - filter: string, - userId: string -) { - const rows = await pb.collection(collection).getFullList({ filter, fields: "classificationId" }); - const classificationIds = [...new Set(rows.map((r) => r.classificationId).filter((id) => id != null))]; - if (classificationIds.length === 0) return 0; - - const classFilter = classificationIds.map((id) => pb.filter("legacyId = {:id}", { id })).join(" || "); - const classifications = await pb.collection("ss_classifications").getFullList({ - filter: classFilter, - fields: "legacyId,author", - }); - const authorByLegacyId = new Map(classifications.map((c) => [c.legacyId, c.author])); - - return rows.filter((r) => { - const author = authorByLegacyId.get(r.classificationId); - return author && author !== userId; - }).length; -} - -export async function getTelescopeStatus() { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const oneWeekAgo = new Date(); - oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); - - const pb = await createPocketbaseAdminClient(); - - const [linkedCount, validCommentsCount, validVotesCount] = await Promise.all([ - pb.collection("linked_anomalies").getList(1, 1, { - filter: withVisibleRecords( - pb.filter("automaton = {:a} && author = {:u} && date >= {:d}", { - a: "Telescope", - u: user.id, - d: oneWeekAgo.toISOString(), - }) - ), - }).then((r) => r.totalItems), - countOthersInteractions( - pb, - "ss_comments", - pb.filter("author = {:u} && createdAt >= {:d}", { u: user.id, d: oneWeekAgo.toISOString() }), - user.id - ), - countOthersInteractions( - pb, - "votes", - pb.filter("userId = {:u} && voteType = {:t} && createdAt >= {:d}", { - u: user.id, - t: "up", - d: oneWeekAgo.toISOString(), - }), - user.id - ), - ]); - - const additionalDeploys = Math.floor(validVotesCount / 3) + validCommentsCount; - const userCanRedeploy = linkedCount + additionalDeploys > linkedCount; - - if (linkedCount === 0) { - return { alreadyDeployed: false, deploymentMessage: null }; - } - - if (userCanRedeploy) { - return { - alreadyDeployed: false, - deploymentMessage: "You have earned additional deploys by interacting with the community this week!", - }; - } - - return { - alreadyDeployed: true, - deploymentMessage: "Telescope has already been deployed this week. Recalibrate & search again next week.", - }; - } catch (error) { - console.error("[Telescope Status] Error:", error); - throw new Error("Failed to fetch telescope status"); - } -} - -export async function getTelescopeAnomalies(deploymentType: DeploymentType) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const pb = await createPocketbaseAdminClient(); - - let includeActiveAsteroids = false; - let includeNgts = false; - - if (deploymentType === "planetary") { - const [minorPlanetCount, hasNgtsAccess] = await Promise.all([ - pb.collection("ss_classifications").getList(1, 1, { - filter: pb.filter("author = {:a} && classificationtype = {:t}", { a: user.id, t: "telescope-minorPlanet" }), - }).then((r) => r.totalItems), - hasResearchedTech(user.id, "ngtsAccess") - ]); - - includeActiveAsteroids = minorPlanetCount >= 2; - includeNgts = hasNgtsAccess; - } - - const setsToFetch = computeSetsToFetch(deploymentType, { - includeActiveAsteroids, - includeNgts, - }); - - const rows = await pb.collection("anomalies").getFullList({ - filter: setsToFetch.map((s) => pb.filter("anomalySet = {:s}", { s })).join(" || "), - }); - - return { anomalies: rows.map(mapAnomalyToRow) }; - } catch (error) { - console.error("[Telescope Anomalies] Error:", error); - throw new Error("Failed to fetch anomalies"); - } -} - -export async function getTelescopeSkillProgress() { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const start = new Date("2000-01-01").toISOString(); - const pb = await createPocketbaseAdminClient(); - - const countByTypes = (types: string[]) => - pb.collection("ss_classifications").getList(1, 1, { - filter: - pb.filter("author = {:a} && createdAt >= {:s}", { a: user.id, s: start }) + - " && (" + - types.map((t) => pb.filter("classificationtype = {:t}", { t })).join(" || ") + - ")", - }).then((r) => r.totalItems); - - const [telescopeCount, weatherCount] = await Promise.all([ - countByTypes(['planet', 'telescope-minorPlanet']), - countByTypes(['cloud', 'lidar-jovianVortexHunter']), - ]); - - return { - skillProgress: { - telescope: telescopeCount, - weather: weatherCount, - } - }; - - } catch (error) { - console.error("[Telescope Skill Progress] Error:", error); - throw new Error("Failed to fetch skill progress"); - } -} - -export async function deployTelescope(prevState: any, formData: FormData) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - return { error: "Unauthorized" }; - } - - // Parse formData manually or expect JSON if called directly. - // Assuming this action might be called with object arguments in a client component wrapper. - // For now, let's assume it receives the raw data object as the first argument if strictly using server actions, - // but standard `useFormState` passes `prevState` and `formData`. - - // Simplification: We'll accept a plain object input for now, and the client will call it directly. - // This deviates from `useFormState` but is cleaner for migrating `fetch` calls. - // We'll rename the function signature to match this pattern. - throw new Error("Use deployTelescopeAction for direct calls"); - } catch (error) { - return { error: "Failed to deploy" }; - } -} - -// Direct action for client components -export async function deployTelescopeAction(payload: z.infer) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const { deploymentType, anomalyIds } = DeploySchema.parse(payload); - - if (anomalyIds.length === 0) { - throw new Error("No anomalies selected"); - } - - const hasProbeReceptors = await hasResearchedTech(user.id, "probereceptors"); - - const maxAnomalies = hasProbeReceptors ? 6 : 4; - const uniqueIds = Array.from(new Set(anomalyIds)).slice(0, maxAnomalies); - - const pb = await createPocketbaseAdminClient(); - const latest = await pb.collection("linked_anomalies").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - let nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - const deploymentDate = new Date().toISOString(); - - await Promise.all( - uniqueIds.map((anomalyId) => - pb.collection("linked_anomalies").create({ - legacyId: nextLegacyId++, - author: user.id, - anomalyId, - classificationId: null, - automaton: "Telescope", - date: deploymentDate, - unlocked: false, - }) - ) - ); - - revalidatePath("/activity/deploy"); - revalidatePath("/structures/telescope"); - revalidatePath("/game"); - - return { success: true, inserted: uniqueIds.length }; - - } catch (error) { - console.error("[Deploy Telescope] Error:", error); - if (error instanceof z.ZodError) { - return { error: "Invalid input data" }; - } - return { error: "Failed to deploy telescope" }; - } -} - -export async function getLinkedAnomaly(anomalyId: number) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const pb = await createPocketbaseAdminClient(); - const linkedAnomaly = await pb - .collection("linked_anomalies") - .getFirstListItem( - withVisibleRecords(pb.filter("author = {:a} && anomalyId = {:id}", { a: user.id, id: anomalyId })), - { - sort: "-legacyId", - }) - .catch(() => null); - - return { - success: true, - data: linkedAnomaly - ? { - id: linkedAnomaly.legacyId, - classificationId: linkedAnomaly.classificationId ?? null, - unlocked: linkedAnomaly.unlocked ?? false, - } - : null, - }; - } catch (error) { - console.error("[Get Linked Anomaly] Error:", error); - return { error: "Failed to fetch linked anomaly" }; - } -} - -export async function updateLinkedAnomalyAction(id: number, updates: { unlocked?: boolean, classification_id?: number }) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const pb = await createPocketbaseAdminClient(); - const existing = await pb - .collection("linked_anomalies") - .getFirstListItem(pb.filter("legacyId = {:id} && author = {:a}", { id, a: user.id })); - - const updated = await pb.collection("linked_anomalies").update(existing.id, { - ...(updates.unlocked !== undefined ? { unlocked: updates.unlocked } : {}), - ...(updates.classification_id !== undefined ? { classificationId: updates.classification_id } : {}), - }); - - revalidatePath("/activity/deploy"); - revalidatePath("/viewports/satellite"); - revalidatePath("/viewports/rover"); - - return { success: true, data: updated }; - } catch (error) { - console.error("[Update Linked Anomaly] Error:", error); - return { error: "Failed to update linked anomaly" }; - } -} +export const updateLinkedAnomalyAction = (...args: Parameters) => + callAction>>("updateLinkedAnomalyAction", args); diff --git a/src/app/actions/gameplay.ts b/src/app/actions/gameplay.ts index 69e22a51..1dfd9878 100644 --- a/src/app/actions/gameplay.ts +++ b/src/app/actions/gameplay.ts @@ -1,139 +1,16 @@ -"use server"; +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/gameplay.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/gameplay"; -import { revalidatePath } from "next/cache"; -import { getRouteUser } from "@/lib/server/routeAuth"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { mapMineralDepositToRow } from "@/lib/pocketbase/legacyShapes"; -import { recursiveSerialize } from "@/utils/serialization"; +export const submitNpsAction = (...args: Parameters) => + callAction>>("submitNpsAction", args); -// ── NPS ────────────────────────────────────────────────────────────────────── +export const submitSurveyorCommentAction = (...args: Parameters) => + callAction>>("submitSurveyorCommentAction", args); -export async function submitNpsAction(input: { npsScore: number; feedback?: string | null }) { - const { user, authError } = await getRouteUser(); - if (authError || !user) return { ok: false as const, error: "Unauthorized" }; +export const getExtractionDepositAction = (...args: Parameters) => + callAction>>("getExtractionDepositAction", args); - const { npsScore, feedback = null } = input; - if (!Number.isFinite(npsScore) || npsScore < 0 || npsScore > 10) { - return { ok: false as const, error: "Invalid score" }; - } - - const pb = await createPocketbaseAdminClient(); - await pb.collection("nps_surveys").create({ - createdAt: new Date().toISOString(), - userId: user.id, - npsScore, - projectInterests: feedback, - }); - - revalidatePath("/game"); - return { ok: true as const }; -} - -// ── SURVEYOR COMMENT ───────────────────────────────────────────────────────── - -type SurveyorCommentInput = { - classificationId: number; - content: string; - configuration?: Record; - surveyor?: string; - category?: string; - value?: string; -}; - -export async function submitSurveyorCommentAction(input: SurveyorCommentInput) { - const { user, authError } = await getRouteUser(); - if (authError || !user) return { ok: false as const, error: "Unauthorized" }; - - const { classificationId, content, configuration, surveyor, category, value } = input; - if (!Number.isFinite(classificationId) || !content.trim()) { - return { ok: false as const, error: "Invalid payload" }; - } - - const pb = await createPocketbaseAdminClient(); - const latest = await pb.collection("ss_comments").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - await pb.collection("ss_comments").create({ - legacyId: nextLegacyId, - createdAt: new Date().toISOString(), - content, - classificationId, - author: user.id, - configuration: configuration ?? null, - surveyor: surveyor ?? null, - category: category ?? null, - value: value ?? null, - }); - - revalidatePath(`/planets/${classificationId}`); - revalidatePath(`/posts/surveyor/${classificationId}`); - return { ok: true as const }; -} - -// ── EXTRACTION ─────────────────────────────────────────────────────────────── - -export async function getExtractionDepositAction(depositId: number) { - const { user, authError } = await getRouteUser(); - if (authError || !user) return { ok: false as const, error: "Unauthorized" }; - - if (!Number.isFinite(depositId)) return { ok: false as const, error: "Invalid deposit ID" }; - - const pb = await createPocketbaseAdminClient(); - const record = await pb - .collection("mineral_deposits") - .getFirstListItem(pb.filter("legacyId = {:id}", { id: depositId })) - .catch(() => null); - if (!record) return { ok: false as const, error: "Mineral deposit not found" }; - if (record.owner !== user.id) return { ok: false as const, error: "Forbidden" }; - - return { ok: true as const, deposit: recursiveSerialize(mapMineralDepositToRow(record)) }; -} - -export async function completeExtractionAction(input: { - depositId: number; - extractedQuantity: number; - purity: number; -}) { - const { user, authError } = await getRouteUser(); - if (authError || !user) return { ok: false as const, error: "Unauthorized" }; - - const { depositId, extractedQuantity, purity } = input; - if (!Number.isFinite(depositId)) return { ok: false as const, error: "Invalid deposit ID" }; - if (!Number.isFinite(extractedQuantity) || extractedQuantity <= 0 || !Number.isFinite(purity)) { - return { ok: false as const, error: "Invalid extraction payload" }; - } - - const pb = await createPocketbaseAdminClient(); - const deposit = await pb - .collection("mineral_deposits") - .getFirstListItem(pb.filter("legacyId = {:id}", { id: depositId })) - .catch(() => null); - if (!deposit) return { ok: false as const, error: "Mineral deposit not found" }; - if (deposit.owner !== user.id) return { ok: false as const, error: "Forbidden" }; - - const mineralType = deposit.mineralConfiguration?.type; - if (!mineralType) return { ok: false as const, error: "Deposit has no mineral type" }; - - const latest = await pb - .collection("user_mineral_inventory") - .getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - await pb.collection("user_mineral_inventory").create({ - legacyId: nextLegacyId, - userId: user.id, - mineralDepositId: deposit.legacyId, - mineralType: String(mineralType), - quantity: extractedQuantity, - purity, - extractedAt: new Date().toISOString(), - createdAt: new Date().toISOString(), - }); - - const updatedConfig = { ...deposit.mineralConfiguration, amount: 0, quantity: 0 }; - await pb.collection("mineral_deposits").update(deposit.id, { mineralConfiguration: updatedConfig }); - - revalidatePath("/inventory"); - revalidatePath(`/extraction/${deposit.legacyId}`); - return { ok: true as const }; -} +export const completeExtractionAction = (...args: Parameters) => + callAction>>("completeExtractionAction", args); diff --git a/src/app/actions/mineral-actions.ts b/src/app/actions/mineral-actions.ts index a2d01c2e..a1fb4fc2 100644 --- a/src/app/actions/mineral-actions.ts +++ b/src/app/actions/mineral-actions.ts @@ -1,89 +1,11 @@ -"use server"; +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/mineral-actions.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/mineral-actions"; +export type { CreateMineralDepositInput } from "@/src/server/actions/mineral-actions"; -import { revalidatePath } from "next/cache"; -import { z } from "zod"; -import { getRouteUser } from "@/lib/server/routeAuth"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { mapMineralDepositToRow } from "@/lib/pocketbase/legacyShapes"; +export const createMineralDepositAction = (...args: Parameters) => + callAction>>("createMineralDepositAction", args); -const MineralDepositSchema = z.object({ - anomaly: z.number(), - discovery: z.number(), - mineral_configuration: z.record(z.string(), z.any()).optional(), - location: z.string().optional().default("Mars"), - rover_name: z.string().optional().default("Rover 1"), - created_at: z.string().optional(), -}); - -export type CreateMineralDepositInput = z.infer; - -export async function createMineralDepositAction(payload: CreateMineralDepositInput) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const { - anomaly, - discovery, - mineral_configuration, - location, - rover_name, - created_at - } = MineralDepositSchema.parse(payload); - - const pb = await createPocketbaseAdminClient(); - const latest = await pb.collection("mineral_deposits").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - const deposit = await pb.collection("mineral_deposits").create({ - legacyId: nextLegacyId, - anomaly, - discovery, - owner: user.id, - mineralConfiguration: mineral_configuration || {}, - location, - roverName: rover_name, - createdAt: created_at ? new Date(created_at).toISOString() : new Date().toISOString(), - }); - - revalidatePath("/inventory"); - revalidatePath("/viewports/rover"); // Fixed typo - revalidatePath(`/next/${discovery}`); - - return { success: true, data: mapMineralDepositToRow(deposit) }; - - } catch (error) { - console.error("[Create Mineral Deposit] Error:", error); - if (error instanceof z.ZodError) { - return { error: "Invalid input data", details: error.flatten() }; - } - return { error: "Failed to create mineral deposit" }; - } -} - -export async function getMineralDeposits(discoveryId?: number) { - try { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - throw new Error("Unauthorized"); - } - - const pb = await createPocketbaseAdminClient(); - const filters = [pb.filter("owner = {:owner}", { owner: user.id }), 'location != ""']; - if (discoveryId !== undefined) { - filters.push(pb.filter("discovery = {:d}", { d: discoveryId })); - } - - const deposits = await pb.collection("mineral_deposits").getFullList({ - filter: filters.join(" && "), - sort: "-createdAt", - }); - - return { success: true, data: deposits.map(mapMineralDepositToRow) }; - } catch (error) { - console.error("[Get Mineral Deposits] Error:", error); - return { error: "Failed to fetch mineral deposits" }; - } -} +export const getMineralDeposits = (...args: Parameters) => + callAction>>("getMineralDeposits", args); diff --git a/src/app/actions/profile-actions.ts b/src/app/actions/profile-actions.ts index b9bc3316..0aa8a636 100644 --- a/src/app/actions/profile-actions.ts +++ b/src/app/actions/profile-actions.ts @@ -1,206 +1,19 @@ -"use server"; +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/profile-actions.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/profile-actions"; -import { currentUser } from "@clerk/nextjs/server"; -import { revalidatePath } from "next/cache"; -import { getRouteUser } from "@/lib/server/routeAuth"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { getStorageUrl } from "@/lib/pocketbase/storageUrl"; -import { storageObjectId, storageFilename } from "@/lib/pocketbase/storageId"; -import { ReferralService } from "@/src/features/referrals/referral-service"; -import { resolveGardenIdentity } from "@/lib/server/gardenIdentity"; +export const getCurrentProfileAction = (...args: Parameters) => + callAction>>("getCurrentProfileAction", args); -async function findProfileByUserId(pb: Awaited>, userId: string) { - return pb - .collection("profiles") - .getFirstListItem(pb.filter("userId = {:id}", { id: userId })) - .catch(() => null); -} +export const updateProfileSetupAction = (...args: Parameters) => + callAction>>("updateProfileSetupAction", args); -export async function getCurrentProfileAction() { - const { user } = await getRouteUser(); - if (!user) return { ok: false as const, error: "Unauthorized" }; +export const completeProfileAction = (...args: Parameters) => + callAction>>("completeProfileAction", args); - const pb = await createPocketbaseAdminClient(); - let email: string | null = null; - try { - const clerkUser = await currentUser(); - email = - clerkUser?.primaryEmailAddress?.emailAddress ?? - clerkUser?.emailAddresses?.[0]?.emailAddress ?? - null; - } catch { - email = null; - } - const identity = await resolveGardenIdentity(pb, user.id, email); - const profile = identity.profileId - ? await pb.collection("profiles").getOne(identity.profileId).catch(() => null) - : await findProfileByUserId(pb, user.id); +export const getReferralPanelDataAction = (...args: Parameters) => + callAction>>("getReferralPanelDataAction", args); - return { - ok: true as const, - data: profile || identity.username - ? { - username: identity.username ?? profile?.username ?? null, - fullName: identity.fullName ?? profile?.fullName ?? null, - avatarUrl: profile?.avatarUrl ?? null, - referralCode: profile?.referralCode ?? null, - } - : null, - }; -} - -export async function updateProfileSetupAction(formData: FormData) { - const { user } = await getRouteUser(); - if (!user) return { ok: false as const, error: "Unauthorized" }; - - const username = String(formData.get("username") || "").trim(); - const firstName = String(formData.get("firstName") || "").trim(); - const existingAvatarPreview = String(formData.get("existingAvatarPreview") || ""); - const avatar = formData.get("avatar"); - - if (!username) { - return { ok: false as const, error: "Username is required." }; - } - - const pb = await createPocketbaseAdminClient(); - - let avatar_url: string | null = existingAvatarPreview && existingAvatarPreview.trim() ? existingAvatarPreview : null; - if (avatar && avatar instanceof File && avatar.size > 0) { - const fileName = `${Date.now()}-${user.id}-avatar.png`; - try { - const pbFormData = new FormData(); - pbFormData.append("id", storageObjectId("avatars", fileName)); - pbFormData.append("bucket", "avatars"); - pbFormData.append("path", fileName); - pbFormData.append( - "file", - new File([avatar], storageFilename(fileName), { type: avatar.type || "image/png" }) - ); - await pb.collection("storage_objects").create(pbFormData); - } catch (error: any) { - return { ok: false as const, error: error?.message || "Avatar upload failed" }; - } - avatar_url = getStorageUrl("avatars", fileName); - } - - try { - const referralCode = await ReferralService.ensureReferralCode(user.id); - const existing = await findProfileByUserId(pb, user.id); - - const fields = { - username, - fullName: firstName, - avatarUrl: avatar_url, - updatedAt: new Date().toISOString(), - }; - - if (existing) { - await pb.collection("profiles").update(existing.id, fields); - } else { - await pb.collection("profiles").create({ userId: user.id, referralCode, ...fields }); - } - } catch (error) { - return { ok: false as const, error: String(error) }; - } - - revalidatePath("/account"); - revalidatePath("/game"); - revalidatePath("/research"); - return { ok: true as const }; -} - -export async function completeProfileAction(input: { - username: string; - fullName: string; - referrerCodeInput?: string; -}) { - const { user } = await getRouteUser(); - if (!user) return { ok: false as const, error: "Unauthorized" }; - - const username = input.username.trim(); - if (username.length < 3) { - return { ok: false as const, error: "Username must be at least 3 characters." }; - } - - const pb = await createPocketbaseAdminClient(); - - try { - const referralCode = await ReferralService.ensureReferralCode(user.id); - const existing = await findProfileByUserId(pb, user.id); - - const fields = { - username, - fullName: input.fullName.trim(), - updatedAt: new Date().toISOString(), - }; - - if (existing) { - await pb.collection("profiles").update(existing.id, fields); - } else { - await pb.collection("profiles").create({ userId: user.id, referralCode, ...fields }); - } - - const referrerCode = (input.referrerCodeInput || "").trim(); - if (referrerCode) { - try { - await ReferralService.applyReferral(user.id, referrerCode); - } catch (e) { - console.warn("Failed to apply referral code during profile completion:", e); - // We don't block profile completion if referral fails - } - } - } catch (error) { - return { ok: false as const, error: "Failed to update profile." }; - } - - revalidatePath("/game"); - revalidatePath("/research"); - revalidatePath("/account"); - return { ok: true as const }; -} - -export async function getReferralPanelDataAction() { - const { user } = await getRouteUser(); - if (!user) return { ok: false as const, error: "Unauthorized" }; - - const pb = await createPocketbaseAdminClient(); - const profile = await findProfileByUserId(pb, user.id); - - if (!profile?.referralCode) { - const newCode = await ReferralService.ensureReferralCode(user.id); - return { ok: true as const, data: { referralCode: newCode, referredUsers: [] } }; - } - - const referrals = await pb.collection("referrals").getFullList({ - filter: pb.filter("referralCode = {:c}", { c: profile.referralCode }), - }); - - const referreeIds: string[] = referrals.map((r) => r.referreeId); - const referreeProfiles = referreeIds.length - ? await pb.collection("profiles").getFullList({ - filter: referreeIds.map((id) => pb.filter("userId = {:id}", { id })).join(" || "), - }) - : []; - - return { - ok: true as const, - data: { - referralCode: profile.referralCode as string, - referredUsers: referreeProfiles.map((p) => ({ id: p.userId as string, username: (p.username as string) ?? null })), - }, - }; -} - -export async function submitReferralCodeAction(referralCode: string) { - const { user } = await getRouteUser(); - if (!user) return { ok: false as const, error: "You must be logged in." }; - - try { - await ReferralService.applyReferral(user.id, referralCode); - revalidatePath("/research"); - revalidatePath("/game"); - return { ok: true as const }; - } catch (error: any) { - return { ok: false as const, error: error.message || "Failed to submit referral code." }; - } -} +export const submitReferralCodeAction = (...args: Parameters) => + callAction>>("submitReferralCodeAction", args); diff --git a/src/app/actions/social-actions.ts b/src/app/actions/social-actions.ts index 39e1be37..dc883642 100644 --- a/src/app/actions/social-actions.ts +++ b/src/app/actions/social-actions.ts @@ -1,87 +1,10 @@ -"use server"; +// Client stubs for the former server actions (SSC-31). Implementations live in +// src/server/actions/social-actions.ts and run behind /api/actions/[name]. +import { callAction } from "@/lib/actions/callAction"; +import type * as impl from "@/src/server/actions/social-actions"; -import { revalidatePath } from "next/cache"; +export const toggleVoteAction = (...args: Parameters) => + callAction>>("toggleVoteAction", args); -import { getRouteUser } from "@/lib/server/routeAuth"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; - -type VoteType = "up" | "down"; - -export async function toggleVoteAction(input: { - classificationId: number; - voteType: VoteType; -}) { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - return { ok: false as const, error: "Not signed in" }; - } - - const { classificationId, voteType } = input; - const pb = await createPocketbaseAdminClient(); - - const existingVote = await pb - .collection("votes") - .getFirstListItem( - pb.filter("userId = {:u} && classificationId = {:c}", { u: user.id, c: classificationId }) - ) - .catch(() => null); - - if (existingVote) { - if (existingVote.voteType === voteType) { - await pb.collection("votes").delete(existingVote.id); - revalidatePath(`/posts/${classificationId}`); - return { ok: true as const, userVote: null as VoteType | null }; - } - - await pb.collection("votes").update(existingVote.id, { voteType }); - revalidatePath(`/posts/${classificationId}`); - return { ok: true as const, userVote: voteType as VoteType }; - } - - const latest = await pb.collection("votes").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - await pb.collection("votes").create({ - legacyId: nextLegacyId, - createdAt: new Date().toISOString(), - userId: user.id, - classificationId, - voteType, - }); - - revalidatePath(`/posts/${classificationId}`); - return { ok: true as const, userVote: voteType as VoteType }; -} - -export async function submitCommentAction(input: { - classificationId: number; - content: string; - parentCommentId?: number; -}) { - const { user, authError } = await getRouteUser(); - if (authError || !user) { - return { ok: false as const, error: "Not signed in" }; - } - - const { classificationId, content, parentCommentId } = input; - const trimmed = content.trim(); - if (!trimmed) { - return { ok: false as const, error: "Empty comment" }; - } - - const pb = await createPocketbaseAdminClient(); - const latest = await pb.collection("ss_comments").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); - const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; - - await pb.collection("ss_comments").create({ - legacyId: nextLegacyId, - createdAt: new Date().toISOString(), - content: trimmed, - author: user.id, - classificationId, - parentCommentId: parentCommentId ?? null, - }); - - revalidatePath(`/posts/${classificationId}`); - return { ok: true as const }; -} +export const submitCommentAction = (...args: Parameters) => + callAction>>("submitCommentAction", args); diff --git a/src/app/api/actions/[name]/route.ts b/src/app/api/actions/[name]/route.ts new file mode 100644 index 00000000..07cbbf2c --- /dev/null +++ b/src/app/api/actions/[name]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; + +import { FORM_DATA_ARGS_HEADER } from "@/lib/actions/callAction"; +import { serverActions } from "@/src/server/actions/registry"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request, { params }: { params: Promise<{ name: string }> }) { + const { name } = await params; + const action = Object.prototype.hasOwnProperty.call(serverActions, name) ? serverActions[name] : undefined; + if (!action) { + return NextResponse.json({ error: "Unknown action" }, { status: 404 }); + } + + let args: unknown[]; + try { + if (request.headers.get(FORM_DATA_ARGS_HEADER) === "form-data") { + args = [await request.formData()]; + } else { + const body = (await request.json()) as { args?: unknown }; + args = Array.isArray(body?.args) ? body.args : []; + } + } catch { + return NextResponse.json({ error: "Invalid action payload" }, { status: 400 }); + } + + try { + const result = await action(...args); + return NextResponse.json({ result: result ?? null }); + } catch (error) { + const message = error instanceof Error ? error.message : "Action failed"; + return NextResponse.json({ error: message }, { status: message === "Unauthorized" ? 401 : 500 }); + } +} diff --git a/src/app/api/auto-notify-discoveries/route.ts b/src/app/api/auto-notify-discoveries/route.ts index c077687f..92deab7a 100644 --- a/src/app/api/auto-notify-discoveries/route.ts +++ b/src/app/api/auto-notify-discoveries/route.ts @@ -1,242 +1,19 @@ -import { NextRequest, NextResponse } from 'next/server'; -import webpush from 'web-push'; +import { NextRequest, NextResponse } from "next/server"; -import { createPocketbaseAdminClient } from '@/lib/pocketbase/adminClient'; +import { requireInternalToken } from "@/lib/server/internalAuth"; +import { enqueueJobs } from "@/src/server/jobs/queue"; -const SEND_TIMEOUT_MS = 8000; -const SEND_CONCURRENCY = 6; -const USER_CONCURRENCY = 4; -const MAX_USERS_PER_RUN = 120; -const MAX_ENDPOINTS_PER_USER = 20; - -type PushSubscriptionRow = { - endpoint: string; - auth: string; - p256dh: string; - profileId: string; -}; - -type LinkedAnomalyRow = { - author: string; - anomalyId: number; - date: string; - automaton: string; -}; - -type AnomalyRow = { - legacyId: number; - content: string; -}; - -type ClassificationRow = { - anomaly: number; -}; - -function dedupeByEndpoint(subscriptions: PushSubscriptionRow[]) { - const unique = new Map(); - for (const sub of subscriptions) { - if (!unique.has(sub.endpoint)) unique.set(sub.endpoint, sub); - } - return Array.from(unique.values()); -} - -async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timeoutHandle: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error(`Push send timed out after ${timeoutMs}ms`)), timeoutMs); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - -async function mapWithConcurrency( - items: T[], - concurrency: number, - fn: (item: T, index: number) => Promise -): Promise { - const results = new Array(items.length); - let currentIndex = 0; - - async function worker() { - while (true) { - const index = currentIndex++; - if (index >= items.length) return; - results[index] = await fn(items[index], index); - } - } - - const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); - await Promise.all(workers); - return results; -} +export const dynamic = "force-dynamic"; +// SSC-39: the daily unclassified-discovery reminder now runs from the +// Worker's cron trigger as a queued fan-out. This endpoint starts the same +// fan-out on demand; it needs INTERNAL_JOBS_TOKEN (it was previously open). +// Job ids are per day, so a second call on the same day sends nothing new. export async function POST(request: NextRequest) { - try { - // Configure web-push with VAPID keys - webpush.setVapidDetails( - 'mailto:admin@starsailors.app', - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY! - ); - - // This endpoint is for automated workflows, so we need admin access - const pb = await createPocketbaseAdminClient(); - - console.log('Auto-checking for unclassified discoveries...'); - - // Get all users who have push subscriptions - let allSubscriptions: PushSubscriptionRow[]; - try { - allSubscriptions = await pb.collection('push_subscriptions').getFullList({ - sort: '-createdAt', - }); - } catch (subError) { - console.error('Error fetching subscriptions:', subError); - return NextResponse.json({ - error: 'Failed to fetch push subscriptions' - }, { status: 500 }); - } - - if (!allSubscriptions || allSubscriptions.length === 0) { - return NextResponse.json({ - message: 'No push subscriptions found' - }); - } - - // Get unique user IDs - const userIds = [...new Set(allSubscriptions.map(sub => sub.profileId))].slice(0, MAX_USERS_PER_RUN); - console.log(`Found ${userIds.length} unique users with push subscriptions`); - - const perUserResults = await mapWithConcurrency(userIds, USER_CONCURRENCY, async (userId) => { - try { - // Get user's linked anomalies - const linkedAnomalies = await pb.collection('linked_anomalies').getFullList({ - filter: pb.filter('author = {:author}', { author: userId }), - sort: '-date', - }).catch(() => []); - - if (linkedAnomalies.length === 0) { - return { sent: 0, hasUnclassified: false }; - } - - // Get anomaly details - const anomalyIds = [...new Set(linkedAnomalies.map(la => la.anomalyId))]; - const anomalyFilter = anomalyIds - .map(id => pb.filter('legacyId = {:id}', { id })) - .join(' || '); - const anomalies = anomalyIds.length > 0 - ? await pb.collection('anomalies').getFullList({ filter: anomalyFilter }).catch(() => []) - : []; - - // Create anomaly details map - const anomalyDetails = new Map(); - anomalies.forEach(a => { - anomalyDetails.set(a.legacyId, a); - }); - - // Get user's classifications - const classifications = await pb.collection('ss_classifications').getFullList({ - filter: pb.filter('author = {:author}', { author: userId }), - }).catch(() => null); - - if (classifications === null) { - return { sent: 0, hasUnclassified: false }; - } - - // Create set of classified anomaly IDs - const classifiedAnomalies = new Set( - classifications.map(c => c.anomaly).filter(Boolean) - ); - - // Find unclassified discoveries - const unclassifiedDiscoveries = linkedAnomalies.filter( - linked => !classifiedAnomalies.has(linked.anomalyId) - ); - - if (unclassifiedDiscoveries.length === 0) { - return { sent: 0, hasUnclassified: false }; - } - - // Prepare discovery data - const discoveryData = unclassifiedDiscoveries.map(d => ({ - anomalyId: d.anomalyId, - name: anomalyDetails.get(d.anomalyId)?.content || `Discovery #${d.anomalyId}`, - automaton: d.automaton, - date: d.date - })); - - // Get user's push subscriptions - const userSubscriptions = await pb.collection('push_subscriptions').getFullList({ - filter: pb.filter('profileId = {:id}', { id: userId }), - sort: '-createdAt', - }).catch(() => []); - - if (userSubscriptions.length === 0) { - return { sent: 0, hasUnclassified: true }; - } - - const deduplicatedSubscriptions = dedupeByEndpoint(userSubscriptions).slice(0, MAX_ENDPOINTS_PER_USER); - - // Create notification message - const discoveryCount = unclassifiedDiscoveries.length; - const title = discoveryCount === 1 - ? 'Discovery Reminder: Classification Needed!' - : `${discoveryCount} Discoveries Need Classification!`; - - const firstDiscovery = discoveryData[0]; - const messageBody = discoveryCount === 1 - ? `Don't forget to classify: ${firstDiscovery.name}` - : `You have ${discoveryCount} unclassified discoveries waiting`; - - const payload = JSON.stringify({ - title, - body: messageBody, - icon: 'https://github.com/Signal-K/client/blob/main/public/assets/Captn.jpg?raw=true', - url: '/structures/telescope' - }); - - // Send notifications to all user's unique endpoints - const results = await mapWithConcurrency(deduplicatedSubscriptions, SEND_CONCURRENCY, async (subscription) => { - try { - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.p256dh - } - }; - - await withTimeout(webpush.sendNotification(pushSubscription, payload), SEND_TIMEOUT_MS); - return { success: true }; - } catch { - return { success: false }; - } - }); - - const successful = results.filter(r => r.success).length; - return { sent: successful, hasUnclassified: true }; - } catch { - return { sent: 0, hasUnclassified: false }; - } - }); - - const totalNotificationsSent = perUserResults.reduce((sum, item) => sum + item.sent, 0); - const usersWithUnclassified = perUserResults.filter((item) => item.hasUnclassified).length; - - return NextResponse.json({ - message: `Auto-notification complete`, - usersProcessed: userIds.length, - usersWithUnclassified, - totalNotificationsSent - }); + const denied = requireInternalToken(request); + if (denied) return denied; - } catch (error) { - console.error('Error in auto-notification API:', error); - return NextResponse.json({ - error: 'Internal server error' - }, { status: 500 }); - } + const day = new Date().toISOString().slice(0, 10); + const queued = await enqueueJobs({ type: "reminders.discoveries", id: `reminders:${day}:p1`, day, page: 1 }); + return NextResponse.json({ status: "queued", day, ...queued }, { status: 202 }); } diff --git a/src/app/api/community-activity/route.ts b/src/app/api/community-activity/route.ts index dc024599..8b6a3c86 100644 --- a/src/app/api/community-activity/route.ts +++ b/src/app/api/community-activity/route.ts @@ -1,35 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -export const dynamic = "force-dynamic"; +import { readSnapshot, snapshotHeaders } from "@/src/server/snapshots/store"; -export async function GET(request: NextRequest) { - try { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const pb = await createPocketbaseAdminClient(); - const exclude = request.nextUrl.searchParams.get("exclude") || ""; +export const dynamic = "force-dynamic"; - const rows = await pb.collection("ss_classifications").getList(1, 12, { - filter: pb.filter("createdAt >= {:d}", { d: since.toISOString() }), - sort: "-createdAt", - skipTotal: true, - fields: "legacyId,author,classificationtype,createdAt", - }); +const VISIBLE = 12; - return NextResponse.json( - rows.items - .filter((r) => !exclude || r.author !== exclude) - .map((r) => ({ - id: r.legacyId, - author: (r.author as string | null)?.slice(0, 8) ?? "user", - type: r.classificationtype, - at: r.createdAt, - })), - // Public, identical for every player (clients filter their own rows), so - // the edge can answer most polls without touching PocketBase. - { headers: { "Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=120" } } - ); - } catch { - return NextResponse.json([]); - } +// SSC-37: recent classifications from the precomputed `community-activity` +// snapshot. Missing data is an empty list (the UI hides the lane); +// `x-snapshot-status` says why. +export async function GET(request: NextRequest) { + const exclude = request.nextUrl.searchParams.get("exclude") || ""; + const snapshot = await readSnapshot("community-activity"); + const items = (snapshot.data ?? []) + .filter((r) => !exclude || r.authorId !== exclude) + .slice(0, VISIBLE) + .map(({ id, author, type, at }) => ({ id, author, type, at })); + const headers = snapshotHeaders(snapshot); + // `exclude` makes the response per-user. + if (exclude) headers["cache-control"] = "private, max-age=60"; + return NextResponse.json(items, { headers }); } diff --git a/src/app/api/gameplay/leaderboards/sunspots/route.ts b/src/app/api/gameplay/leaderboards/sunspots/route.ts index bba1ff31..ad1e664e 100644 --- a/src/app/api/gameplay/leaderboards/sunspots/route.ts +++ b/src/app/api/gameplay/leaderboards/sunspots/route.ts @@ -1,60 +1,21 @@ import { NextResponse } from "next/server"; -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { readSnapshot, snapshotHeaders } from "@/src/server/snapshots/store"; export const dynamic = "force-dynamic"; -type LeaderEntry = { user_id: string; username: string | null; full_name: string | null; avatar_url: string | null; count: number }; - -function toLeaderboard(countsByUser: Map, profileByUserId: Map>): LeaderEntry[] { - return [...countsByUser.entries()] - .map(([userId, count]) => { - const profile = profileByUserId.get(userId); - return { - user_id: userId, - username: profile?.username || "Anonymous", - full_name: profile?.fullName || "Unknown", - avatar_url: profile?.avatarUrl ?? null, - count, - }; - }) - .sort((a, b) => b.count - a.count) - .slice(0, 10); -} - +// SSC-37: served from the precomputed `sunspot-leaderboard` snapshot; the +// full-collection scan runs on the Worker's cron trigger, not per request. export async function GET() { - const pb = await createPocketbaseAdminClient(); - - const [probes, sunspotClassifications] = await Promise.all([ - pb.collection("defensive_probes").getFullList({ fields: "userId,count" }), - pb.collection("ss_classifications").getFullList({ - filter: pb.filter("classificationtype = {:t}", { t: "sunspot" }), - fields: "author", - }), - ]); - - const probeCounts = new Map(); - for (const p of probes) { - if (!p.userId) continue; - probeCounts.set(p.userId, (probeCounts.get(p.userId) ?? 0) + (p.count ?? 0)); + const snapshot = await readSnapshot("sunspot-leaderboard"); + if (!snapshot.data) { + return NextResponse.json( + { error: "Leaderboard is being prepared", status: snapshot.status }, + { status: 503, headers: snapshotHeaders(snapshot) }, + ); } - - const classificationCounts = new Map(); - for (const c of sunspotClassifications) { - if (!c.author) continue; - classificationCounts.set(c.author, (classificationCounts.get(c.author) ?? 0) + 1); - } - - const allUserIds = [...new Set([...probeCounts.keys(), ...classificationCounts.keys()])]; - let profileByUserId = new Map>(); - if (allUserIds.length > 0) { - const filter = allUserIds.map((id) => pb.filter("userId = {:id}", { id })).join(" || "); - const profiles = await pb.collection("profiles").getFullList({ filter }); - profileByUserId = new Map(profiles.map((p) => [p.userId, p])); - } - - return NextResponse.json({ - probeLeaders: toLeaderboard(probeCounts, profileByUserId), - classificationLeaders: toLeaderboard(classificationCounts, profileByUserId), - }); + return NextResponse.json( + { ...snapshot.data, status: snapshot.status, generatedAt: snapshot.generatedAt }, + { headers: snapshotHeaders(snapshot) }, + ); } diff --git a/src/app/api/internal/jobs/route.ts b/src/app/api/internal/jobs/route.ts new file mode 100644 index 00000000..b3b4bb86 --- /dev/null +++ b/src/app/api/internal/jobs/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; + +import { requireInternalToken } from "@/lib/server/internalAuth"; +import { enqueueJobs, listDeadLetters, replayDeadLetters } from "@/src/server/jobs/queue"; + +export const dynamic = "force-dynamic"; + +// SSC-39 operator endpoint. +// GET parked (dead-lettered) jobs +// POST {"action":"replay","ids"?:[..]} re-enqueue parked jobs (all when ids is omitted) +// POST {"action":"discovery-reminders"} start the daily reminder fan-out now +export async function GET(request: Request) { + const denied = requireInternalToken(request); + if (denied) return denied; + const parked = await listDeadLetters(); + return NextResponse.json({ count: parked.length, parked }); +} + +export async function POST(request: Request) { + const denied = requireInternalToken(request); + if (denied) return denied; + + const body = (await request.json().catch(() => ({}))) as { action?: string; ids?: unknown }; + if (body.action === "replay") { + const ids = Array.isArray(body.ids) ? body.ids.filter((id): id is string => typeof id === "string") : undefined; + return NextResponse.json(await replayDeadLetters(ids), { status: 202 }); + } + if (body.action === "discovery-reminders") { + const day = new Date().toISOString().slice(0, 10); + const queued = await enqueueJobs({ type: "reminders.discoveries", id: `reminders:${day}:p1`, day, page: 1 }); + return NextResponse.json(queued, { status: 202 }); + } + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); +} diff --git a/src/app/api/internal/snapshots/refresh/route.ts b/src/app/api/internal/snapshots/refresh/route.ts new file mode 100644 index 00000000..8e29db0d --- /dev/null +++ b/src/app/api/internal/snapshots/refresh/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +import { requireInternalToken } from "@/lib/server/internalAuth"; +import { refreshSnapshots } from "@/src/server/snapshots/store"; + +export const dynamic = "force-dynamic"; + +// SSC-37: publish the public snapshots now instead of waiting for the cron +// (for example straight after the first deploy). +export async function POST(request: Request) { + const denied = requireInternalToken(request); + if (denied) return denied; + const report = await refreshSnapshots(); + return NextResponse.json(report, { status: report.results.every((r) => r.ok) ? 200 : 207 }); +} diff --git a/src/app/api/notify-my-discoveries/route.ts b/src/app/api/notify-my-discoveries/route.ts index 998f48a7..17eb6f9b 100644 --- a/src/app/api/notify-my-discoveries/route.ts +++ b/src/app/api/notify-my-discoveries/route.ts @@ -1,238 +1,50 @@ -import { NextRequest, NextResponse } from 'next/server'; -import webpush from 'web-push'; - -import { createPocketbaseAdminClient } from '@/lib/pocketbase/adminClient'; - -const SEND_TIMEOUT_MS = 8000; -const SEND_CONCURRENCY = 6; -const MAX_ENDPOINTS_PER_USER = 30; - -type PushSubscriptionRow = { - endpoint: string; - auth: string; - p256dh: string; - profileId: string; -}; - -async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timeoutHandle: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error(`Push send timed out after ${timeoutMs}ms`)), timeoutMs); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - -async function mapWithConcurrency( - items: T[], - concurrency: number, - fn: (item: T, index: number) => Promise -): Promise { - const results = new Array(items.length); - let currentIndex = 0; - - async function worker() { - while (true) { - const index = currentIndex++; - if (index >= items.length) return; - results[index] = await fn(items[index], index); - } - } - - const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); - await Promise.all(workers); - return results; +import { NextRequest, NextResponse } from "next/server"; + +import { getRouteUser } from "@/lib/server/routeAuth"; +import { enqueueJobs } from "@/src/server/jobs/queue"; +import type { Notification } from "@/src/server/jobs/types"; + +export const dynamic = "force-dynamic"; + +type DiscoveryInput = { anomalyId?: unknown; name?: unknown }; + +const clip = (value: unknown, max: number) => (typeof value === "string" ? value.trim().slice(0, max) : ""); + +function notificationFor(body: { customMessage?: Record; unclassifiedDiscoveries?: DiscoveryInput[] }): Notification | null { + if (body.customMessage) { + const title = clip(body.customMessage.title, 120); + const url = clip(body.customMessage.url, 200); + if (!title) return null; + return { title, body: clip(body.customMessage.body, 300), url: url.startsWith("/") ? url : "/structures/telescope" }; + } + const discoveries = Array.isArray(body.unclassifiedDiscoveries) ? body.unclassifiedDiscoveries : []; + if (!discoveries.length) return null; + const first = discoveries[0]; + const name = clip(first?.name, 120) || `Discovery #${String(first?.anomalyId ?? "")}`; + return discoveries.length === 1 + ? { title: "New Discovery Awaits Classification!", body: `Classify your discovery: ${name}`, url: "/structures/telescope" } + : { + title: `${discoveries.length} New Discoveries Await Classification!`, + body: `You have ${discoveries.length} unclassified discoveries waiting for analysis`, + url: "/structures/telescope", + }; } +// SSC-39: queues a push to the signed-in user's own devices and returns at +// once; the queue consumer talks to the push services. (Previously this took +// any `userId` from the body without authentication and sent inline.) export async function POST(request: NextRequest) { - try { - // Configure web-push with VAPID keys - webpush.setVapidDetails( - 'mailto:admin@starsailors.app', - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY! - ); - - // Get the discovery data and user info from the request - const requestBody = await request.json().catch(() => ({})); - const { userId, unclassifiedDiscoveries, customMessage } = requestBody; - - if (!userId) { - return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); - } - - const pb = await createPocketbaseAdminClient(); - - // Handle custom messages (like deployment notifications) - if (customMessage) { - console.log('Processing custom message notification for user:', userId); - - // Get user's push subscriptions - let subscriptions: PushSubscriptionRow[]; - try { - subscriptions = await pb.collection('push_subscriptions').getFullList({ - filter: pb.filter('profileId = {:id}', { id: userId }), - sort: '-createdAt', - }); - } catch (subError) { - console.error('Error fetching subscriptions:', subError); - return NextResponse.json({ - error: 'Failed to fetch push subscriptions', - details: String(subError) - }, { status: 500 }); - } - - if (!subscriptions || subscriptions.length === 0) { - return NextResponse.json({ - message: 'User has no push subscriptions' - }); - } - - // Deduplicate subscriptions by endpoint - const uniqueSubscriptions = new Map(); - subscriptions.forEach(sub => { - if (!uniqueSubscriptions.has(sub.endpoint)) { - uniqueSubscriptions.set(sub.endpoint, sub); - } - }); - - const deduplicatedSubscriptions = Array.from(uniqueSubscriptions.values()).slice(0, MAX_ENDPOINTS_PER_USER); - const skipped = Math.max(0, uniqueSubscriptions.size - deduplicatedSubscriptions.length); - - const payload = JSON.stringify({ - title: customMessage.title, - body: customMessage.body, - icon: 'https://github.com/Signal-K/client/blob/main/public/assets/Captn.jpg?raw=true', - url: customMessage.url || '/structures/telescope' - }); - - // Send notifications to all user's unique endpoints - const results = await mapWithConcurrency(deduplicatedSubscriptions, SEND_CONCURRENCY, async (subscription) => { - try { - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.p256dh - } - }; - - await withTimeout(webpush.sendNotification(pushSubscription, payload), SEND_TIMEOUT_MS); - return { success: true, endpoint: subscription.endpoint }; - } catch (pushError) { - return { success: false, endpoint: subscription.endpoint, error: String(pushError) }; - } - }); - - const successful = results.filter(r => r.success).length; - const failed = results.filter(r => !r.success).length; - - return NextResponse.json({ - message: `Sent ${successful} custom notifications, ${failed} failed`, - notificationsSent: successful, - notificationsFailed: failed, - attempted: deduplicatedSubscriptions.length, - skipped - }); - } - - if (!unclassifiedDiscoveries || unclassifiedDiscoveries.length === 0) { - return NextResponse.json({ - message: 'No unclassified discoveries to notify about', - unclassifiedCount: 0 - }); - } - - // Get user's push subscriptions - let subscriptions: PushSubscriptionRow[]; - try { - subscriptions = await pb.collection('push_subscriptions').getFullList({ - filter: pb.filter('profileId = {:id}', { id: userId }), - sort: '-createdAt', - }); - } catch (subError) { - console.error('Error fetching subscriptions:', subError); - return NextResponse.json({ - error: 'Failed to fetch push subscriptions', - details: String(subError) - }, { status: 500 }); - } - - if (!subscriptions || subscriptions.length === 0) { - return NextResponse.json({ - message: 'User has no push subscriptions', - unclassifiedCount: unclassifiedDiscoveries.length - }); - } - - // Deduplicate subscriptions by endpoint - const uniqueSubscriptions = new Map(); - subscriptions.forEach(sub => { - if (!uniqueSubscriptions.has(sub.endpoint)) { - uniqueSubscriptions.set(sub.endpoint, sub); - } - }); - - const deduplicatedSubscriptions = Array.from(uniqueSubscriptions.values()).slice(0, MAX_ENDPOINTS_PER_USER); - const skipped = Math.max(0, uniqueSubscriptions.size - deduplicatedSubscriptions.length); - - // Create notification message - const discoveryCount = unclassifiedDiscoveries.length; - const title = discoveryCount === 1 - ? 'New Discovery Awaits Classification!' - : `${discoveryCount} New Discoveries Await Classification!`; - - const firstDiscovery = unclassifiedDiscoveries[0]; - const messageBody = discoveryCount === 1 - ? `Classify your discovery: ${firstDiscovery.name || `Discovery #${firstDiscovery.anomalyId}`}` - : `You have ${discoveryCount} unclassified discoveries waiting for analysis`; - - const payload = JSON.stringify({ - title, - body: messageBody, - icon: 'https://github.com/Signal-K/client/blob/main/public/assets/Captn.jpg?raw=true', - url: '/structures/telescope' - }); - - - // Send notifications to all user's unique endpoints - const results = await mapWithConcurrency(deduplicatedSubscriptions, SEND_CONCURRENCY, async (subscription) => { - try { - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.p256dh - } - }; - - await withTimeout(webpush.sendNotification(pushSubscription, payload), SEND_TIMEOUT_MS); - return { success: true, endpoint: subscription.endpoint }; - } catch (pushError) { - return { success: false, endpoint: subscription.endpoint, error: String(pushError) }; - } - }); - - const successful = results.filter(r => r.success).length; - const failed = results.filter(r => !r.success).length; - - return NextResponse.json({ - message: `Sent ${successful} notifications, ${failed} failed`, - unclassifiedCount: discoveryCount, - notificationsSent: successful, - notificationsFailed: failed, - attempted: deduplicatedSubscriptions.length, - skipped, - discoveries: unclassifiedDiscoveries - }); - - } catch (error) { - console.error('Error in manual notification API:', error); - return NextResponse.json({ - error: 'Internal server error' - }, { status: 500 }); - } + const { user, authError } = await getRouteUser(); + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const notification = notificationFor(body ?? {}); + if (!notification) { + return NextResponse.json({ status: "skipped", message: "Nothing to notify about" }); + } + + const { mode, ids } = await enqueueJobs({ type: "push.user", userId: user.id, notification }); + return NextResponse.json({ status: "queued", mode, jobId: ids[0] }, { status: 202 }); } diff --git a/src/app/api/public/snapshots/[name]/route.ts b/src/app/api/public/snapshots/[name]/route.ts new file mode 100644 index 00000000..029ce388 --- /dev/null +++ b/src/app/api/public/snapshots/[name]/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; + +import { isSnapshotName, readSnapshot, snapshotDefinitions, snapshotHeaders } from "@/src/server/snapshots/store"; + +export const dynamic = "force-dynamic"; + +// SSC-37: public, precomputed data. Reads the published KV snapshot only; the +// cron trigger does the PocketBase work. 503 until the first refresh lands. +export async function GET(_request: Request, { params }: { params: Promise<{ name: string }> }) { + const { name } = await params; + if (!isSnapshotName(name) || !snapshotDefinitions[name].public) { + return NextResponse.json({ error: "Unknown snapshot" }, { status: 404 }); + } + + const snapshot = await readSnapshot(name); + const body = { + name, + status: snapshot.status, + generatedAt: snapshot.generatedAt, + ageSeconds: snapshot.ageSeconds, + data: snapshot.data, + }; + return NextResponse.json(body, { status: snapshot.status === "missing" ? 503 : 200, headers: snapshotHeaders(snapshot) }); +} diff --git a/src/app/api/public/status/route.ts b/src/app/api/public/status/route.ts new file mode 100644 index 00000000..5bb36554 --- /dev/null +++ b/src/app/api/public/status/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +import { readAllSnapshots } from "@/src/server/snapshots/store"; + +export const dynamic = "force-dynamic"; + +// SSC-37: freshness of every published snapshot, including the last refresh +// error, so a failing cron is visible without Cloudflare dashboard access. +export async function GET() { + const snapshots = await readAllSnapshots(); + const healthy = snapshots.every((s) => s.status === "fresh"); + return NextResponse.json( + { + healthy, + snapshots: snapshots.map(({ name, status, generatedAt, ageSeconds, lastAttemptAt, lastError }) => ({ + name, + status, + generatedAt, + ageSeconds, + lastAttemptAt, + lastError, + })), + }, + { headers: { "cache-control": "public, max-age=30" } }, + ); +} diff --git a/src/app/api/send-test-notification/route.ts b/src/app/api/send-test-notification/route.ts index 6c8e918f..4374dc1c 100644 --- a/src/app/api/send-test-notification/route.ts +++ b/src/app/api/send-test-notification/route.ts @@ -1,136 +1,24 @@ -import { NextRequest, NextResponse } from 'next/server'; -import webpush from 'web-push'; +import { NextRequest, NextResponse } from "next/server"; -import { createPocketbaseAdminClient } from '@/lib/pocketbase/adminClient'; +import { requireInternalToken } from "@/lib/server/internalAuth"; +import { enqueueJobs } from "@/src/server/jobs/queue"; -const SEND_TIMEOUT_MS = 8000; -const SEND_CONCURRENCY = 8; -const MAX_NOTIFICATIONS_PER_REQUEST = 200; - -type PushSubscriptionRow = { - endpoint: string; - auth: string; - p256dh: string; - profileId: string; -}; - -function dedupeByEndpoint(subscriptions: PushSubscriptionRow[]) { - const unique = new Map(); - for (const sub of subscriptions) { - if (!unique.has(sub.endpoint)) unique.set(sub.endpoint, sub); - } - return Array.from(unique.values()); -} - -async function withTimeout(promise: Promise, timeoutMs: number): Promise { - let timeoutHandle: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => reject(new Error(`Push send timed out after ${timeoutMs}ms`)), timeoutMs); - }); - try { - return await Promise.race([promise, timeoutPromise]); - } finally { - if (timeoutHandle) clearTimeout(timeoutHandle); - } -} - -async function mapWithConcurrency( - items: T[], - concurrency: number, - fn: (item: T, index: number) => Promise -): Promise { - const results = new Array(items.length); - let currentIndex = 0; - - async function worker() { - while (true) { - const index = currentIndex++; - if (index >= items.length) return; - results[index] = await fn(items[index], index); - } - } - - const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker()); - await Promise.all(workers); - return results; -} +export const dynamic = "force-dynamic"; +// SSC-39: operator broadcast to every subscribed device. Queues a paged +// fan-out (one push.user job per user) instead of sending inline, and needs +// INTERNAL_JOBS_TOKEN (it was previously open to anyone). export async function POST(request: NextRequest) { - try { - // Configure web-push with your VAPID keys - webpush.setVapidDetails( - 'mailto:admin@starsailors.app', - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY! - ); - - const pb = await createPocketbaseAdminClient(); - - // Get all push subscriptions, but deduplicate by endpoint to avoid sending multiple notifications to the same device - let allSubscriptions: PushSubscriptionRow[]; - try { - allSubscriptions = await pb.collection('push_subscriptions').getFullList({ - sort: '-createdAt', - }); - } catch (error) { - console.error('Error fetching subscriptions:', error); - return NextResponse.json({ - error: 'Failed to fetch subscriptions', - details: String(error) - }, { status: 500 }); - } - - if (!allSubscriptions || allSubscriptions.length === 0) { - return NextResponse.json({ message: 'No subscriptions found' }, { status: 200 }); - } - - const deduped = dedupeByEndpoint(allSubscriptions); - const subscriptions = deduped.slice(0, MAX_NOTIFICATIONS_PER_REQUEST); - const skipped = Math.max(0, deduped.length - subscriptions.length); - - // Parse request body for custom message - const body = await request.json().catch(() => ({})); - const title = body.title || 'Test Notification'; - const message = body.message || 'This is a test push notification!'; - const url = body.url || '/'; - - const payload = JSON.stringify({ - title, - body: message, - url, - icon: 'https://github.com/Signal-K/client/blob/main/public/assets/Captn.jpg?raw=true' - }); - - // Send notifications with bounded concurrency and timeout guards. - const results = await mapWithConcurrency(subscriptions, SEND_CONCURRENCY, async (subscription) => { - try { - const pushSubscription = { - endpoint: subscription.endpoint, - keys: { - auth: subscription.auth, - p256dh: subscription.p256dh - } - }; - - await withTimeout(webpush.sendNotification(pushSubscription, payload), SEND_TIMEOUT_MS); - return { success: true, userId: subscription.profileId }; - } catch (error) { - return { success: false, userId: subscription.profileId, error: String(error) }; - } - }); - - const successful = results.filter(r => r.success).length; - const failed = results.filter(r => !r.success).length; - - return NextResponse.json({ - message: `Sent ${successful} notifications successfully, ${failed} failed`, - attempted: subscriptions.length, - skipped, - results - }); - - } catch (error) { - console.error('Error sending test notifications:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } + const denied = requireInternalToken(request); + if (denied) return denied; + + const body = await request.json().catch(() => ({})); + const notification = { + title: typeof body?.title === "string" && body.title ? body.title.slice(0, 120) : "Test Notification", + body: typeof body?.message === "string" && body.message ? body.message.slice(0, 300) : "This is a test push notification!", + url: typeof body?.url === "string" && body.url.startsWith("/") ? body.url : "/", + }; + const runId = `broadcast-${crypto.randomUUID().slice(0, 8)}`; + const queued = await enqueueJobs({ type: "push.broadcast", id: `${runId}:p1`, notification, runId, page: 1 }); + return NextResponse.json({ status: "queued", runId, ...queued }, { status: 202 }); } diff --git a/src/app/classify/[id]/ClassifyPageClient.tsx b/src/app/classify/[id]/ClassifyPageClient.tsx new file mode 100644 index 00000000..82c9ae52 --- /dev/null +++ b/src/app/classify/[id]/ClassifyPageClient.tsx @@ -0,0 +1,10 @@ +"use client"; + +import ClientClassificationPage from "@/src/components/projects/(classifications)/NextScene"; +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; + +export default function ClassifyPageClient() { + const params = useRouteParams<"id">("/classify/[id]"); + if (!params?.id) return null; + return ; +} diff --git a/src/app/classify/[id]/page.tsx b/src/app/classify/[id]/page.tsx index 3781fb5e..5a1a1cb8 100644 --- a/src/app/classify/[id]/page.tsx +++ b/src/app/classify/[id]/page.tsx @@ -1,14 +1,11 @@ -import ClientClassificationPage from "@/src/components/projects/(classifications)/NextScene"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -type Props = { - params: Promise<{ id: string }>; -}; +import ClassifyPageClient from "./ClassifyPageClient"; -export default async function Page(props: Props) { - const params = await props.params; - const { id } = params; +export function generateStaticParams() { + return placeholderParams("id"); +} - if (!id) return null; // or trigger notFound(); - - return ; -}; \ No newline at end of file +export default function Page() { + return ; +} diff --git a/src/app/extraction/[id]/ExtractionPageClient.tsx b/src/app/extraction/[id]/ExtractionPageClient.tsx new file mode 100644 index 00000000..f3a72594 --- /dev/null +++ b/src/app/extraction/[id]/ExtractionPageClient.tsx @@ -0,0 +1,147 @@ +"use client" + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { ExtractionScene } from "@/src/components/deployment/extraction/ex-scene" +import { useAuthUser } from "@/src/hooks/useAuthUser" +import MainHeader from "@/src/components/layout/Header/MainHeader" +import UseDarkMode from "@/src/hooks/useDarkMode" +import { usePageData } from "@/src/hooks/usePageData" +import { getExtractionDepositAction, completeExtractionAction } from "@/src/app/actions/gameplay"; + +export default function ExtractionPage() { + const params = useRouteParams<"id">("/extraction/[id]") ?? {} + const router = useRouter() + const { user } = useAuthUser() + const { isDark, toggleDarkMode } = UseDarkMode() + const { activityFeed, otherClassifications } = usePageData() + + const [deposit, setDeposit] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [notificationsOpen, setNotificationsOpen] = useState(false) + + useEffect(() => { + async function fetchDeposit() { + if (!params.id) return + + try { + const result = await getExtractionDepositAction(Number(params.id)); + if (!result.ok) { + setError(result.error); + return; + } + setDeposit(result.deposit ?? null); + } catch (err) { + console.error("Error fetching deposit:", err) + setError("Failed to load mineral deposit") + } finally { + setLoading(false) + } + } + + fetchDeposit() + }, [params.id, user]) + + const handleExtractionComplete = async (extractedQuantity: number, purity: number) => { + if (!user?.id || !deposit) return + + try { + const result = await completeExtractionAction({ depositId: deposit.id, extractedQuantity, purity }); + if (!result.ok) { + console.error("Error adding to mineral inventory:", result.error); + alert("Failed to save to inventory. Please try again."); + return + } + + setTimeout(() => { router.push("/inventory") }, 3000) + } catch (err) { + console.error("Error completing extraction:", err) + alert("An unexpected error occurred. Please try again.") + } + } + + if (loading) { + return ( +
+ setNotificationsOpen((open) => !open)} + activityFeed={activityFeed} + otherClassifications={otherClassifications} + /> +
+
+
+

Loading mineral deposit...

+
+
+
+ ) + } + + if (error || !deposit) { + return ( +
+ setNotificationsOpen((open) => !open)} + activityFeed={activityFeed} + otherClassifications={otherClassifications} + /> +
+
+

{error || "Deposit not found"}

+ +
+
+
+ ) + } + + // Determine project type from mineral configuration + const mineralType = deposit.mineral_configuration.type + let projectType: "P4" | "cloudspotting" | "JVH" | "AI4M" = "AI4M" + + if (["dust", "soil", "water-vapour"].includes(mineralType)) { + projectType = "P4" + } else if (["water-ice", "co2-ice"].includes(mineralType)) { + projectType = "cloudspotting" + } else if (["metallic-hydrogen", "metallic-helium", "methane", "ammonia"].includes(mineralType)) { + projectType = "JVH" + } + + return ( +
+ setNotificationsOpen((open) => !open)} + activityFeed={activityFeed} + otherClassifications={otherClassifications} + /> +
+ +
+
+ ) +}; diff --git a/src/app/extraction/[id]/page.tsx b/src/app/extraction/[id]/page.tsx index caaca9f3..711afcdd 100644 --- a/src/app/extraction/[id]/page.tsx +++ b/src/app/extraction/[id]/page.tsx @@ -1,146 +1,11 @@ -"use client" +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import { useEffect, useState } from "react" -import { useParams, useRouter } from "next/navigation" -import { ExtractionScene } from "@/src/components/deployment/extraction/ex-scene" -import { useAuthUser } from "@/src/hooks/useAuthUser" -import MainHeader from "@/src/components/layout/Header/MainHeader" -import UseDarkMode from "@/src/hooks/useDarkMode" -import { usePageData } from "@/src/hooks/usePageData" -import { getExtractionDepositAction, completeExtractionAction } from "@/src/app/actions/gameplay"; +import ExtractionPageClient from "./ExtractionPageClient"; -export default function ExtractionPage() { - const params = useParams() - const router = useRouter() - const { user } = useAuthUser() - const { isDark, toggleDarkMode } = UseDarkMode() - const { activityFeed, otherClassifications } = usePageData() +export function generateStaticParams() { + return placeholderParams("id"); +} - const [deposit, setDeposit] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [notificationsOpen, setNotificationsOpen] = useState(false) - - useEffect(() => { - async function fetchDeposit() { - if (!params.id) return - - try { - const result = await getExtractionDepositAction(Number(params.id)); - if (!result.ok) { - setError(result.error); - return; - } - setDeposit(result.deposit ?? null); - } catch (err) { - console.error("Error fetching deposit:", err) - setError("Failed to load mineral deposit") - } finally { - setLoading(false) - } - } - - fetchDeposit() - }, [params.id, user]) - - const handleExtractionComplete = async (extractedQuantity: number, purity: number) => { - if (!user?.id || !deposit) return - - try { - const result = await completeExtractionAction({ depositId: deposit.id, extractedQuantity, purity }); - if (!result.ok) { - console.error("Error adding to mineral inventory:", result.error); - alert("Failed to save to inventory. Please try again."); - return - } - - setTimeout(() => { router.push("/inventory") }, 3000) - } catch (err) { - console.error("Error completing extraction:", err) - alert("An unexpected error occurred. Please try again.") - } - } - - if (loading) { - return ( -
- setNotificationsOpen((open) => !open)} - activityFeed={activityFeed} - otherClassifications={otherClassifications} - /> -
-
-
-

Loading mineral deposit...

-
-
-
- ) - } - - if (error || !deposit) { - return ( -
- setNotificationsOpen((open) => !open)} - activityFeed={activityFeed} - otherClassifications={otherClassifications} - /> -
-
-

{error || "Deposit not found"}

- -
-
-
- ) - } - - // Determine project type from mineral configuration - const mineralType = deposit.mineral_configuration.type - let projectType: "P4" | "cloudspotting" | "JVH" | "AI4M" = "AI4M" - - if (["dust", "soil", "water-vapour"].includes(mineralType)) { - projectType = "P4" - } else if (["water-ice", "co2-ice"].includes(mineralType)) { - projectType = "cloudspotting" - } else if (["metallic-hydrogen", "metallic-helium", "methane", "ammonia"].includes(mineralType)) { - projectType = "JVH" - } - - return ( -
- setNotificationsOpen((open) => !open)} - activityFeed={activityFeed} - otherClassifications={otherClassifications} - /> -
- -
-
- ) -}; +export default function Page() { + return ; +} diff --git a/src/app/leaderboards/sunspots/page.tsx b/src/app/leaderboards/sunspots/page.tsx index a10bbffe..e7ca050d 100644 --- a/src/app/leaderboards/sunspots/page.tsx +++ b/src/app/leaderboards/sunspots/page.tsx @@ -22,6 +22,8 @@ export default function SunspotLeaderboardPage() { const [probeLeaders, setProbeLeaders] = useState([]); const [classificationLeaders, setClassificationLeaders] = useState([]); const [loading, setLoading] = useState(true); + // SSC-37: rankings are precomputed every few minutes; say how old they are. + const [freshness, setFreshness] = useState(null); useEffect(() => { async function fetchLeaderboards() { @@ -33,8 +35,14 @@ export default function SunspotLeaderboardPage() { }); const result = await response.json().catch(() => ({})); if (!response.ok) { + if (response.status === 503) setFreshness("Rankings are being prepared. Check back in a few minutes."); throw new Error(result?.error || "Failed to fetch leaderboard data"); } + if (typeof result?.generatedAt === "string") { + const minutes = Math.max(0, Math.round((Date.now() - Date.parse(result.generatedAt)) / 60_000)); + const ago = minutes < 1 ? "just now" : `${minutes} min ago`; + setFreshness(result.status === "stale" ? `Rankings may be out of date (updated ${ago}).` : `Updated ${ago}.`); + } setProbeLeaders(Array.isArray(result?.probeLeaders) ? result.probeLeaders : []); setClassificationLeaders(Array.isArray(result?.classificationLeaders) ? result.classificationLeaders : []); @@ -82,6 +90,7 @@ export default function SunspotLeaderboardPage() { Sunspot Mission Leaderboards + {freshness ?

{freshness}

: null} diff --git a/src/app/next/[id]/LegacyClassifyRedirect.tsx b/src/app/next/[id]/LegacyClassifyRedirect.tsx new file mode 100644 index 00000000..3e511aa3 --- /dev/null +++ b/src/app/next/[id]/LegacyClassifyRedirect.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { DynamicRouteRedirect } from "@/src/components/routing/DynamicRouteRedirect"; + +const to = (id: string) => `/classify/${encodeURIComponent(id)}`; + +export default function LegacyClassifyRedirect() { + return ; +} diff --git a/src/app/next/[id]/page.tsx b/src/app/next/[id]/page.tsx index d6a43a48..1d55d094 100644 --- a/src/app/next/[id]/page.tsx +++ b/src/app/next/[id]/page.tsx @@ -1,6 +1,11 @@ -import { redirect } from "next/navigation"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -export default async function LegacyClassifyPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - redirect(`/classify/${id}`); +import LegacyClassifyRedirect from "./LegacyClassifyRedirect"; + +export function generateStaticParams() { + return placeholderParams("id"); +} + +export default function LegacyClassifyPage() { + return ; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 87fa6c65..7dc846eb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,6 +6,7 @@ import { ArrowRight, ExternalLink } from "lucide-react"; import { cn } from "@/src/lib/utils"; import { LandingAnalytics } from "./(landing)/_components/LandingAnalytics"; import { LandingMobileMenu } from "./(landing)/_components/LandingMobileMenu"; +import { LandingSignedInRedirect } from "./(landing)/_components/LandingSignedInRedirect"; import { LandingStats, LandingStatsFallback } from "./(landing)/_components/LandingStats"; export const metadata: Metadata = { @@ -64,12 +65,14 @@ const projects = [ }, ] as const; -// Signed-in visitors are redirected to /game by middleware, so the page itself -// needs no Clerk read and can render as static HTML. +// Signed-in visitors are redirected to /game (by middleware under `next dev`, +// by LandingSignedInRedirect in the static export), so the page itself needs +// no Clerk read and can render as static HTML. export default function HomePage() { return (
+
diff --git a/src/app/planets/[id]/PlanetRedirect.tsx b/src/app/planets/[id]/PlanetRedirect.tsx new file mode 100644 index 00000000..b878e97b --- /dev/null +++ b/src/app/planets/[id]/PlanetRedirect.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { DynamicRouteRedirect } from "@/src/components/routing/DynamicRouteRedirect"; + +const to = (id: string) => `/planets/edit/${encodeURIComponent(id)}`; + +export default function PlanetRedirect() { + return ; +} diff --git a/src/app/planets/[id]/page.tsx b/src/app/planets/[id]/page.tsx index a4b38a99..4915952a 100644 --- a/src/app/planets/[id]/page.tsx +++ b/src/app/planets/[id]/page.tsx @@ -1,6 +1,11 @@ -import { redirect } from "next/navigation"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -export default async function PlanetRedirect({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - redirect(`/planets/edit/${id}`); +import PlanetRedirect from "./PlanetRedirect"; + +export function generateStaticParams() { + return placeholderParams("id"); +} + +export default function Page() { + return ; } diff --git a/src/app/planets/clouds/[id]/CloudPageClient.tsx b/src/app/planets/clouds/[id]/CloudPageClient.tsx new file mode 100644 index 00000000..eae58893 --- /dev/null +++ b/src/app/planets/clouds/[id]/CloudPageClient.tsx @@ -0,0 +1,10 @@ +"use client"; + +import CloudDetailsClient from "@/src/components/discovery/planets/client"; +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; + +export default function CloudPageClient() { + const params = useRouteParams<"id">("/planets/clouds/[id]"); + if (!params?.id) return null; + return ; +} diff --git a/src/app/planets/clouds/[id]/page.tsx b/src/app/planets/clouds/[id]/page.tsx index cf888c59..0e54aee5 100644 --- a/src/app/planets/clouds/[id]/page.tsx +++ b/src/app/planets/clouds/[id]/page.tsx @@ -1,12 +1,11 @@ -import CloudDetailsClient from '@/src/components/discovery/planets/client'; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -type MyCloudPageProps = { - params: Promise<{ - id: string; - }>; +import CloudPageClient from "./CloudPageClient"; + +export function generateStaticParams() { + return placeholderParams("id"); } -export default async function Page(props: MyCloudPageProps) { - const params = await props.params; - return ; -} \ No newline at end of file +export default function Page() { + return ; +} diff --git a/src/app/planets/edit/[id]/EditPlanetClient.tsx b/src/app/planets/edit/[id]/EditPlanetClient.tsx new file mode 100644 index 00000000..cb2b026d --- /dev/null +++ b/src/app/planets/edit/[id]/EditPlanetClient.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import React, { useEffect, useState } from "react"; +import { useSession } from "@/src/lib/auth/session-context"; +import Navbar from "@/src/components/layout/Navbar"; +import { PostCardSingleWithGeneratorEditMode } from "@/src/components/social/posts/PostWithGen"; + +interface Classification { + id: number; + content: string | null; + author: string | null; + anomaly?: { id: number; content: string | null } | null; + media?: (string | { uploadUrl?: string })[] | null; + classificationtype: string | null; + classificationConfiguration?: any; + created_at: string; + title?: string; + votes?: number; + category?: string; + tags?: string[]; + images?: string[]; +}; + +export default function EditPlanetAnomaly() { + const params = useRouteParams<"id">("/planets/edit/[id]"); + const session = useSession(); + + const [classification, setClassification] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!params) return; + let isMounted = true; // Prevent setting state if unmounted + + const fetchClassification = async () => { + if (!params?.id || !session) { + setLoading(false); + return; + } + + const response = await fetch(`/api/gameplay/classifications/${params?.id}`, { + cache: "no-store", + }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload?.classification) { + if (isMounted) setError("Failed to fetch classification."); + setLoading(false); + return; + } + + const data = payload.classification as Classification; + + if (data && isMounted) { + // Extract media URLs correctly + let images: string[] = []; + const media = data.media as (string | { uploadUrl?: string })[] | { uploadUrl?: string } | null | undefined; + + if (Array.isArray(media)) { + images = media + .map((item) => (typeof item === "string" ? item : item?.uploadUrl)) + .filter((value): value is string => typeof value === "string" && value.length > 0); + } else if (media && typeof media === "object" && "uploadUrl" in media && media.uploadUrl) { + images.push(media.uploadUrl); + } + + setClassification({ + ...data, + images, + votes: data.classificationConfiguration?.votes || 0, + }); + } + + setLoading(false); + }; + + fetchClassification(); + + return () => { + isMounted = false; // Cleanup function to avoid setting state on unmounted component + }; + }, [params, session]); + + // const handleCloudSummaryUpdate = (summary: AggregatedCloud) => { + // setCloudSummary(summary); + // }; + + if (loading) { + return ( +

Loading classification data...

+ ); + }; + + if (error) { + return ( +

{error}

+ ); + }; + + if (!classification) { + return ( +

No classification found.

+ ); + }; + + return ( +
+ + Barren (default planet type) background + + + {/* Related Classifications */} + {/* {relatedClassifications.length > 0 && ( +
+

Related Classifications

+
    + {relatedClassifications.map((related) => ( +
  • {related.classificationtype} - {related.id}
  • + ))} +
+
+ )} */} +
+ ); +}; diff --git a/src/app/planets/edit/[id]/page.tsx b/src/app/planets/edit/[id]/page.tsx index 7fcebc34..8d4a55c0 100644 --- a/src/app/planets/edit/[id]/page.tsx +++ b/src/app/planets/edit/[id]/page.tsx @@ -1,141 +1,11 @@ -"use client"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import React, { useEffect, useState, use } from "react"; -import { useSession } from "@/src/lib/auth/session-context"; -import Navbar from "@/src/components/layout/Navbar"; -import { PostCardSingleWithGeneratorEditMode } from "@/src/components/social/posts/PostWithGen"; +import EditPlanetClient from "./EditPlanetClient"; -interface Classification { - id: number; - content: string | null; - author: string | null; - anomaly?: { id: number; content: string | null } | null; - media?: (string | { uploadUrl?: string })[] | null; - classificationtype: string | null; - classificationConfiguration?: any; - created_at: string; - title?: string; - votes?: number; - category?: string; - tags?: string[]; - images?: string[]; -}; +export function generateStaticParams() { + return placeholderParams("id"); +} -export default function EditPlanetAnomaly(props: { params: Promise<{ id: string }> }) { - const params = use(props.params); - const session = useSession(); - - const [classification, setClassification] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let isMounted = true; // Prevent setting state if unmounted - - const fetchClassification = async () => { - if (!params.id || !session) { - setLoading(false); - return; - } - - const response = await fetch(`/api/gameplay/classifications/${params.id}`, { - cache: "no-store", - }); - const payload = await response.json().catch(() => null); - if (!response.ok || !payload?.classification) { - if (isMounted) setError("Failed to fetch classification."); - setLoading(false); - return; - } - - const data = payload.classification as Classification; - - if (data && isMounted) { - // Extract media URLs correctly - let images: string[] = []; - const media = data.media as (string | { uploadUrl?: string })[] | { uploadUrl?: string } | null | undefined; - - if (Array.isArray(media)) { - images = media - .map((item) => (typeof item === "string" ? item : item?.uploadUrl)) - .filter((value): value is string => typeof value === "string" && value.length > 0); - } else if (media && typeof media === "object" && "uploadUrl" in media && media.uploadUrl) { - images.push(media.uploadUrl); - } - - setClassification({ - ...data, - images, - votes: data.classificationConfiguration?.votes || 0, - }); - } - - setLoading(false); - }; - - fetchClassification(); - - return () => { - isMounted = false; // Cleanup function to avoid setting state on unmounted component - }; - }, [params.id, session]); - - // const handleCloudSummaryUpdate = (summary: AggregatedCloud) => { - // setCloudSummary(summary); - // }; - - if (loading) { - return ( -

Loading classification data...

- ); - }; - - if (error) { - return ( -

{error}

- ); - }; - - if (!classification) { - return ( -

No classification found.

- ); - }; - - return ( -
- - Barren (default planet type) background - - - {/* Related Classifications */} - {/* {relatedClassifications.length > 0 && ( -
-

Related Classifications

-
    - {relatedClassifications.map((related) => ( -
  • {related.classificationtype} - {related.id}
  • - ))} -
-
- )} */} -
- ); -}; +export default function Page() { + return ; +} diff --git a/src/app/posts/[id]/SinglePostClient.tsx b/src/app/posts/[id]/SinglePostClient.tsx new file mode 100644 index 00000000..4c48b5e8 --- /dev/null +++ b/src/app/posts/[id]/SinglePostClient.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import React, { useEffect, useState } from "react"; +import { SimplePostSingle } from "@/src/components/social/posts/SimplePostSingle"; +import { useRouter } from "next/navigation"; +import { SourceClassificationCallout } from "@/src/components/classification/SourceClassificationCallout"; +import MainHeader from "@/src/components/layout/Header/MainHeader"; +import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; +import UseDarkMode from "@/src/hooks/useDarkMode"; + +interface Classification { + id: number; + created_at: string; + content: string | null; + author: string | null; + anomaly: number | null; + media: string[] | null; + classificationtype: string | null; + classificationConfiguration?: any | null; +}; + +export default function SinglePostPage() { + const params = useRouteParams<"id">("/posts/[id]"); + const router = useRouter(); + const { isDark, toggleDarkMode } = UseDarkMode(); + + const [classification, setClassification] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sourceClassificationMedia, setSourceClassificationMedia] = useState([]); + + useEffect(() => { + if (!params?.id) return; + const fetchClassification = async () => { + const id = parseInt(params?.id ?? "", 10); + if (isNaN(id)) { + setError("Invalid classification ID."); + setLoading(false); + return; + } + + try { + const response = await fetch(`/api/gameplay/classifications/${id}`, { cache: "no-store" }); + const result = await response.json().catch(() => ({})); + + if (!response.ok || !result?.classification) { + setError("Classification not found."); + } else { + const data = result.classification; + const flattenedMedia = (data.media || []) + .flat() + .filter((url: string) => typeof url === "string" && url.startsWith("http")); + + setClassification({ + ...data, + media: flattenedMedia, + }); + + const extractedMedia: string[] = []; + const sourceMedia = Array.isArray(result?.sourceMedia) ? result.sourceMedia : []; + for (const item of sourceMedia) { + if (Array.isArray(item) && typeof item[0] === "string" && item[0].startsWith("http")) { + extractedMedia.push(item[0]); + } else if (typeof item === "string" && item.startsWith("http")) { + extractedMedia.push(item); + } + } + setSourceClassificationMedia(extractedMedia); + } + } catch (err) { + console.error(err); + setError("An error occurred while fetching the classification."); + } finally { + setLoading(false); + } + }; + + fetchClassification(); + }, [params?.id]); + + if (loading) + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+
Loading classification...
+
+
+ ); + + if (error) return

{error}

; + if (!classification) return

No classification found.

; + + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+ {classification.author && ( +
+ {/* Source Classification Callout */} + {classification?.classificationConfiguration?.source_classification_id && ( + + )} + + + {/*
+ +
*/} +
+ )} +
+
+ ); +}; diff --git a/src/app/posts/[id]/page.tsx b/src/app/posts/[id]/page.tsx index 364561ec..10f3ad34 100644 --- a/src/app/posts/[id]/page.tsx +++ b/src/app/posts/[id]/page.tsx @@ -1,157 +1,11 @@ -"use client"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import React, { useEffect, useState, use } from "react"; -import { SimplePostSingle } from "@/src/components/social/posts/SimplePostSingle"; -import { useRouter } from "next/navigation"; -import { SourceClassificationCallout } from "@/src/components/classification/SourceClassificationCallout"; -import MainHeader from "@/src/components/layout/Header/MainHeader"; -import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; -import UseDarkMode from "@/src/hooks/useDarkMode"; +import SinglePostClient from "./SinglePostClient"; -interface Classification { - id: number; - created_at: string; - content: string | null; - author: string | null; - anomaly: number | null; - media: string[] | null; - classificationtype: string | null; - classificationConfiguration?: any | null; -}; +export function generateStaticParams() { + return placeholderParams("id"); +} -export default function SinglePostPage(props: { params: Promise<{ id: string }> }) { - const params = use(props.params); - const router = useRouter(); - const { isDark, toggleDarkMode } = UseDarkMode(); - - const [classification, setClassification] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [sourceClassificationMedia, setSourceClassificationMedia] = useState([]); - - useEffect(() => { - const fetchClassification = async () => { - const id = parseInt(params.id, 10); - if (isNaN(id)) { - setError("Invalid classification ID."); - setLoading(false); - return; - } - - try { - const response = await fetch(`/api/gameplay/classifications/${id}`, { cache: "no-store" }); - const result = await response.json().catch(() => ({})); - - if (!response.ok || !result?.classification) { - setError("Classification not found."); - } else { - const data = result.classification; - const flattenedMedia = (data.media || []) - .flat() - .filter((url: string) => typeof url === "string" && url.startsWith("http")); - - setClassification({ - ...data, - media: flattenedMedia, - }); - - const extractedMedia: string[] = []; - const sourceMedia = Array.isArray(result?.sourceMedia) ? result.sourceMedia : []; - for (const item of sourceMedia) { - if (Array.isArray(item) && typeof item[0] === "string" && item[0].startsWith("http")) { - extractedMedia.push(item[0]); - } else if (typeof item === "string" && item.startsWith("http")) { - extractedMedia.push(item); - } - } - setSourceClassificationMedia(extractedMedia); - } - } catch (err) { - console.error(err); - setError("An error occurred while fetching the classification."); - } finally { - setLoading(false); - } - }; - - fetchClassification(); - }, [params.id]); - - if (loading) - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
-
Loading classification...
-
-
- ); - - if (error) return

{error}

; - if (!classification) return

No classification found.

; - - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
- {classification.author && ( -
- {/* Source Classification Callout */} - {classification?.classificationConfiguration?.source_classification_id && ( - - )} - - - {/*
- -
*/} -
- )} -
-
- ); -}; +export default function Page() { + return ; +} diff --git a/src/app/posts/surveyor/[id]/SurveyorPostClient.tsx b/src/app/posts/surveyor/[id]/SurveyorPostClient.tsx new file mode 100644 index 00000000..8cff53be --- /dev/null +++ b/src/app/posts/surveyor/[id]/SurveyorPostClient.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import React, { useEffect, useState } from "react"; +import PostCard from "@/src/components/social/posts/TestPostCard"; +import StructuresOnPlanet from "@/src/components/deployment/structures/Structures"; +import MainHeader from "@/src/components/layout/Header/MainHeader"; +import UseDarkMode from "@/src/hooks/useDarkMode"; +import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; + +interface Classification { + id: number; + created_at: string; + content: string | null; + author: string | null; + anomaly: number | null; + media: string[] | null; + classificationtype: string | null; + classificationConfig?: any | null; +}; + +export default function SurveyorPostPage() { + const params = useRouteParams<"id">("/posts/surveyor/[id]"); + const [classification, setClassification] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { isDark, toggleDarkMode } = UseDarkMode(); + + useEffect(() => { + if (!params?.id) return; + const fetchClassification = async () => { + const id = parseInt(params?.id ?? "", 10); + if (isNaN(id)) { + setError("Invalid classification ID."); + setLoading(false); + return; + }; + + try { + const response = await fetch(`/api/gameplay/classifications/${id}`, { cache: "no-store" }); + const result = await response.json().catch(() => ({})); + const data = result?.classification; + + if (!response.ok || !data) { + setError("Classification not found."); + } else { + const flattenedMedia = (data.media || []) + .flat() + .filter( + (url: string) => typeof url === "string" && url.startsWith("http") + ); + + setClassification({ + ...data, + media: flattenedMedia, + }); + } + } catch (err) { + console.error(err); + setError("An error occurred while fetching the classification."); + } finally { + setLoading(false); + } + }; + + fetchClassification(); + }, [params?.id]); + + if (loading) + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+

Loading classification...

+
+
+ ); + + if (error) return

{error}

; + if (!classification) return

No classification found.

; + + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+ {classification.author && ( +
+ + {/* */} +
+ )} +
+
+ ); +} diff --git a/src/app/posts/surveyor/[id]/page.tsx b/src/app/posts/surveyor/[id]/page.tsx index e0a2ce52..6ea214b5 100644 --- a/src/app/posts/surveyor/[id]/page.tsx +++ b/src/app/posts/surveyor/[id]/page.tsx @@ -1,133 +1,11 @@ -'use client'; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import React, { useEffect, useState, use } from "react"; -import PostCard from "@/src/components/social/posts/TestPostCard"; -import StructuresOnPlanet from "@/src/components/deployment/structures/Structures"; -import MainHeader from "@/src/components/layout/Header/MainHeader"; -import UseDarkMode from "@/src/hooks/useDarkMode"; -import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; +import SurveyorPostClient from "./SurveyorPostClient"; -interface Classification { - id: number; - created_at: string; - content: string | null; - author: string | null; - anomaly: number | null; - media: string[] | null; - classificationtype: string | null; - classificationConfig?: any | null; -}; +export function generateStaticParams() { + return placeholderParams("id"); +} -export default function SurveyorPostPage(props: { params: Promise<{ id: string }> }) { - const params = use(props.params); - const [classification, setClassification] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const { isDark, toggleDarkMode } = UseDarkMode(); - - useEffect(() => { - const fetchClassification = async () => { - const id = parseInt(params.id, 10); - if (isNaN(id)) { - setError("Invalid classification ID."); - setLoading(false); - return; - }; - - try { - const response = await fetch(`/api/gameplay/classifications/${id}`, { cache: "no-store" }); - const result = await response.json().catch(() => ({})); - const data = result?.classification; - - if (!response.ok || !data) { - setError("Classification not found."); - } else { - const flattenedMedia = (data.media || []) - .flat() - .filter( - (url: string) => typeof url === "string" && url.startsWith("http") - ); - - setClassification({ - ...data, - media: flattenedMedia, - }); - } - } catch (err) { - console.error(err); - setError("An error occurred while fetching the classification."); - } finally { - setLoading(false); - } - }; - - fetchClassification(); - }, [params.id]); - - if (loading) - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
-

Loading classification...

-
-
- ); - - if (error) return

{error}

; - if (!classification) return

No classification found.

; - - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
- {classification.author && ( -
- - {/* */} -
- )} -
-
- ); +export default function Page() { + return ; } diff --git a/src/app/structures/balloon/[project]/BalloonProjectClient.tsx b/src/app/structures/balloon/[project]/BalloonProjectClient.tsx new file mode 100644 index 00000000..f1e7f224 --- /dev/null +++ b/src/app/structures/balloon/[project]/BalloonProjectClient.tsx @@ -0,0 +1,107 @@ +'use client'; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import { useRouter } from 'next/navigation'; +import { useSession } from '@/src/lib/auth/session-context'; +import AI4M from '@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/AI4M/AIForMars'; +import PlanetFour from '@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/P4/PlanetFour'; +import CloudspottingOnMars from '@/src/components/deployment/missions/structures/Meteorologists/Cloudspotting/CloudspottingOnMars'; +import JovianVortexHunters from '@/src/components/deployment/missions/structures/Meteorologists/JVH/JovianVortexHunters'; +// import { DataSourcesModal } from '@/src/components/social/activity/unlockNewDataSources'; +import React from 'react'; +import MainHeader from '@/src/components/layout/Header/MainHeader'; +import { TelescopeBackground } from '@/src/components/classification/telescope/telescope-background'; +import UseDarkMode from '@/src/hooks/useDarkMode'; + +export default function WeatherBalloonProjectPage() { + const session = useSession(); + + const router = useRouter(); + const params = useRouteParams<"project">("/structures/balloon/[project]"); + const { isDark, toggleDarkMode } = UseDarkMode(); + + React.useEffect(() => { + if (!session) { + router.replace("/auth"); + } + }, [session, router]); + + if (!session || !params) return null; + + const project = params?.project; + + const componentMap: { [key: string]: React.ReactNode } = { + // research: , + clouds: , + storms: , + landmarks: , + surface: , + }; + + const SelectedComponent = componentMap[project as string]; + + if (!SelectedComponent) { + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+

Unknown Project

+

The selected weather balloon project does not exist.

+
+
+ ); + }; + + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+
+
+ +
+ {SelectedComponent} +
+
+
+ ); +}; diff --git a/src/app/structures/balloon/[project]/[id]/[mission]/BalloonClassifyClient.tsx b/src/app/structures/balloon/[project]/[id]/[mission]/BalloonClassifyClient.tsx new file mode 100644 index 00000000..f6ac9bf6 --- /dev/null +++ b/src/app/structures/balloon/[project]/[id]/[mission]/BalloonClassifyClient.tsx @@ -0,0 +1,115 @@ +'use client' + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import React, { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { useSessionContext } from "@/src/lib/auth/session-context" + +import { StarterLidar } from "@/src/components/projects/Lidar/Clouds" +import { StarterJovianVortexHunter } from "@/src/components/projects/Lidar/JovianVortexHunter" +import { StarterCoMShapes } from "@/src/components/projects/Lidar/CloudspottingOnMarsShapes" +import { StarterPlanetFour } from "@/src/components/projects/Satellite/PlanetFour" +import MainHeader from "@/src/components/layout/Header/MainHeader" +import UseDarkMode from "@/src/hooks/useDarkMode" +import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background" + +export default function BalloonClassifyPage() { + const params = useRouteParams<"project" | "id" | "mission">("/structures/balloon/[project]/[id]/[mission]"); + const router = useRouter(); + + const { session, isLoading } = useSessionContext(); + const { isDark, toggleDarkMode } = UseDarkMode(); + + const [MissionComponent, setMissionComponent] = useState(null); + + useEffect(() => { + if (!isLoading && !session) { + router.push('/') + }; + }, [session, isLoading, router]); + + useEffect(() => { + if (isLoading || !session || !params) return; + + const project = String(params?.project ?? ""); + const mission = String(params?.mission ?? ""); + const idParam = String(params?.id ?? ""); + + let anomalyid: string | undefined; + if (idParam.startsWith("an-")) { + anomalyid = idParam.replace("an-", ""); + } else if (idParam.startsWith("db-")) { + anomalyid = idParam.replace("db-", ""); + } else if (!isNaN(Number(idParam))) { + anomalyid = idParam; + }; + + let component: React.ReactNode = null; + + switch(project) { + case 'cloudspotting': + switch(mission) { + case "classify": + component = + break; + } + break; + + case "jvh": + switch(mission) { + case "classify": + component = + break; + } + break; + + case "shapes": + switch(mission) { + case "classify": + component = + break; + } + break; + + case "p4": + switch(mission) { + case "classify": + component = + break; + } + break; + }; + + setMissionComponent(component) + }, [params, session, isLoading]); + + return ( +
+
+ {}} + /> +
+ + {}} + activityFeed={[]} + otherClassifications={[]} + /> + +
+
+ {MissionComponent} +
+
+
+ ) +} diff --git a/src/app/structures/balloon/[project]/[id]/[mission]/page.tsx b/src/app/structures/balloon/[project]/[id]/[mission]/page.tsx index 0fe58e24..e2e58eef 100644 --- a/src/app/structures/balloon/[project]/[id]/[mission]/page.tsx +++ b/src/app/structures/balloon/[project]/[id]/[mission]/page.tsx @@ -1,114 +1,11 @@ -'use client' +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import React, { useEffect, useState } from "react" -import { useParams, useRouter } from "next/navigation" -import { useSessionContext } from "@/src/lib/auth/session-context" +import BalloonClassifyClient from "./BalloonClassifyClient"; -import { StarterLidar } from "@/src/components/projects/Lidar/Clouds" -import { StarterJovianVortexHunter } from "@/src/components/projects/Lidar/JovianVortexHunter" -import { StarterCoMShapes } from "@/src/components/projects/Lidar/CloudspottingOnMarsShapes" -import { StarterPlanetFour } from "@/src/components/projects/Satellite/PlanetFour" -import MainHeader from "@/src/components/layout/Header/MainHeader" -import UseDarkMode from "@/src/hooks/useDarkMode" -import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background" - -export default function BalloonClassifyPage() { - const params = useParams(); - const router = useRouter(); - - const { session, isLoading } = useSessionContext(); - const { isDark, toggleDarkMode } = UseDarkMode(); - - const [MissionComponent, setMissionComponent] = useState(null); - - useEffect(() => { - if (!isLoading && !session) { - router.push('/') - }; - }, [session, isLoading, router]); - - useEffect(() => { - if (isLoading || !session) return; - - const project = String(params?.project ?? ""); - const mission = String(params?.mission ?? ""); - const idParam = String(params?.id ?? ""); - - let anomalyid: string | undefined; - if (idParam.startsWith("an-")) { - anomalyid = idParam.replace("an-", ""); - } else if (idParam.startsWith("db-")) { - anomalyid = idParam.replace("db-", ""); - } else if (!isNaN(Number(idParam))) { - anomalyid = idParam; - }; - - let component: React.ReactNode = null; - - switch(project) { - case 'cloudspotting': - switch(mission) { - case "classify": - component = - break; - } - break; - - case "jvh": - switch(mission) { - case "classify": - component = - break; - } - break; - - case "shapes": - switch(mission) { - case "classify": - component = - break; - } - break; - - case "p4": - switch(mission) { - case "classify": - component = - break; - } - break; - }; - - setMissionComponent(component) - }, [params, session, isLoading]); - - return ( -
-
- {}} - /> -
- - {}} - activityFeed={[]} - otherClassifications={[]} - /> +export function generateStaticParams() { + return placeholderParams("project", "id", "mission"); +} -
-
- {MissionComponent} -
-
-
- ) +export default function Page() { + return ; } diff --git a/src/app/structures/balloon/[project]/page.tsx b/src/app/structures/balloon/[project]/page.tsx index cebad9e2..b8463ffe 100644 --- a/src/app/structures/balloon/[project]/page.tsx +++ b/src/app/structures/balloon/[project]/page.tsx @@ -1,106 +1,11 @@ -'use client'; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import { useRouter, useParams } from 'next/navigation'; -import { useSession } from '@/src/lib/auth/session-context'; -import AI4M from '@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/AI4M/AIForMars'; -import PlanetFour from '@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/P4/PlanetFour'; -import CloudspottingOnMars from '@/src/components/deployment/missions/structures/Meteorologists/Cloudspotting/CloudspottingOnMars'; -import JovianVortexHunters from '@/src/components/deployment/missions/structures/Meteorologists/JVH/JovianVortexHunters'; -// import { DataSourcesModal } from '@/src/components/social/activity/unlockNewDataSources'; -import React from 'react'; -import MainHeader from '@/src/components/layout/Header/MainHeader'; -import { TelescopeBackground } from '@/src/components/classification/telescope/telescope-background'; -import UseDarkMode from '@/src/hooks/useDarkMode'; +import BalloonProjectClient from "./BalloonProjectClient"; -export default function WeatherBalloonProjectPage() { - const session = useSession(); - - const router = useRouter(); - const params = useParams(); - const { isDark, toggleDarkMode } = UseDarkMode(); +export function generateStaticParams() { + return placeholderParams("project"); +} - React.useEffect(() => { - if (!session) { - router.replace("/auth"); - } - }, [session, router]); - - if (!session) return null; - - const project = params?.project; - - const componentMap: { [key: string]: React.ReactNode } = { - // research: , - clouds: , - storms: , - landmarks: , - surface: , - }; - - const SelectedComponent = componentMap[project as string]; - - if (!SelectedComponent) { - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
-

Unknown Project

-

The selected weather balloon project does not exist.

-
-
- ); - }; - - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
-
-
- -
- {SelectedComponent} -
-
-
- ); -}; +export default function Page() { + return ; +} diff --git a/src/app/structures/seiscam/[project]/[id]/[mission]/SeiscamProjectClient.tsx b/src/app/structures/seiscam/[project]/[id]/[mission]/SeiscamProjectClient.tsx new file mode 100644 index 00000000..5cf86b8a --- /dev/null +++ b/src/app/structures/seiscam/[project]/[id]/[mission]/SeiscamProjectClient.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import { useRouter } from "next/navigation"; +import { useSession, useSessionContext } from "@/src/lib/auth/session-context"; +import AI4M from "@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/AI4M/AIForMars"; +import PlanetFour from "@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/P4/PlanetFour"; +import React, { useEffect, useState } from "react"; +import { AiForMarsProjectWithID } from "@/src/components/projects/Auto/AI4Mars"; +import { RoverBackground } from "@/src/components/classification/telescope/rover-background"; +import MainHeader from "@/src/components/layout/Header/MainHeader"; +import UseDarkMode from "@/src/hooks/useDarkMode"; + +export default function SeiscamProjectRoute() { + const params = useRouteParams<"project" | "id" | "mission">("/structures/seiscam/[project]/[id]/[mission]"); + const router = useRouter(); + + const { session, isLoading } = useSessionContext(); + const { isDark, toggleDarkMode } = UseDarkMode(); + + if (!isLoading && !session) { + router.push("/"); + return null; + } + + if (!params) return null; + + const projectStr = params ? String(params.project) : ""; + const missionStr = params ? String(params.mission) : ""; + const idParam = params ? String(params.id || "") : ""; + + let anomalyId: number | undefined = undefined; + if (idParam.startsWith("cl-")) { + anomalyId = Number(idParam.replace("cl-", "")); + } else if (idParam.startsWith("db-")) { + anomalyId = Number(idParam.replace("db-", "")); + } else if (!isNaN(Number(idParam))) { + anomalyId = Number(idParam); + } + + let ProjectComponent: React.ReactNode = null; + switch (projectStr) { + case "ai4mars": + switch (missionStr) { + case "one": + ProjectComponent = ; + break; + default: + ProjectComponent =
Unknown mission for AI4Mars.
; + break; + } + break; + default: + ProjectComponent =
Unknown mission or project.
; + break; + } + + return ( +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> +
+ +
+
+
+ {ProjectComponent} +
+
+
+ ); +} diff --git a/src/app/structures/seiscam/[project]/[id]/[mission]/page.tsx b/src/app/structures/seiscam/[project]/[id]/[mission]/page.tsx index e41c54e3..95eb08f2 100644 --- a/src/app/structures/seiscam/[project]/[id]/[mission]/page.tsx +++ b/src/app/structures/seiscam/[project]/[id]/[mission]/page.tsx @@ -1,77 +1,11 @@ -"use client"; +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import { useRouter, useParams } from "next/navigation"; -import { useSession, useSessionContext } from "@/src/lib/auth/session-context"; -import AI4M from "@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/AI4M/AIForMars"; -import PlanetFour from "@/src/components/deployment/missions/structures/Astronomers/SatellitePhotos/P4/PlanetFour"; -import React, { useEffect, useState } from "react"; -import { AiForMarsProjectWithID } from "@/src/components/projects/Auto/AI4Mars"; -import { RoverBackground } from "@/src/components/classification/telescope/rover-background"; -import MainHeader from "@/src/components/layout/Header/MainHeader"; -import UseDarkMode from "@/src/hooks/useDarkMode"; +import SeiscamProjectClient from "./SeiscamProjectClient"; -export default function SeiscamProjectRoute() { - const params = useParams(); - const projectParam = params ? params["project"] : undefined; - const project = Array.isArray(projectParam) ? projectParam[0] : projectParam; - const router = useRouter(); - - const { session, isLoading } = useSessionContext(); - const { isDark, toggleDarkMode } = UseDarkMode(); - - if (!isLoading && !session) { - router.push("/"); - return null; - } - - const projectStr = params ? String(params.project) : ""; - const missionStr = params ? String(params.mission) : ""; - const idParam = params ? String(params.id || "") : ""; - - let anomalyId: number | undefined = undefined; - if (idParam.startsWith("cl-")) { - anomalyId = Number(idParam.replace("cl-", "")); - } else if (idParam.startsWith("db-")) { - anomalyId = Number(idParam.replace("db-", "")); - } else if (!isNaN(Number(idParam))) { - anomalyId = Number(idParam); - } - - let ProjectComponent: React.ReactNode = null; - switch (projectStr) { - case "ai4mars": - switch (missionStr) { - case "one": - ProjectComponent = ; - break; - default: - ProjectComponent =
Unknown mission for AI4Mars.
; - break; - } - break; - default: - ProjectComponent =
Unknown mission or project.
; - break; - } +export function generateStaticParams() { + return placeholderParams("project", "id", "mission"); +} - return ( -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> -
- -
-
-
- {ProjectComponent} -
-
-
- ); +export default function Page() { + return ; } diff --git a/src/app/structures/telescope/[project]/TelescopeProjectClient.tsx b/src/app/structures/telescope/[project]/TelescopeProjectClient.tsx new file mode 100644 index 00000000..acf6e678 --- /dev/null +++ b/src/app/structures/telescope/[project]/TelescopeProjectClient.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useSessionContext } from "@/src/lib/auth/session-context"; + +import PlanetHuntersSteps from "@/src/components/deployment/missions/structures/Astronomers/PlanetHunters/PlanetHunters"; +import { TelescopeDiskDetector } from "@/src/components/projects/Telescopes/DiskDetector"; +import DailyMinorPlanetMissions from "@/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet"; +import SunspotSteps from "@/src/components/projects/Telescopes/Sunspots/SunspotShell"; +import MainHeader from "@/src/components/layout/Header/MainHeader"; +import UseDarkMode from "@/src/hooks/useDarkMode"; +import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; + +export default function TelescopeProjectRoute() { + const params = useRouteParams<"project">("/structures/telescope/[project]"); + const project = params?.project; + const router = useRouter(); + const { isDark, toggleDarkMode } = UseDarkMode(); + + const { session, isLoading } = useSessionContext(); + + const [ProjectComponent, setProjectComponent] = useState(null); + + useEffect(() => { + if (!isLoading && !session) { + router.push('/'); + } + }, [isLoading, session, router]); + + useEffect(() => { + if (!isLoading && session && params) { + switch (project) { + case 'planet-hunters': + setProjectComponent(); + break; + case 'sunspots': + setProjectComponent(); + break; + case 'daily-minor-planet': + setProjectComponent(); + break; + case 'disk-detective': + setProjectComponent(); + break; + default: + router.push('/structures/telescope'); + break; + }; + }; + }, [params, project, session, isLoading, router]); + + if (isLoading) { + return null; + }; + + return ( +
+
+ {}} + /> +
+ {}} + activityFeed={[]} + otherClassifications={[]} + /> + +
+
+
+ +
+ {ProjectComponent} +
+
+
+ ); +}; diff --git a/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageClient.tsx b/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageClient.tsx index db0ab174..f6952e18 100644 --- a/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageClient.tsx +++ b/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageClient.tsx @@ -1,7 +1,8 @@ 'use client' +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; import { useEffect, useState } from "react" -import { useParams, useRouter } from "next/navigation" +import { useRouter } from "next/navigation" import { useSessionContext } from "@/src/lib/auth/session-context" // Mission imports @@ -19,7 +20,7 @@ import { ActiveAsteroidWithId, ActiveAsteroidClassifyWithId } from "@/src/compon import UseDarkMode from "@/src/hooks/useDarkMode" export default function TelescopeClassifyPage() { - const params = useParams() + const params = useRouteParams<"project" | "id" | "mission">("/structures/telescope/[project]/[id]/[mission]") const router = useRouter() const { session, isLoading } = useSessionContext() @@ -34,7 +35,7 @@ export default function TelescopeClassifyPage() { }, [session, isLoading, router]) useEffect(() => { - if (isLoading || !session) return; + if (isLoading || !session || !params) return; const project = params ? String(params.project) : ""; const mission = params ? String(params.mission) : ""; diff --git a/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageNoSsr.tsx b/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageNoSsr.tsx new file mode 100644 index 00000000..a6e67983 --- /dev/null +++ b/src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageNoSsr.tsx @@ -0,0 +1,14 @@ +'use client' + +import dynamic from "next/dynamic"; + +// @react-three/fiber (used transitively via PlanetGenerator → planetViewer) ships +// react-reconciler@0.27 which accesses React 18 internals (ReactCurrentBatchConfig) +// that no longer exist in React 19, causing a 500 during SSR. Disabling SSR here +// keeps all Three.js code client-side only where it belongs. +const TelescopeClassifyPageClient = dynamic( + () => import("./TelescopeClassifyPageClient"), + { ssr: false } +); + +export default TelescopeClassifyPageClient; diff --git a/src/app/structures/telescope/[project]/[id]/[mission]/page.tsx b/src/app/structures/telescope/[project]/[id]/[mission]/page.tsx index a6e67983..efe6e431 100644 --- a/src/app/structures/telescope/[project]/[id]/[mission]/page.tsx +++ b/src/app/structures/telescope/[project]/[id]/[mission]/page.tsx @@ -1,14 +1,11 @@ -'use client' +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import dynamic from "next/dynamic"; +import TelescopeClassifyPageNoSsr from "./TelescopeClassifyPageNoSsr"; -// @react-three/fiber (used transitively via PlanetGenerator → planetViewer) ships -// react-reconciler@0.27 which accesses React 18 internals (ReactCurrentBatchConfig) -// that no longer exist in React 19, causing a 500 during SSR. Disabling SSR here -// keeps all Three.js code client-side only where it belongs. -const TelescopeClassifyPageClient = dynamic( - () => import("./TelescopeClassifyPageClient"), - { ssr: false } -); +export function generateStaticParams() { + return placeholderParams("project", "id", "mission"); +} -export default TelescopeClassifyPageClient; +export default function Page() { + return ; +} diff --git a/src/app/structures/telescope/[project]/page.tsx b/src/app/structures/telescope/[project]/page.tsx index 9abf510e..8be82a28 100644 --- a/src/app/structures/telescope/[project]/page.tsx +++ b/src/app/structures/telescope/[project]/page.tsx @@ -1,94 +1,11 @@ -'use client' +import { placeholderParams } from "@/src/lib/routing/staticParams"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { useSessionContext } from "@/src/lib/auth/session-context"; +import TelescopeProjectClient from "./TelescopeProjectClient"; -import PlanetHuntersSteps from "@/src/components/deployment/missions/structures/Astronomers/PlanetHunters/PlanetHunters"; -import { TelescopeDiskDetector } from "@/src/components/projects/Telescopes/DiskDetector"; -import DailyMinorPlanetMissions from "@/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet"; -import SunspotSteps from "@/src/components/projects/Telescopes/Sunspots/SunspotShell"; -import MainHeader from "@/src/components/layout/Header/MainHeader"; -import UseDarkMode from "@/src/hooks/useDarkMode"; -import { TelescopeBackground } from "@/src/components/classification/telescope/telescope-background"; +export function generateStaticParams() { + return placeholderParams("project"); +} -export default function TelescopeProjectRoute() { - const params = useParams(); - const projectParam = params ? params['project'] : undefined; - const project = Array.isArray(projectParam) ? projectParam[0] : projectParam; - const router = useRouter(); - const { isDark, toggleDarkMode } = UseDarkMode(); - - const { session, isLoading } = useSessionContext(); - - const [ProjectComponent, setProjectComponent] = useState(null); - - useEffect(() => { - if (!isLoading && !session) { - router.push('/'); - } - }, [isLoading, session, router]); - - useEffect(() => { - if (!isLoading && session) { - switch (project) { - case 'planet-hunters': - setProjectComponent(); - break; - case 'sunspots': - setProjectComponent(); - break; - case 'daily-minor-planet': - setProjectComponent(); - break; - case 'disk-detective': - setProjectComponent(); - break; - default: - router.push('/structures/telescope'); - break; - }; - }; - }, [project, session, isLoading, router]); - - if (isLoading) { - return null; - }; - - return ( -
-
- {}} - /> -
- {}} - activityFeed={[]} - otherClassifications={[]} - /> - -
-
-
- -
- {ProjectComponent} -
-
-
- ); -}; +export default function Page() { + return ; +} diff --git a/src/app/viewports/satellite/deploy/page.tsx b/src/app/viewports/satellite/deploy/page.tsx index c17b39d8..a54c7b63 100644 --- a/src/app/viewports/satellite/deploy/page.tsx +++ b/src/app/viewports/satellite/deploy/page.tsx @@ -1,4 +1,3 @@ -export const dynamic = 'force-dynamic'; import SatelliteDeployPageClient from "./SatelliteDeployPageClient"; export default function SatelliteDeployPage() { diff --git a/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet.tsx b/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet.tsx index 47fc0032..6752ee52 100644 --- a/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet.tsx +++ b/src/components/deployment/missions/structures/Astronomers/DailyMinorPlanet/DailyMinorPlanet.tsx @@ -1,6 +1,6 @@ +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; import React, { useEffect, useState } from "react"; import { useSession } from "@/src/lib/auth/session-context"; -import { useParams } from "next/navigation"; import { BarChartBigIcon, GlassWater, Guitar, HelpCircle, PenBoxIcon, RadioIcon, SpeechIcon, TelescopeIcon, VoteIcon } from "lucide-react"; import MissionShell from "../../BasePlate"; import { DailyMinorPlanetWithId, StarterDailyMinorPlanet } from "@/src/components/projects/Telescopes/DailyMinorPlanet"; @@ -23,7 +23,7 @@ interface Mission { const DailyMinorPlanetMissions = () => { const session = useSession(); - const params = useParams(); + const params = useRouteParams<"id">("/structures/telescope/[project]/[id]/[mission]"); // Extract anomaly ID from URL params if available const getAnomalyId = () => { diff --git a/src/components/routing/DynamicRouteRedirect.tsx b/src/components/routing/DynamicRouteRedirect.tsx new file mode 100644 index 00000000..9d458fc0 --- /dev/null +++ b/src/components/routing/DynamicRouteRedirect.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { useRouteParams } from "@/src/lib/routing/useRouteParams"; + +/** Client-side replacement for a server redirect() on a statically exported dynamic page. */ +export function DynamicRouteRedirect({ pattern, to }: { pattern: string; to: (id: string) => string }) { + const router = useRouter(); + const params = useRouteParams<"id">(pattern); + + useEffect(() => { + if (params?.id) router.replace(to(params.id)); + }, [params?.id, router, to]); + + return null; +} diff --git a/src/components/scenes/deploy/Telescope/TelescopeActions.ts b/src/components/scenes/deploy/Telescope/TelescopeActions.ts index 708294ba..10891f2f 100644 --- a/src/components/scenes/deploy/Telescope/TelescopeActions.ts +++ b/src/components/scenes/deploy/Telescope/TelescopeActions.ts @@ -172,17 +172,14 @@ export async function handleDeployAction(params: HandleDeployParams) { const anomalyNames = selectedAnomalies.map(a => a.content || `${dt === "stellar" ? "DSK" : "TESS"}-${String(a.id).padStart(3, "0")}`) const sectorName = generateSectorName(selectedSector!.x, selectedSector!.y) setDeploymentResult({ anomalies: anomalyNames, sectorName }) - try { - const notificationTitle = "Telescope Deployed Successfully" + // SSC-39: the push is queued server-side; never hold the confirmation for it. + if (userId) { const targetType = dt === "stellar" ? "stellar objects" : "exoplanet candidates" - const notificationBody = `${selectedAnomalies.length} ${targetType} discovered in ${sectorName}` - if (userId) { - await fetch('/api/notify-my-discoveries', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId, customMessage: { title: notificationTitle, body: notificationBody, url: '/structures/telescope' } }) - }) - } - } catch (e) { console.error('Failed to send deployment notification:', e) } + void fetch('/api/notify-my-discoveries', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ customMessage: { title: "Telescope Deployed Successfully", body: `${selectedAnomalies.length} ${targetType} discovered in ${sectorName}`, url: '/structures/telescope' } }) + }).catch((e) => console.error('Failed to queue deployment notification:', e)) + } setShowConfirmation(true) } diff --git a/src/lib/actions/callAction.ts b/src/lib/actions/callAction.ts new file mode 100644 index 00000000..c2d17217 --- /dev/null +++ b/src/lib/actions/callAction.ts @@ -0,0 +1,27 @@ +// Browser side of /api/actions/[name] (SSC-31). Keeps the old server-action +// call shape: resolve with the action's return value, reject with its error +// message. A lone FormData argument is sent as multipart so file uploads work. +export const FORM_DATA_ARGS_HEADER = "x-action-args"; + +export async function callAction(name: string, args: unknown[]): Promise { + const init: RequestInit = { method: "POST", credentials: "same-origin" }; + + if (args.length === 1 && typeof FormData !== "undefined" && args[0] instanceof FormData) { + init.body = args[0]; + init.headers = { [FORM_DATA_ARGS_HEADER]: "form-data" }; + } else { + init.body = JSON.stringify({ args }); + init.headers = { "content-type": "application/json" }; + } + + const response = await fetch(`/api/actions/${encodeURIComponent(name)}`, init); + const payload = (await response.json().catch(() => null)) as + | { result?: T; error?: string } + | null; + + if (!response.ok) { + throw new Error(payload?.error || `Action ${name} failed (${response.status})`); + } + + return payload?.result as T; +} diff --git a/src/lib/cloudflare/clerk-keyless-actions.static.js b/src/lib/cloudflare/clerk-keyless-actions.static.js new file mode 100644 index 00000000..647d6477 --- /dev/null +++ b/src/lib/cloudflare/clerk-keyless-actions.static.js @@ -0,0 +1,8 @@ +// Static-export stand-in for @clerk/nextjs/dist/esm/app-router/keyless-actions.js +// (SSC-31). Keyless mode is Clerk's no-publishable-key development flow; the +// Cloudflare build always has a publishable key, so these are never reached. +export async function syncKeylessConfigAction() {} +export async function createOrReadKeylessAction() { + return null; +} +export async function deleteKeylessAction() {} diff --git a/src/lib/cloudflare/clerk-server-actions.static.js b/src/lib/cloudflare/clerk-server-actions.static.js new file mode 100644 index 00000000..5d6adfd3 --- /dev/null +++ b/src/lib/cloudflare/clerk-server-actions.static.js @@ -0,0 +1,4 @@ +// Static-export stand-in for @clerk/nextjs/dist/esm/app-router/server-actions.js +// (SSC-31). Exports cannot contain server actions. invalidateCacheAction only +// clears Next's server render cache after sign-in, and a static export has none. +export async function invalidateCacheAction() {} diff --git a/src/lib/routing/staticParams.test.ts b/src/lib/routing/staticParams.test.ts new file mode 100644 index 00000000..64ad2491 --- /dev/null +++ b/src/lib/routing/staticParams.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { STATIC_PARAM_PLACEHOLDER, matchRouteParams, placeholderParams } from "./staticParams"; + +describe("placeholderParams", () => { + it("exports each dynamic page once with every param set to the placeholder", () => { + expect(placeholderParams("project", "id")).toEqual([{ project: STATIC_PARAM_PLACEHOLDER, id: STATIC_PARAM_PLACEHOLDER }]); + }); +}); + +describe("matchRouteParams", () => { + it("reads params from the real URL", () => { + expect(matchRouteParams("/posts/[id]", "/posts/42")).toEqual({ id: "42" }); + expect(matchRouteParams("/structures/balloon/[project]/[id]/[mission]", "/structures/balloon/clouds/an-7/one")).toEqual({ + project: "clouds", + id: "an-7", + mission: "one", + }); + }); + + it("decodes segments and ignores a trailing slash", () => { + expect(matchRouteParams("/posts/[id]", "/posts/a%20b/")).toEqual({ id: "a b" }); + }); + + it("returns null for other routes, malformed escapes and the export placeholder", () => { + expect(matchRouteParams("/posts/[id]", "/posts/surveyor/1")).toBeNull(); + expect(matchRouteParams("/posts/[id]", "/planets/1")).toBeNull(); + expect(matchRouteParams("/posts/[id]", "/posts/%E0%A4%A")).toBeNull(); + expect(matchRouteParams("/posts/[id]", `/posts/${STATIC_PARAM_PLACEHOLDER}`)).toBeNull(); + }); +}); diff --git a/src/lib/routing/staticParams.ts b/src/lib/routing/staticParams.ts new file mode 100644 index 00000000..2ebed0b1 --- /dev/null +++ b/src/lib/routing/staticParams.ts @@ -0,0 +1,35 @@ +// SSC-31: the Cloudflare build is a static export, so each dynamic page is +// exported once with every param set to this placeholder and the Worker serves +// that HTML for any real id. The params baked into that page are therefore the +// placeholder; the real ones only exist in the browser URL. +export const STATIC_PARAM_PLACEHOLDER = "__static__"; + +/** generateStaticParams() result exporting a dynamic page once. */ +export function placeholderParams(...names: K[]): Array> { + return [Object.fromEntries(names.map((name) => [name, STATIC_PARAM_PLACEHOLDER])) as Record]; +} + +/** Reads `[name]` segments of `pattern` (e.g. "/posts/[id]") from `pathname`. */ +export function matchRouteParams(pattern: string, pathname: string): Record | null { + const expected = pattern.split("/").filter(Boolean); + const actual = pathname.split("/").filter(Boolean); + if (expected.length !== actual.length) return null; + + const params: Record = {}; + for (let i = 0; i < expected.length; i++) { + const param = expected[i].match(/^\[(\w+)\]$/); + let segment: string; + try { + segment = decodeURIComponent(actual[i]); + } catch { + return null; + } + if (param) { + if (segment === STATIC_PARAM_PLACEHOLDER) return null; + params[param[1]] = segment; + } else if (expected[i] !== segment) { + return null; + } + } + return params; +} diff --git a/src/lib/routing/useRouteParams.ts b/src/lib/routing/useRouteParams.ts new file mode 100644 index 00000000..2453914f --- /dev/null +++ b/src/lib/routing/useRouteParams.ts @@ -0,0 +1,23 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { matchRouteParams } from "./staticParams"; + +/** + * The current dynamic route params, read from the browser URL. Null during the + * static prerender and the first client render (so hydration matches), and + * when the URL does not fit `pattern`. + */ +export function useRouteParams(pattern: string): Partial> | null { + const pathname = usePathname(); + const [params, setParams] = useState | null>(null); + + useEffect(() => { + const next = matchRouteParams(pattern, window.location.pathname); + setParams((prev) => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next)); + }, [pattern, pathname]); + + return params as Partial> | null; +} diff --git a/src/lib/server/hub-leaderboard.ts b/src/lib/server/hub-leaderboard.ts index 9331b192..75f57350 100644 --- a/src/lib/server/hub-leaderboard.ts +++ b/src/lib/server/hub-leaderboard.ts @@ -1,5 +1,6 @@ import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; -import { unstable_cache } from "next/cache"; +import { computeHubTopProfiles } from "@/src/server/snapshots/compute"; +import { readSnapshot } from "@/src/server/snapshots/store"; export interface HubLeaderboardEntry { rank: number; @@ -13,21 +14,18 @@ export interface HubLeaderboardData { currentUser: HubLeaderboardEntry | null; } -const getCachedTopProfiles = unstable_cache( - async () => { - const pb = await createPocketbaseAdminClient(); - const result = await pb.collection("profiles").getList(1, 5, { - sort: "-classificationPoints,+updatedAt", - fields: "userId,username,classificationPoints", - }); - return result.items; - }, - ["hub-top-profiles"], - { revalidate: 300, tags: ["leaderboard"] } -); +// SSC-37: the top five come from the precomputed `hub-top-profiles` snapshot +// (cron-refreshed). Only the caller's own rank is read per request. +async function getTopProfiles() { + const snapshot = await readSnapshot("hub-top-profiles"); + if (snapshot.data) return snapshot.data; + // Not published yet (first minutes after the first deploy): one bounded read. + const pb = await createPocketbaseAdminClient(); + return computeHubTopProfiles(pb); +} export async function getHubLeaderboard(userId: string): Promise { - const topProfiles = await getCachedTopProfiles(); + const topProfiles = await getTopProfiles(); const pb = await createPocketbaseAdminClient(); const me = await pb @@ -46,7 +44,7 @@ export async function getHubLeaderboard(userId: string): Promise ({ rank: index + 1, username: profile.username || `User ${profile.userId.slice(0, 6)}`, - score: Number(profile.classificationPoints ?? 0), + score: profile.score, isCurrentUser: profile.userId === userId, })); diff --git a/src/lib/server/internalAuth.ts b/src/lib/server/internalAuth.ts new file mode 100644 index 00000000..431625f0 --- /dev/null +++ b/src/lib/server/internalAuth.ts @@ -0,0 +1,20 @@ +import { timingSafeEqual } from "node:crypto"; + +import { NextResponse } from "next/server"; + +// Operator-only endpoints (SSC-37/SSC-39: snapshot refresh, job fan-out, +// dead-letter replay) take `Authorization: Bearer $INTERNAL_JOBS_TOKEN`. +// Without the secret configured they are disabled rather than open. +export function requireInternalToken(request: Request, env: NodeJS.ProcessEnv = process.env): NextResponse | null { + const expected = env.INTERNAL_JOBS_TOKEN; + if (!expected) return NextResponse.json({ error: "Internal endpoints are disabled (INTERNAL_JOBS_TOKEN unset)" }, { status: 503 }); + + const header = request.headers.get("authorization") ?? ""; + const provided = header.startsWith("Bearer ") ? header.slice(7).trim() : ""; + const left = Buffer.from(provided); + const right = Buffer.from(expected); + if (left.length !== right.length || !timingSafeEqual(left, right)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + return null; +} diff --git a/src/lib/server/posthog.ts b/src/lib/server/posthog.ts index bbdee863..7b8cd5c9 100644 --- a/src/lib/server/posthog.ts +++ b/src/lib/server/posthog.ts @@ -1,32 +1,20 @@ -import { PostHog } from "posthog-node"; - -let client: PostHog | null | undefined; - -function posthogKey(): string | undefined { - return process.env.posthog_api_key ?? process.env.POSTHOG_API_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_KEY; -} - -export function getPostHogServer(): PostHog | null { - if (process.env.NODE_ENV === "development") return null; - if (client !== undefined) return client; - const apiKey = posthogKey(); - if (!apiKey) { - client = null; - return null; - } - client = new PostHog(apiKey, { - host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", - }); - return client; -} +import { enqueueJobs } from "@/src/server/jobs/queue"; +/** + * Server-side PostHog event (SSC-39). Queued rather than sent inline, so the + * caller's response never waits on PostHog; the consumer delivers it with the + * job id as PostHog's dedupe uuid. + */ export async function captureServerEvent( distinctId: string, event: string, - properties?: Record, + properties: Record = {}, ): Promise { - const posthog = getPostHogServer(); - if (!posthog) return; - posthog.capture({ distinctId, event, properties }); - await posthog.flush(); + if (process.env.NODE_ENV === "development") return; + try { + await enqueueJobs({ type: "analytics.capture", distinctId, event, properties, timestamp: new Date().toISOString() }); + } catch (error) { + // Analytics must never fail the user's action. + console.warn(`[analytics] could not queue ${event}: ${String(error)}`); + } } diff --git a/src/lib/server/stagingPlaytestAuth.ts b/src/lib/server/stagingPlaytestAuth.ts new file mode 100644 index 00000000..99f19751 --- /dev/null +++ b/src/lib/server/stagingPlaytestAuth.ts @@ -0,0 +1,56 @@ +import { timingSafeEqual } from "node:crypto"; + +export const STAGING_PLAYTEST_MARKER = "star-sailors-staging-playtest-v1"; + +export type StagingPlaytestConfig = { + enabled: boolean; + host: string; + secret: string | null; +}; + +function normalizedHost(value: string | null): string { + return (value || "").trim().toLowerCase().replace(/:\d+$/, ""); +} + +function equalSecrets(provided: string | null, expected: string | null): boolean { + if (!provided || !expected) return false; + + const left = Buffer.from(provided); + const right = Buffer.from(expected); + return left.length === right.length && timingSafeEqual(left, right); +} + +export function stagingPlaytestConfig(env: NodeJS.ProcessEnv = process.env): StagingPlaytestConfig { + return { + enabled: env.STAGING_PLAYTEST_AUTH_ENABLED === "true", + host: normalizedHost(env.STAGING_PLAYTEST_HOST || "staging.starsailors.space"), + secret: env.STAGING_PLAYTEST_AUTH_SECRET || null, + }; +} + +/** + * This guard intentionally has three independent conditions. A staging build + * is not enough on its own: the endpoint must also receive the staging host + * and an operator-held secret. That keeps it absent from both production and + * arbitrary preview URLs even if a runtime environment is misconfigured. + */ +export function authorizesStagingPlaytest( + request: Request, + config: StagingPlaytestConfig = stagingPlaytestConfig(), +): boolean { + return ( + config.enabled && + normalizedHost(request.headers.get("host")) === config.host && + equalSecrets(request.headers.get("x-staging-playtest-secret"), config.secret) + ); +} + +export function isStagingPlaytestMetadata(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const marker = (value as Record).starSailorsPlaytest; + return ( + !!marker && + typeof marker === "object" && + (marker as Record).marker === STAGING_PLAYTEST_MARKER + ); +} diff --git a/src/lib/server/stats.test.ts b/src/lib/server/stats.test.ts deleted file mode 100644 index 629017f2..00000000 --- a/src/lib/server/stats.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { uniqueNonEmptyStrings } from "./stats"; - -describe("uniqueNonEmptyStrings", () => { - it("counts unique non-empty strings only", () => { - expect(uniqueNonEmptyStrings(["a", "b", "a", "", null, undefined, 3])).toBe(2); - }); - - it("returns 0 for an empty scan", () => { - expect(uniqueNonEmptyStrings([])).toBe(0); - }); -}); diff --git a/src/lib/server/stats.ts b/src/lib/server/stats.ts deleted file mode 100644 index 6675023c..00000000 --- a/src/lib/server/stats.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; - -/** Hard cap so landing SSR cannot pull the full classifications collection into a Worker isolate. */ -export const LANDING_STATS_SCAN_LIMIT = 50; - -export function uniqueNonEmptyStrings(values: unknown[]): number { - const unique = new Set(); - for (const value of values) { - if (typeof value === "string" && value.length > 0) { - unique.add(value); - } - } - return unique.size; -} - -async function uniqueFieldSample(options: { - filter: string; - field: string; -}): Promise { - const pb = await createPocketbaseAdminClient(); - const result = await pb.collection("ss_classifications").getList(1, LANDING_STATS_SCAN_LIMIT, { - filter: options.filter, - fields: options.field, - }); - return uniqueNonEmptyStrings(result.items.map((row) => row[options.field])); -} - -export async function getActiveSailors(): Promise { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const pb = await createPocketbaseAdminClient(); - return uniqueFieldSample({ - filter: pb.filter("createdAt >= {:since} && author != null", { since }), - field: "author", - }); -} - -export async function getTotalDiscoveries(): Promise { - const pb = await createPocketbaseAdminClient(); - const result = await pb.collection("ss_classifications").getList(1, 1); - return result.totalItems; -} - -export async function getActiveProjects(): Promise { - const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); - const pb = await createPocketbaseAdminClient(); - return uniqueFieldSample({ - filter: pb.filter("createdAt >= {:since} && classificationtype != null", { since }), - field: "classificationtype", - }); -} diff --git a/src/server/actions/classification-actions.ts b/src/server/actions/classification-actions.ts new file mode 100644 index 00000000..6a47b000 --- /dev/null +++ b/src/server/actions/classification-actions.ts @@ -0,0 +1,80 @@ +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getRouteUser } from "@/lib/server/routeAuth"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { mapClassificationToRow } from "@/lib/pocketbase/legacyShapes"; + +const ClassificationSchema = z.object({ + anomaly: z.number().nullable().optional(), + classificationtype: z.string().min(1), + content: z.string().nullable().optional(), + media: z.any().optional(), + classificationConfiguration: z.any().optional(), + classificationParent: z.union([z.number(), z.string()]).nullable().optional(), +}); + +export type CreateClassificationInput = z.infer; + +export async function createClassificationAction(payload: CreateClassificationInput) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const { + anomaly, + classificationtype, + content, + media, + classificationConfiguration, + classificationParent + } = ClassificationSchema.parse(payload); + + if (!classificationtype) { + throw new Error("Classification type is required"); + } + + const anomalyId = anomaly ? Number(anomaly) : null; + void classificationParent; // not persisted — not part of the classifications collection + + const pb = await createPocketbaseAdminClient(); + + // legacyId assignment mirrors the old autoincrement PK: one past the + // current max. Matches the pattern in /api/gameplay/classifications POST. + const latest = await pb + .collection("ss_classifications") + .getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + const classification = await pb.collection("ss_classifications").create({ + legacyId: nextLegacyId, + createdAt: new Date().toISOString(), + author: user.id, + anomaly: anomalyId, + classificationtype, + content: content || "", + media: media ?? null, + classificationConfiguration: classificationConfiguration ?? null, + }); + + // Revalidate paths + revalidatePath("/game"); + revalidatePath("/research"); + revalidatePath("/viewports/satellite"); + revalidatePath("/viewports/solar"); + revalidatePath("/viewports/rover"); + revalidatePath(`/next/${nextLegacyId}`); + + return { success: true, data: mapClassificationToRow(classification) }; + + } catch (error) { + console.error("[Create Classification] Error:", error); + if (error instanceof z.ZodError) { + return { error: "Invalid input data", details: error.flatten() }; + } + return { error: "Failed to create classification" }; + } +} + + diff --git a/src/server/actions/deploy-actions.ts b/src/server/actions/deploy-actions.ts new file mode 100644 index 00000000..0a0b2897 --- /dev/null +++ b/src/server/actions/deploy-actions.ts @@ -0,0 +1,333 @@ +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getRouteUser } from "@/lib/server/routeAuth"; +import { hasResearchedTech } from "@/lib/server/researched"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { mapAnomalyToRow } from "@/lib/pocketbase/legacyShapes"; +import { withVisibleRecords } from "@/lib/pocketbase/sscVisibility"; + +// Schema for deployment input +const DeploySchema = z.object({ + deploymentType: z.enum(["stellar", "planetary"]), + anomalyIds: z.array(z.number()), +}); + +export type DeploymentType = z.infer["deploymentType"]; + +// Helper to determine which anomaly sets to fetch +function computeSetsToFetch( + deploymentType: DeploymentType, + options: { includeActiveAsteroids: boolean; includeNgts: boolean } +) { + if (deploymentType === "stellar") { + return ["diskDetective", "superwasp-variable", "telescope-superwasp-variable"]; + } + + const sets = ["telescope-tess", "telescope-minorPlanet"]; + if (options.includeActiveAsteroids) { + sets.push("active-asteroids"); + } + if (options.includeNgts) { + sets.push("telescope-ngts"); + } + return sets; +} + +async function countOthersInteractions( + pb: Awaited>, + collection: "ss_comments" | "votes", + filter: string, + userId: string +) { + const rows = await pb.collection(collection).getFullList({ filter, fields: "classificationId" }); + const classificationIds = [...new Set(rows.map((r) => r.classificationId).filter((id) => id != null))]; + if (classificationIds.length === 0) return 0; + + const classFilter = classificationIds.map((id) => pb.filter("legacyId = {:id}", { id })).join(" || "); + const classifications = await pb.collection("ss_classifications").getFullList({ + filter: classFilter, + fields: "legacyId,author", + }); + const authorByLegacyId = new Map(classifications.map((c) => [c.legacyId, c.author])); + + return rows.filter((r) => { + const author = authorByLegacyId.get(r.classificationId); + return author && author !== userId; + }).length; +} + +export async function getTelescopeStatus() { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + const pb = await createPocketbaseAdminClient(); + + const [linkedCount, validCommentsCount, validVotesCount] = await Promise.all([ + pb.collection("linked_anomalies").getList(1, 1, { + filter: withVisibleRecords( + pb.filter("automaton = {:a} && author = {:u} && date >= {:d}", { + a: "Telescope", + u: user.id, + d: oneWeekAgo.toISOString(), + }) + ), + }).then((r) => r.totalItems), + countOthersInteractions( + pb, + "ss_comments", + pb.filter("author = {:u} && createdAt >= {:d}", { u: user.id, d: oneWeekAgo.toISOString() }), + user.id + ), + countOthersInteractions( + pb, + "votes", + pb.filter("userId = {:u} && voteType = {:t} && createdAt >= {:d}", { + u: user.id, + t: "up", + d: oneWeekAgo.toISOString(), + }), + user.id + ), + ]); + + const additionalDeploys = Math.floor(validVotesCount / 3) + validCommentsCount; + const userCanRedeploy = linkedCount + additionalDeploys > linkedCount; + + if (linkedCount === 0) { + return { alreadyDeployed: false, deploymentMessage: null }; + } + + if (userCanRedeploy) { + return { + alreadyDeployed: false, + deploymentMessage: "You have earned additional deploys by interacting with the community this week!", + }; + } + + return { + alreadyDeployed: true, + deploymentMessage: "Telescope has already been deployed this week. Recalibrate & search again next week.", + }; + } catch (error) { + console.error("[Telescope Status] Error:", error); + throw new Error("Failed to fetch telescope status"); + } +} + +export async function getTelescopeAnomalies(deploymentType: DeploymentType) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const pb = await createPocketbaseAdminClient(); + + let includeActiveAsteroids = false; + let includeNgts = false; + + if (deploymentType === "planetary") { + const [minorPlanetCount, hasNgtsAccess] = await Promise.all([ + pb.collection("ss_classifications").getList(1, 1, { + filter: pb.filter("author = {:a} && classificationtype = {:t}", { a: user.id, t: "telescope-minorPlanet" }), + }).then((r) => r.totalItems), + hasResearchedTech(user.id, "ngtsAccess") + ]); + + includeActiveAsteroids = minorPlanetCount >= 2; + includeNgts = hasNgtsAccess; + } + + const setsToFetch = computeSetsToFetch(deploymentType, { + includeActiveAsteroids, + includeNgts, + }); + + const rows = await pb.collection("anomalies").getFullList({ + filter: setsToFetch.map((s) => pb.filter("anomalySet = {:s}", { s })).join(" || "), + }); + + return { anomalies: rows.map(mapAnomalyToRow) }; + } catch (error) { + console.error("[Telescope Anomalies] Error:", error); + throw new Error("Failed to fetch anomalies"); + } +} + +export async function getTelescopeSkillProgress() { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const start = new Date("2000-01-01").toISOString(); + const pb = await createPocketbaseAdminClient(); + + const countByTypes = (types: string[]) => + pb.collection("ss_classifications").getList(1, 1, { + filter: + pb.filter("author = {:a} && createdAt >= {:s}", { a: user.id, s: start }) + + " && (" + + types.map((t) => pb.filter("classificationtype = {:t}", { t })).join(" || ") + + ")", + }).then((r) => r.totalItems); + + const [telescopeCount, weatherCount] = await Promise.all([ + countByTypes(['planet', 'telescope-minorPlanet']), + countByTypes(['cloud', 'lidar-jovianVortexHunter']), + ]); + + return { + skillProgress: { + telescope: telescopeCount, + weather: weatherCount, + } + }; + + } catch (error) { + console.error("[Telescope Skill Progress] Error:", error); + throw new Error("Failed to fetch skill progress"); + } +} + +export async function deployTelescope(prevState: any, formData: FormData) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + return { error: "Unauthorized" }; + } + + // Parse formData manually or expect JSON if called directly. + // Assuming this action might be called with object arguments in a client component wrapper. + // For now, let's assume it receives the raw data object as the first argument if strictly using server actions, + // but standard `useFormState` passes `prevState` and `formData`. + + // Simplification: We'll accept a plain object input for now, and the client will call it directly. + // This deviates from `useFormState` but is cleaner for migrating `fetch` calls. + // We'll rename the function signature to match this pattern. + throw new Error("Use deployTelescopeAction for direct calls"); + } catch (error) { + return { error: "Failed to deploy" }; + } +} + +// Direct action for client components +export async function deployTelescopeAction(payload: z.infer) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const { deploymentType, anomalyIds } = DeploySchema.parse(payload); + + if (anomalyIds.length === 0) { + throw new Error("No anomalies selected"); + } + + const hasProbeReceptors = await hasResearchedTech(user.id, "probereceptors"); + + const maxAnomalies = hasProbeReceptors ? 6 : 4; + const uniqueIds = Array.from(new Set(anomalyIds)).slice(0, maxAnomalies); + + const pb = await createPocketbaseAdminClient(); + const latest = await pb.collection("linked_anomalies").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + let nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + const deploymentDate = new Date().toISOString(); + + await Promise.all( + uniqueIds.map((anomalyId) => + pb.collection("linked_anomalies").create({ + legacyId: nextLegacyId++, + author: user.id, + anomalyId, + classificationId: null, + automaton: "Telescope", + date: deploymentDate, + unlocked: false, + }) + ) + ); + + revalidatePath("/activity/deploy"); + revalidatePath("/structures/telescope"); + revalidatePath("/game"); + + return { success: true, inserted: uniqueIds.length }; + + } catch (error) { + console.error("[Deploy Telescope] Error:", error); + if (error instanceof z.ZodError) { + return { error: "Invalid input data" }; + } + return { error: "Failed to deploy telescope" }; + } +} + +export async function getLinkedAnomaly(anomalyId: number) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const pb = await createPocketbaseAdminClient(); + const linkedAnomaly = await pb + .collection("linked_anomalies") + .getFirstListItem( + withVisibleRecords(pb.filter("author = {:a} && anomalyId = {:id}", { a: user.id, id: anomalyId })), + { + sort: "-legacyId", + }) + .catch(() => null); + + return { + success: true, + data: linkedAnomaly + ? { + id: linkedAnomaly.legacyId, + classificationId: linkedAnomaly.classificationId ?? null, + unlocked: linkedAnomaly.unlocked ?? false, + } + : null, + }; + } catch (error) { + console.error("[Get Linked Anomaly] Error:", error); + return { error: "Failed to fetch linked anomaly" }; + } +} + +export async function updateLinkedAnomalyAction(id: number, updates: { unlocked?: boolean, classification_id?: number }) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const pb = await createPocketbaseAdminClient(); + const existing = await pb + .collection("linked_anomalies") + .getFirstListItem(pb.filter("legacyId = {:id} && author = {:a}", { id, a: user.id })); + + const updated = await pb.collection("linked_anomalies").update(existing.id, { + ...(updates.unlocked !== undefined ? { unlocked: updates.unlocked } : {}), + ...(updates.classification_id !== undefined ? { classificationId: updates.classification_id } : {}), + }); + + revalidatePath("/activity/deploy"); + revalidatePath("/viewports/satellite"); + revalidatePath("/viewports/rover"); + + return { success: true, data: updated }; + } catch (error) { + console.error("[Update Linked Anomaly] Error:", error); + return { error: "Failed to update linked anomaly" }; + } +} diff --git a/src/server/actions/gameplay.ts b/src/server/actions/gameplay.ts new file mode 100644 index 00000000..8ba61651 --- /dev/null +++ b/src/server/actions/gameplay.ts @@ -0,0 +1,137 @@ +import { revalidatePath } from "next/cache"; +import { getRouteUser } from "@/lib/server/routeAuth"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { mapMineralDepositToRow } from "@/lib/pocketbase/legacyShapes"; +import { recursiveSerialize } from "@/utils/serialization"; + +// ── NPS ────────────────────────────────────────────────────────────────────── + +export async function submitNpsAction(input: { npsScore: number; feedback?: string | null }) { + const { user, authError } = await getRouteUser(); + if (authError || !user) return { ok: false as const, error: "Unauthorized" }; + + const { npsScore, feedback = null } = input; + if (!Number.isFinite(npsScore) || npsScore < 0 || npsScore > 10) { + return { ok: false as const, error: "Invalid score" }; + } + + const pb = await createPocketbaseAdminClient(); + await pb.collection("nps_surveys").create({ + createdAt: new Date().toISOString(), + userId: user.id, + npsScore, + projectInterests: feedback, + }); + + revalidatePath("/game"); + return { ok: true as const }; +} + +// ── SURVEYOR COMMENT ───────────────────────────────────────────────────────── + +type SurveyorCommentInput = { + classificationId: number; + content: string; + configuration?: Record; + surveyor?: string; + category?: string; + value?: string; +}; + +export async function submitSurveyorCommentAction(input: SurveyorCommentInput) { + const { user, authError } = await getRouteUser(); + if (authError || !user) return { ok: false as const, error: "Unauthorized" }; + + const { classificationId, content, configuration, surveyor, category, value } = input; + if (!Number.isFinite(classificationId) || !content.trim()) { + return { ok: false as const, error: "Invalid payload" }; + } + + const pb = await createPocketbaseAdminClient(); + const latest = await pb.collection("ss_comments").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + await pb.collection("ss_comments").create({ + legacyId: nextLegacyId, + createdAt: new Date().toISOString(), + content, + classificationId, + author: user.id, + configuration: configuration ?? null, + surveyor: surveyor ?? null, + category: category ?? null, + value: value ?? null, + }); + + revalidatePath(`/planets/${classificationId}`); + revalidatePath(`/posts/surveyor/${classificationId}`); + return { ok: true as const }; +} + +// ── EXTRACTION ─────────────────────────────────────────────────────────────── + +export async function getExtractionDepositAction(depositId: number) { + const { user, authError } = await getRouteUser(); + if (authError || !user) return { ok: false as const, error: "Unauthorized" }; + + if (!Number.isFinite(depositId)) return { ok: false as const, error: "Invalid deposit ID" }; + + const pb = await createPocketbaseAdminClient(); + const record = await pb + .collection("mineral_deposits") + .getFirstListItem(pb.filter("legacyId = {:id}", { id: depositId })) + .catch(() => null); + if (!record) return { ok: false as const, error: "Mineral deposit not found" }; + if (record.owner !== user.id) return { ok: false as const, error: "Forbidden" }; + + return { ok: true as const, deposit: recursiveSerialize(mapMineralDepositToRow(record)) }; +} + +export async function completeExtractionAction(input: { + depositId: number; + extractedQuantity: number; + purity: number; +}) { + const { user, authError } = await getRouteUser(); + if (authError || !user) return { ok: false as const, error: "Unauthorized" }; + + const { depositId, extractedQuantity, purity } = input; + if (!Number.isFinite(depositId)) return { ok: false as const, error: "Invalid deposit ID" }; + if (!Number.isFinite(extractedQuantity) || extractedQuantity <= 0 || !Number.isFinite(purity)) { + return { ok: false as const, error: "Invalid extraction payload" }; + } + + const pb = await createPocketbaseAdminClient(); + const deposit = await pb + .collection("mineral_deposits") + .getFirstListItem(pb.filter("legacyId = {:id}", { id: depositId })) + .catch(() => null); + if (!deposit) return { ok: false as const, error: "Mineral deposit not found" }; + if (deposit.owner !== user.id) return { ok: false as const, error: "Forbidden" }; + + const mineralType = deposit.mineralConfiguration?.type; + if (!mineralType) return { ok: false as const, error: "Deposit has no mineral type" }; + + const latest = await pb + .collection("user_mineral_inventory") + .getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + await pb.collection("user_mineral_inventory").create({ + legacyId: nextLegacyId, + userId: user.id, + mineralDepositId: deposit.legacyId, + mineralType: String(mineralType), + quantity: extractedQuantity, + purity, + extractedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }); + + const updatedConfig = { ...deposit.mineralConfiguration, amount: 0, quantity: 0 }; + await pb.collection("mineral_deposits").update(deposit.id, { mineralConfiguration: updatedConfig }); + + revalidatePath("/inventory"); + revalidatePath(`/extraction/${deposit.legacyId}`); + return { ok: true as const }; +} diff --git a/src/server/actions/mineral-actions.ts b/src/server/actions/mineral-actions.ts new file mode 100644 index 00000000..38c47308 --- /dev/null +++ b/src/server/actions/mineral-actions.ts @@ -0,0 +1,87 @@ +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getRouteUser } from "@/lib/server/routeAuth"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { mapMineralDepositToRow } from "@/lib/pocketbase/legacyShapes"; + +const MineralDepositSchema = z.object({ + anomaly: z.number(), + discovery: z.number(), + mineral_configuration: z.record(z.string(), z.any()).optional(), + location: z.string().optional().default("Mars"), + rover_name: z.string().optional().default("Rover 1"), + created_at: z.string().optional(), +}); + +export type CreateMineralDepositInput = z.infer; + +export async function createMineralDepositAction(payload: CreateMineralDepositInput) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const { + anomaly, + discovery, + mineral_configuration, + location, + rover_name, + created_at + } = MineralDepositSchema.parse(payload); + + const pb = await createPocketbaseAdminClient(); + const latest = await pb.collection("mineral_deposits").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + const deposit = await pb.collection("mineral_deposits").create({ + legacyId: nextLegacyId, + anomaly, + discovery, + owner: user.id, + mineralConfiguration: mineral_configuration || {}, + location, + roverName: rover_name, + createdAt: created_at ? new Date(created_at).toISOString() : new Date().toISOString(), + }); + + revalidatePath("/inventory"); + revalidatePath("/viewports/rover"); // Fixed typo + revalidatePath(`/next/${discovery}`); + + return { success: true, data: mapMineralDepositToRow(deposit) }; + + } catch (error) { + console.error("[Create Mineral Deposit] Error:", error); + if (error instanceof z.ZodError) { + return { error: "Invalid input data", details: error.flatten() }; + } + return { error: "Failed to create mineral deposit" }; + } +} + +export async function getMineralDeposits(discoveryId?: number) { + try { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + throw new Error("Unauthorized"); + } + + const pb = await createPocketbaseAdminClient(); + const filters = [pb.filter("owner = {:owner}", { owner: user.id }), 'location != ""']; + if (discoveryId !== undefined) { + filters.push(pb.filter("discovery = {:d}", { d: discoveryId })); + } + + const deposits = await pb.collection("mineral_deposits").getFullList({ + filter: filters.join(" && "), + sort: "-createdAt", + }); + + return { success: true, data: deposits.map(mapMineralDepositToRow) }; + } catch (error) { + console.error("[Get Mineral Deposits] Error:", error); + return { error: "Failed to fetch mineral deposits" }; + } +} diff --git a/src/server/actions/profile-actions.ts b/src/server/actions/profile-actions.ts new file mode 100644 index 00000000..58192e50 --- /dev/null +++ b/src/server/actions/profile-actions.ts @@ -0,0 +1,204 @@ +import { currentUser } from "@clerk/nextjs/server"; +import { revalidatePath } from "next/cache"; +import { getRouteUser } from "@/lib/server/routeAuth"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { getStorageUrl } from "@/lib/pocketbase/storageUrl"; +import { storageObjectId, storageFilename } from "@/lib/pocketbase/storageId"; +import { ReferralService } from "@/src/features/referrals/referral-service"; +import { resolveGardenIdentity } from "@/lib/server/gardenIdentity"; + +async function findProfileByUserId(pb: Awaited>, userId: string) { + return pb + .collection("profiles") + .getFirstListItem(pb.filter("userId = {:id}", { id: userId })) + .catch(() => null); +} + +export async function getCurrentProfileAction() { + const { user } = await getRouteUser(); + if (!user) return { ok: false as const, error: "Unauthorized" }; + + const pb = await createPocketbaseAdminClient(); + let email: string | null = null; + try { + const clerkUser = await currentUser(); + email = + clerkUser?.primaryEmailAddress?.emailAddress ?? + clerkUser?.emailAddresses?.[0]?.emailAddress ?? + null; + } catch { + email = null; + } + const identity = await resolveGardenIdentity(pb, user.id, email); + const profile = identity.profileId + ? await pb.collection("profiles").getOne(identity.profileId).catch(() => null) + : await findProfileByUserId(pb, user.id); + + return { + ok: true as const, + data: profile || identity.username + ? { + username: identity.username ?? profile?.username ?? null, + fullName: identity.fullName ?? profile?.fullName ?? null, + avatarUrl: profile?.avatarUrl ?? null, + referralCode: profile?.referralCode ?? null, + } + : null, + }; +} + +export async function updateProfileSetupAction(formData: FormData) { + const { user } = await getRouteUser(); + if (!user) return { ok: false as const, error: "Unauthorized" }; + + const username = String(formData.get("username") || "").trim(); + const firstName = String(formData.get("firstName") || "").trim(); + const existingAvatarPreview = String(formData.get("existingAvatarPreview") || ""); + const avatar = formData.get("avatar"); + + if (!username) { + return { ok: false as const, error: "Username is required." }; + } + + const pb = await createPocketbaseAdminClient(); + + let avatar_url: string | null = existingAvatarPreview && existingAvatarPreview.trim() ? existingAvatarPreview : null; + if (avatar && avatar instanceof File && avatar.size > 0) { + const fileName = `${Date.now()}-${user.id}-avatar.png`; + try { + const pbFormData = new FormData(); + pbFormData.append("id", storageObjectId("avatars", fileName)); + pbFormData.append("bucket", "avatars"); + pbFormData.append("path", fileName); + pbFormData.append( + "file", + new File([avatar], storageFilename(fileName), { type: avatar.type || "image/png" }) + ); + await pb.collection("storage_objects").create(pbFormData); + } catch (error: any) { + return { ok: false as const, error: error?.message || "Avatar upload failed" }; + } + avatar_url = getStorageUrl("avatars", fileName); + } + + try { + const referralCode = await ReferralService.ensureReferralCode(user.id); + const existing = await findProfileByUserId(pb, user.id); + + const fields = { + username, + fullName: firstName, + avatarUrl: avatar_url, + updatedAt: new Date().toISOString(), + }; + + if (existing) { + await pb.collection("profiles").update(existing.id, fields); + } else { + await pb.collection("profiles").create({ userId: user.id, referralCode, ...fields }); + } + } catch (error) { + return { ok: false as const, error: String(error) }; + } + + revalidatePath("/account"); + revalidatePath("/game"); + revalidatePath("/research"); + return { ok: true as const }; +} + +export async function completeProfileAction(input: { + username: string; + fullName: string; + referrerCodeInput?: string; +}) { + const { user } = await getRouteUser(); + if (!user) return { ok: false as const, error: "Unauthorized" }; + + const username = input.username.trim(); + if (username.length < 3) { + return { ok: false as const, error: "Username must be at least 3 characters." }; + } + + const pb = await createPocketbaseAdminClient(); + + try { + const referralCode = await ReferralService.ensureReferralCode(user.id); + const existing = await findProfileByUserId(pb, user.id); + + const fields = { + username, + fullName: input.fullName.trim(), + updatedAt: new Date().toISOString(), + }; + + if (existing) { + await pb.collection("profiles").update(existing.id, fields); + } else { + await pb.collection("profiles").create({ userId: user.id, referralCode, ...fields }); + } + + const referrerCode = (input.referrerCodeInput || "").trim(); + if (referrerCode) { + try { + await ReferralService.applyReferral(user.id, referrerCode); + } catch (e) { + console.warn("Failed to apply referral code during profile completion:", e); + // We don't block profile completion if referral fails + } + } + } catch (error) { + return { ok: false as const, error: "Failed to update profile." }; + } + + revalidatePath("/game"); + revalidatePath("/research"); + revalidatePath("/account"); + return { ok: true as const }; +} + +export async function getReferralPanelDataAction() { + const { user } = await getRouteUser(); + if (!user) return { ok: false as const, error: "Unauthorized" }; + + const pb = await createPocketbaseAdminClient(); + const profile = await findProfileByUserId(pb, user.id); + + if (!profile?.referralCode) { + const newCode = await ReferralService.ensureReferralCode(user.id); + return { ok: true as const, data: { referralCode: newCode, referredUsers: [] } }; + } + + const referrals = await pb.collection("referrals").getFullList({ + filter: pb.filter("referralCode = {:c}", { c: profile.referralCode }), + }); + + const referreeIds: string[] = referrals.map((r) => r.referreeId); + const referreeProfiles = referreeIds.length + ? await pb.collection("profiles").getFullList({ + filter: referreeIds.map((id) => pb.filter("userId = {:id}", { id })).join(" || "), + }) + : []; + + return { + ok: true as const, + data: { + referralCode: profile.referralCode as string, + referredUsers: referreeProfiles.map((p) => ({ id: p.userId as string, username: (p.username as string) ?? null })), + }, + }; +} + +export async function submitReferralCodeAction(referralCode: string) { + const { user } = await getRouteUser(); + if (!user) return { ok: false as const, error: "You must be logged in." }; + + try { + await ReferralService.applyReferral(user.id, referralCode); + revalidatePath("/research"); + revalidatePath("/game"); + return { ok: true as const }; + } catch (error: any) { + return { ok: false as const, error: error.message || "Failed to submit referral code." }; + } +} diff --git a/src/server/actions/registry.ts b/src/server/actions/registry.ts new file mode 100644 index 00000000..74151be1 --- /dev/null +++ b/src/server/actions/registry.ts @@ -0,0 +1,42 @@ +// Server implementations behind /api/actions/[name] (SSC-31). +// +// These used to be Next.js server actions. The Cloudflare static export cannot +// contain server actions, so client components call the same functions through +// src/lib/actions/callAction.ts, and the route handler dispatches here. Only +// names listed in this map are callable. +import * as classification from "./classification-actions"; +import * as deploy from "./deploy-actions"; +import * as gameplay from "./gameplay"; +import * as mineral from "./mineral-actions"; +import * as profile from "./profile-actions"; +import * as social from "./social-actions"; + +type ServerAction = (...args: any[]) => Promise; + +export const serverActions: Record = { + createClassificationAction: classification.createClassificationAction, + + getTelescopeStatus: deploy.getTelescopeStatus, + getTelescopeAnomalies: deploy.getTelescopeAnomalies, + getTelescopeSkillProgress: deploy.getTelescopeSkillProgress, + deployTelescopeAction: deploy.deployTelescopeAction, + getLinkedAnomaly: deploy.getLinkedAnomaly, + updateLinkedAnomalyAction: deploy.updateLinkedAnomalyAction, + + submitNpsAction: gameplay.submitNpsAction, + submitSurveyorCommentAction: gameplay.submitSurveyorCommentAction, + getExtractionDepositAction: gameplay.getExtractionDepositAction, + completeExtractionAction: gameplay.completeExtractionAction, + + createMineralDepositAction: mineral.createMineralDepositAction, + getMineralDeposits: mineral.getMineralDeposits, + + getCurrentProfileAction: profile.getCurrentProfileAction, + updateProfileSetupAction: profile.updateProfileSetupAction, + completeProfileAction: profile.completeProfileAction, + getReferralPanelDataAction: profile.getReferralPanelDataAction, + submitReferralCodeAction: profile.submitReferralCodeAction, + + toggleVoteAction: social.toggleVoteAction, + submitCommentAction: social.submitCommentAction, +}; diff --git a/src/server/actions/social-actions.ts b/src/server/actions/social-actions.ts new file mode 100644 index 00000000..2e6bbb1c --- /dev/null +++ b/src/server/actions/social-actions.ts @@ -0,0 +1,85 @@ +import { revalidatePath } from "next/cache"; + +import { getRouteUser } from "@/lib/server/routeAuth"; +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; + +type VoteType = "up" | "down"; + +export async function toggleVoteAction(input: { + classificationId: number; + voteType: VoteType; +}) { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + return { ok: false as const, error: "Not signed in" }; + } + + const { classificationId, voteType } = input; + const pb = await createPocketbaseAdminClient(); + + const existingVote = await pb + .collection("votes") + .getFirstListItem( + pb.filter("userId = {:u} && classificationId = {:c}", { u: user.id, c: classificationId }) + ) + .catch(() => null); + + if (existingVote) { + if (existingVote.voteType === voteType) { + await pb.collection("votes").delete(existingVote.id); + revalidatePath(`/posts/${classificationId}`); + return { ok: true as const, userVote: null as VoteType | null }; + } + + await pb.collection("votes").update(existingVote.id, { voteType }); + revalidatePath(`/posts/${classificationId}`); + return { ok: true as const, userVote: voteType as VoteType }; + } + + const latest = await pb.collection("votes").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + await pb.collection("votes").create({ + legacyId: nextLegacyId, + createdAt: new Date().toISOString(), + userId: user.id, + classificationId, + voteType, + }); + + revalidatePath(`/posts/${classificationId}`); + return { ok: true as const, userVote: voteType as VoteType }; +} + +export async function submitCommentAction(input: { + classificationId: number; + content: string; + parentCommentId?: number; +}) { + const { user, authError } = await getRouteUser(); + if (authError || !user) { + return { ok: false as const, error: "Not signed in" }; + } + + const { classificationId, content, parentCommentId } = input; + const trimmed = content.trim(); + if (!trimmed) { + return { ok: false as const, error: "Empty comment" }; + } + + const pb = await createPocketbaseAdminClient(); + const latest = await pb.collection("ss_comments").getList(1, 1, { sort: "-legacyId", fields: "legacyId" }); + const nextLegacyId = (latest.items[0]?.legacyId ?? 0) + 1; + + await pb.collection("ss_comments").create({ + legacyId: nextLegacyId, + createdAt: new Date().toISOString(), + content: trimmed, + author: user.id, + classificationId, + parentCommentId: parentCommentId ?? null, + }); + + revalidatePath(`/posts/${classificationId}`); + return { ok: true as const }; +} diff --git a/src/server/jobs/consumer.ts b/src/server/jobs/consumer.ts new file mode 100644 index 00000000..6f9d9a97 --- /dev/null +++ b/src/server/jobs/consumer.ts @@ -0,0 +1,96 @@ +// Queue consumer (SSC-39). Called by the Worker's `queue()` handler for the +// jobs queue and its dead-letter queue, and by enqueueJobs() when it has to +// run jobs in the background instead. +// +// success ack; push jobs also write a receipt +// already receipted ack without repeating the side effect (redelivery) +// retryable failure retry with exponential backoff, up to MAX_ATTEMPTS +// permanent failure, invalid body or last attempt +// park in KV (`jobs:dead:`) and ack; see +// /api/internal/jobs for listing and replay +import { runJob, PermanentJobError, type HandlerDeps } from "./handlers"; +import { hasReceipt, parkDeadLetter, writeReceipt } from "./queue"; +import { parseJob, RECEIPT_TYPES, type Job } from "./types"; + +/** Must stay below wrangler.jsonc `max_retries` + 1 so we park before the platform dead-letters. */ +export const MAX_ATTEMPTS = 5; + +export type QueueMessageLike = { + id: string; + body: unknown; + attempts: number; + ack(): void; + retry(options?: { delaySeconds?: number }): void; +}; + +export const backoffSeconds = (attempts: number) => Math.min(15 * 60, 30 * 2 ** Math.max(0, attempts - 1)); + +export type JobOutcome = { id: string; type: string | null; outcome: "done" | "duplicate" | "retry" | "parked"; detail?: unknown }; + +async function execute(job: Job, deps: HandlerDeps) { + const idempotent = RECEIPT_TYPES.has(job.type); + if (idempotent && (await hasReceipt(job.id))) return { duplicate: true as const }; + const result = await runJob(job, deps); + if (idempotent) await writeReceipt(job.id, { type: job.type, ...result }); + return { duplicate: false as const, result }; +} + +const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +export async function consumeJobBatch( + messages: readonly QueueMessageLike[], + options: { deadLetterQueue?: boolean; deps?: HandlerDeps } = {}, +): Promise { + const outcomes: JobOutcome[] = []; + // Sequential: keeps the batch's subrequests and CPU predictable. + for (const message of messages) { + const job = parseJob(message.body); + + if (options.deadLetterQueue || !job) { + await parkDeadLetter({ + id: job?.id ?? `invalid:${message.id}`, + job, + body: job ? undefined : message.body, + error: job ? "Delivered to the dead-letter queue after exhausting retries" : "Invalid or unsupported job message", + attempts: message.attempts, + source: options.deadLetterQueue ? "dead-letter-queue" : "consumer", + }); + message.ack(); + outcomes.push({ id: job?.id ?? message.id, type: job?.type ?? null, outcome: "parked" }); + continue; + } + + try { + const { duplicate, result } = await execute(job, options.deps ?? {}); + message.ack(); + outcomes.push({ id: job.id, type: job.type, outcome: duplicate ? "duplicate" : "done", detail: result }); + } catch (error) { + const permanent = error instanceof PermanentJobError; + if (!permanent && message.attempts < MAX_ATTEMPTS) { + console.warn(`[jobs] ${job.type} ${job.id} attempt ${message.attempts} failed, retrying: ${errorText(error)}`); + message.retry({ delaySeconds: backoffSeconds(message.attempts) }); + outcomes.push({ id: job.id, type: job.type, outcome: "retry", detail: errorText(error) }); + continue; + } + await parkDeadLetter({ id: job.id, job, error: errorText(error), attempts: message.attempts, source: "consumer" }); + message.ack(); + outcomes.push({ id: job.id, type: job.type, outcome: "parked", detail: errorText(error) }); + } + } + return outcomes; +} + +/** Fallback when no queue is bound: one attempt each, failures parked for replay. */ +export async function runJobsInBackground(jobs: Job[], deps: HandlerDeps = {}): Promise { + const outcomes: JobOutcome[] = []; + for (const job of jobs) { + try { + const { duplicate, result } = await execute(job, deps); + outcomes.push({ id: job.id, type: job.type, outcome: duplicate ? "duplicate" : "done", detail: result }); + } catch (error) { + await parkDeadLetter({ id: job.id, job, error: errorText(error), attempts: 1, source: "background" }).catch(() => undefined); + outcomes.push({ id: job.id, type: job.type, outcome: "parked", detail: errorText(error) }); + } + } + return outcomes; +} diff --git a/src/server/jobs/handlers.ts b/src/server/jobs/handlers.ts new file mode 100644 index 00000000..59a8a956 --- /dev/null +++ b/src/server/jobs/handlers.ts @@ -0,0 +1,208 @@ +// Job handlers (SSC-39). Each one is safe to run more than once for the same +// job id: analytics events carry the job id as PostHog's dedupe uuid, pushes +// write a receipt (queue.ts) and retry only the endpoints that failed, and +// fan-out jobs derive their children's ids from the run so a repeated page +// produces the same children (already-receipted ones are skipped). +import type PocketBase from "pocketbase"; + +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; + +import { enqueueJobs } from "./queue"; +import type { Job, Notification } from "./types"; +import { pushTopic, sendWebPush, type PushResult, type VapidConfig } from "./webpush"; + +/** Retrying will not help (bad config, invalid data): park immediately. */ +export class PermanentJobError extends Error {} + +export type HandlerDeps = { pb?: () => Promise; fetchImpl?: typeof fetch }; +export type HandlerResult = Record; + +/** Devices per user per job. Keeps a consumer batch under the 50-subrequest Free cap. */ +export const MAX_ENDPOINTS_PER_USER = 5; +/** Follow-up attempts for endpoints that failed with a retryable status. */ +export const MAX_PUSH_RETRIES = 4; +const FAN_OUT_PAGE = 200; +const MAX_FAN_OUT_PAGES = 10; +const ICON = "https://github.com/Signal-K/client/blob/main/public/assets/Captn.jpg?raw=true"; + +function vapidConfig(): VapidConfig | null { + const publicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || process.env.VAPID_PUBLIC_KEY; + const privateKey = process.env.VAPID_PRIVATE_KEY; + if (!publicKey || !privateKey) return null; + return { publicKey, privateKey, subject: process.env.VAPID_SUBJECT || "mailto:admin@starsailors.app" }; +} + +function posthogIngestHost(): string { + const region = (process.env.posthog_region || "US Cloud").toLowerCase(); + return region.includes("eu") ? "https://eu.i.posthog.com" : "https://us.i.posthog.com"; +} + +async function captureAnalytics(job: Extract, deps: HandlerDeps): Promise { + const apiKey = process.env.posthog_api_key ?? process.env.POSTHOG_API_KEY ?? process.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!apiKey) return { skipped: "no PostHog key" }; + + const response = await (deps.fetchImpl ?? fetch)(`${posthogIngestHost()}/batch/`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + api_key: apiKey, + batch: [ + { + event: job.event, + distinct_id: job.distinctId, + properties: { ...job.properties, $lib: "starsailors-worker" }, + timestamp: job.timestamp, + // PostHog drops a second event with the same uuid: redelivery-safe. + uuid: job.id, + }, + ], + }), + }); + if (response.ok) return { captured: 1 }; + const message = `PostHog ${response.status}: ${(await response.text().catch(() => "")).slice(0, 200)}`; + if (response.status === 429 || response.status >= 500) throw new Error(message); + throw new PermanentJobError(message); +} + +type SubscriptionRow = { id: string; endpoint: string; auth: string; p256dh: string }; + +/** Push to a user's devices; schedules a narrowed retry for retryable failures. */ +async function deliverToUser( + pb: PocketBase, + job: Job, + userId: string, + notification: Notification, + options: { topic?: string; endpoints?: string[]; fetchImpl?: typeof fetch }, +): Promise { + const vapid = vapidConfig(); + if (!vapid) throw new PermanentJobError("NEXT_PUBLIC_VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are not configured"); + + const rows = await pb.collection("push_subscriptions").getList(1, 50, { + filter: pb.filter("profileId = {:id}", { id: userId }), + sort: "-createdAt", + fields: "id,endpoint,auth,p256dh", + }); + const byEndpoint = new Map(); + for (const row of rows.items) { + if (!row.endpoint) continue; + byEndpoint.set(row.endpoint, [...(byEndpoint.get(row.endpoint) ?? []), row]); + } + const only = options.endpoints ? new Set(options.endpoints) : null; + const targets = [...byEndpoint.entries()].filter(([endpoint]) => !only || only.has(endpoint)).slice(0, MAX_ENDPOINTS_PER_USER); + if (!targets.length) return { sent: 0, reason: "no subscriptions" }; + + const payload = JSON.stringify({ title: notification.title, body: notification.body, url: notification.url, icon: ICON }); + const results: PushResult[] = []; + for (const [endpoint, [row]] of targets) { + results.push(await sendWebPush({ endpoint, auth: row.auth, p256dh: row.p256dh }, payload, vapid, { topic: options.topic, fetchImpl: options.fetchImpl })); + } + + // Maintenance: the browser unsubscribed, so drop every row for that endpoint. + const gone = results.filter((r) => r.outcome === "gone").flatMap((r) => byEndpoint.get(r.endpoint) ?? []); + await Promise.all(gone.map((row) => pb.collection("push_subscriptions").delete(row.id).catch(() => undefined))); + + const retry = results.filter((r) => r.outcome === "retry").map((r) => r.endpoint); + const rejected = results.filter((r): r is Extract => r.outcome === "rejected"); + for (const r of rejected) console.warn(`[jobs] push rejected (${r.status}) for ${new URL(r.endpoint).host}: ${r.error}`); + + if (retry.length) { + const [rootId, attempt] = splitRetryId(job.id); + if (attempt >= MAX_PUSH_RETRIES) { + // Parked as is: this job already lists only the failing endpoints, so a + // replay retries just those. + throw new PermanentJobError(`push failed for ${retry.length} endpoint(s) after ${attempt + 1} attempts`); + } + await enqueueJobs({ + type: "push.user", + id: `${rootId}~r${attempt + 1}`, + userId, + notification, + topic: options.topic, + endpoints: retry, + delaySeconds: 60 * 2 ** attempt, + }); + } + + return { sent: results.filter((r) => r.outcome === "sent").length, gone: gone.length, retrying: retry.length, rejected: rejected.length }; +} + +/** `abc~r2` → ["abc", 2]; follow-up ids are deterministic, so receipts dedupe them too. */ +export function splitRetryId(id: string): [string, number] { + const match = id.match(/^(.*)~r(\d+)$/); + return match ? [match[1], Number(match[2])] : [id, 0]; +} + +async function subscribedUsersPage(pb: PocketBase, page: number) { + const result = await pb.collection("push_subscriptions").getList(page, FAN_OUT_PAGE, { sort: "profileId", fields: "profileId" }); + const users = [...new Set(result.items.map((row) => row.profileId).filter((id): id is string => typeof id === "string" && id.length > 0))]; + const hasMore = page < Math.min(result.totalPages, MAX_FAN_OUT_PAGES); + return { users, hasMore }; +} + +async function discoveryReminder(pb: PocketBase, job: Extract, deps: HandlerDeps) { + const linked = await pb.collection("linked_anomalies").getList(1, 100, { + filter: pb.filter("author = {:author}", { author: job.userId }), + sort: "-date", + fields: "anomalyId", + }); + const anomalyIds = [...new Set(linked.items.map((row) => Number(row.anomalyId)).filter((id) => Number.isFinite(id) && id > 0))]; + if (!anomalyIds.length) return { sent: 0, reason: "no discoveries" }; + + const classified = await pb.collection("ss_classifications").getList(1, 200, { + filter: `${pb.filter("author = {:author}", { author: job.userId })} && (${anomalyIds.map((id) => `anomaly = ${id}`).join(" || ")})`, + fields: "anomaly", + }); + const done = new Set(classified.items.map((row) => Number(row.anomaly))); + const pending = anomalyIds.filter((id) => !done.has(id)); + if (!pending.length) return { sent: 0, reason: "all classified" }; + + const first = await pb + .collection("anomalies") + .getFirstListItem(pb.filter("legacyId = {:id}", { id: pending[0] }), { fields: "content" }) + .catch(() => null); + const name = (first?.content as string | undefined) || `Discovery #${pending[0]}`; + const notification: Notification = + pending.length === 1 + ? { title: "Discovery Reminder: Classification Needed!", body: `Don't forget to classify: ${name}`, url: "/structures/telescope" } + : { title: `${pending.length} Discoveries Need Classification!`, body: `You have ${pending.length} unclassified discoveries waiting`, url: "/structures/telescope" }; + + return deliverToUser(pb, job, job.userId, notification, { topic: pushTopic(`reminder-${job.day}`), fetchImpl: deps.fetchImpl }); +} + +export async function runJob(job: Job, deps: HandlerDeps = {}): Promise { + const pb = () => (deps.pb ?? createPocketbaseAdminClient)(); + switch (job.type) { + case "analytics.capture": + return captureAnalytics(job, deps); + case "push.user": + return deliverToUser(await pb(), job, job.userId, job.notification, { + topic: job.topic, + endpoints: job.endpoints, + fetchImpl: deps.fetchImpl, + }); + case "push.broadcast": { + const { users, hasMore } = await subscribedUsersPage(await pb(), job.page); + await enqueueJobs([ + ...users.map((userId) => ({ + type: "push.user" as const, + id: `${job.runId}:${userId}`, + userId, + notification: job.notification, + topic: pushTopic(job.runId), + })), + ...(hasMore ? [{ type: "push.broadcast" as const, id: `${job.runId}:p${job.page + 1}`, notification: job.notification, runId: job.runId, page: job.page + 1 }] : []), + ]); + return { users: users.length, nextPage: hasMore }; + } + case "reminders.discoveries": { + const { users, hasMore } = await subscribedUsersPage(await pb(), job.page); + await enqueueJobs([ + ...users.map((userId) => ({ type: "reminders.discovery-user" as const, id: `reminder:${job.day}:${userId}`, userId, day: job.day })), + ...(hasMore ? [{ type: "reminders.discoveries" as const, id: `reminders:${job.day}:p${job.page + 1}`, day: job.day, page: job.page + 1 }] : []), + ]); + return { users: users.length, nextPage: hasMore }; + } + case "reminders.discovery-user": + return discoveryReminder(await pb(), job, deps); + } +} diff --git a/src/server/jobs/jobs.test.ts b/src/server/jobs/jobs.test.ts new file mode 100644 index 00000000..0e1867a4 --- /dev/null +++ b/src/server/jobs/jobs.test.ts @@ -0,0 +1,285 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { configurePlatform, createMemoryKV, type KVLike } from "@/src/server/platform"; +import { fakePocketBase } from "@/src/server/testing/fakePocketBase"; + +import { backoffSeconds, consumeJobBatch, MAX_ATTEMPTS, type QueueMessageLike } from "./consumer"; +import { MAX_PUSH_RETRIES, runJob, splitRetryId } from "./handlers"; +import { enqueueJobs, listDeadLetters, replayDeadLetters, toJob, type EnqueueInput } from "./queue"; +import { parseJob, type Job } from "./types"; +import { b64urlEncode } from "./webpush"; + +let kv: KVLike; +let sent: Array<{ body: Job; delaySeconds?: number }>; +let background: Promise[]; + +function install(options: { queue?: boolean; failSend?: boolean } = {}) { + configurePlatform(() => ({ + kv, + sendJobs: + options.queue === false + ? null + : async (messages) => { + if (options.failSend) throw new Error("Queue operations limit exceeded"); + sent.push(...(messages as typeof sent)); + }, + waitUntil: (promise) => background.push(promise), + localFallback: false, + })); +} + +function message(body: unknown, attempts = 1) { + const calls = { ack: 0, retry: [] as Array<{ delaySeconds?: number } | undefined> }; + const msg: QueueMessageLike = { + id: "msg-1", + body, + attempts, + ack: () => void calls.ack++, + retry: (options) => void calls.retry.push(options), + }; + return { msg, calls }; +} + +async function vapidEnv() { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"])) as CryptoKeyPair; + vi.stubEnv("NEXT_PUBLIC_VAPID_PUBLIC_KEY", b64urlEncode(await crypto.subtle.exportKey("raw", pair.publicKey))); + vi.stubEnv("VAPID_PRIVATE_KEY", (await crypto.subtle.exportKey("jwk", pair.privateKey)).d!); +} + +// A real browser subscription key pair so the payload encrypts. +async function subscription(id: string, endpoint: string, profileId = "user_1") { + const ua = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"])) as CryptoKeyPair; + return { + id, + profileId, + endpoint, + p256dh: b64urlEncode(await crypto.subtle.exportKey("raw", ua.publicKey)), + auth: b64urlEncode(crypto.getRandomValues(new Uint8Array(16))), + createdAt: id, + }; +} + +const pushJob = (overrides: Partial = {}) => + toJob({ type: "push.user", id: "job-1", userId: "user_1", notification: { title: "Hi", body: "There", url: "/game" }, ...overrides } as EnqueueInput); + +beforeEach(() => { + kv = createMemoryKV(); + sent = []; + background = []; + vi.unstubAllEnvs(); + install(); +}); + +describe("parseJob", () => { + it("accepts current messages and rejects old or malformed ones", () => { + expect(parseJob(pushJob())).not.toBeNull(); + expect(parseJob({ ...pushJob(), v: 0 })).toBeNull(); + expect(parseJob({ ...pushJob(), type: "unknown" })).toBeNull(); + expect(parseJob({ ...pushJob(), notification: { title: "" } })).toBeNull(); + expect(parseJob("nope")).toBeNull(); + }); +}); + +describe("enqueueJobs", () => { + it("sends to the queue and returns without running anything", async () => { + const result = await enqueueJobs({ type: "analytics.capture", distinctId: "u", event: "e", properties: {}, timestamp: "t" }); + expect(result.mode).toBe("queued"); + expect(sent).toHaveLength(1); + expect(sent[0].body).toMatchObject({ v: 1, type: "analytics.capture", id: result.ids[0] }); + expect(background).toHaveLength(0); + }); + + it("runs jobs after the response via waitUntil when the queue rejects the send", async () => { + install({ failSend: true }); + vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}")); + vi.stubEnv("posthog_api_key", "phc_test"); + const result = await enqueueJobs({ type: "analytics.capture", distinctId: "u", event: "e", properties: {}, timestamp: "t" }); + expect(result.mode).toBe("background"); + expect(background).toHaveLength(1); + await Promise.all(background); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); +}); + +describe("consumeJobBatch", () => { + it("parks an invalid message instead of retrying it forever", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { msg, calls } = message({ v: 99, type: "push.user" }); + expect((await consumeJobBatch([msg]))[0].outcome).toBe("parked"); + expect(calls.ack).toBe(1); + const [parked] = await listDeadLetters(); + expect(parked).toMatchObject({ id: "invalid:msg-1", job: null, body: { v: 99, type: "push.user" } }); + }); + + it("retries a transient failure with backoff, then parks it on the last attempt", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const failingPb = async () => { + throw new Error("PocketBase 502"); + }; + await vapidEnv(); + const first = message(pushJob(), 1); + await consumeJobBatch([first.msg], { deps: { pb: failingPb } }); + expect(first.calls).toEqual({ ack: 0, retry: [{ delaySeconds: backoffSeconds(1) }] }); + + const last = message(pushJob(), MAX_ATTEMPTS); + await consumeJobBatch([last.msg], { deps: { pb: failingPb } }); + expect(last.calls.ack).toBe(1); + expect(last.calls.retry).toHaveLength(0); + expect((await listDeadLetters())[0]).toMatchObject({ id: "job-1", error: "PocketBase 502", attempts: MAX_ATTEMPTS, source: "consumer" }); + }); + + it("parks a permanent failure immediately", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { msg, calls } = message(pushJob(), 1); // no VAPID keys configured + await consumeJobBatch([msg], { deps: { pb: async () => fakePocketBase({}).pb } }); + expect(calls).toEqual({ ack: 1, retry: [] }); + expect((await listDeadLetters())[0].error).toMatch(/VAPID/); + }); + + it("does not repeat a push when the message is delivered twice", async () => { + await vapidEnv(); + const data = { push_subscriptions: [await subscription("s1", "https://push.example.net/a")] }; + const fetchImpl = vi.fn(async () => new Response(null, { status: 201 })); + const deps = { pb: async () => fakePocketBase(data).pb, fetchImpl: fetchImpl as unknown as typeof fetch }; + + const first = await consumeJobBatch([message(pushJob()).msg], { deps }); + const second = await consumeJobBatch([message(pushJob()).msg], { deps }); + expect(first[0]).toMatchObject({ outcome: "done", detail: { sent: 1 } }); + expect(second[0].outcome).toBe("duplicate"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("parks messages that reach the dead-letter queue", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { msg, calls } = message(pushJob(), 6); + await consumeJobBatch([msg], { deadLetterQueue: true }); + expect(calls.ack).toBe(1); + expect((await listDeadLetters())[0]).toMatchObject({ id: "job-1", source: "dead-letter-queue" }); + }); +}); + +describe("push.user", () => { + it("retries only the failed endpoints and drops subscriptions the browser removed", async () => { + await vapidEnv(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const data = { + push_subscriptions: [ + await subscription("s1", "https://push.example.net/ok"), + await subscription("s2", "https://push.example.net/busy"), + await subscription("s3", "https://push.example.net/gone"), + await subscription("s4", "https://push.example.net/ok"), // duplicate endpoint + ], + }; + const fake = fakePocketBase(data); + const status: Record = { ok: 201, busy: 503, gone: 410 }; + const fetchImpl = vi.fn(async (url: string) => new Response(null, { status: status[url.split("/").pop()!] })); + + const result = await runJob(pushJob(), { pb: async () => fake.pb, fetchImpl: fetchImpl as unknown as typeof fetch }); + expect(result).toEqual({ sent: 1, gone: 1, retrying: 1, rejected: 0 }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(fake.deleted).toEqual([["push_subscriptions", "s3"]]); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + delaySeconds: 60, + body: { type: "push.user", id: "job-1~r1", endpoints: ["https://push.example.net/busy"] }, + }); + }); + + it("gives up after MAX_PUSH_RETRIES follow-ups", async () => { + await vapidEnv(); + const data = { push_subscriptions: [await subscription("s1", "https://push.example.net/busy")] }; + const fetchImpl = vi.fn(async () => new Response(null, { status: 503 })); + await expect( + runJob(pushJob({ id: `job-1~r${MAX_PUSH_RETRIES}` }), { pb: async () => fakePocketBase(data).pb, fetchImpl: fetchImpl as unknown as typeof fetch }), + ).rejects.toThrow(/after 5 attempts/); + expect(sent).toHaveLength(0); + }); + + it("splits retry ids", () => { + expect(splitRetryId("reminder:2026-09-25:u1")).toEqual(["reminder:2026-09-25:u1", 0]); + expect(splitRetryId("abc~r3")).toEqual(["abc", 3]); + }); +}); + +describe("fan-out and reminders", () => { + it("queues one reminder per subscribed user with per-day ids", async () => { + const data = { + push_subscriptions: [ + await subscription("s1", "https://p/1", "user_a"), + await subscription("s2", "https://p/2", "user_a"), + await subscription("s3", "https://p/3", "user_b"), + ], + }; + const job = toJob({ type: "reminders.discoveries", id: "reminders:2026-09-25:p1", day: "2026-09-25", page: 1 }); + expect(await runJob(job, { pb: async () => fakePocketBase(data).pb })).toEqual({ users: 2, nextPage: false }); + expect(sent.map((m) => m.body.id)).toEqual(["reminder:2026-09-25:user_a", "reminder:2026-09-25:user_b"]); + }); + + it("reminds a user only about discoveries they have not classified", async () => { + await vapidEnv(); + const data = { + linked_anomalies: [ + { author: "user_a", anomalyId: 7, date: "2" }, + { author: "user_a", anomalyId: 8, date: "1" }, + ], + ss_classifications: [{ author: "user_a", anomaly: 7 }], + anomalies: [{ legacyId: 8, content: "TIC 1234" }], + push_subscriptions: [await subscription("s1", "https://push.example.net/a", "user_a")], + }; + const bodies: string[] = []; + const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => { + bodies.push((init.headers as Record).topic); + return new Response(null, { status: 201 }); + }); + const job = toJob({ type: "reminders.discovery-user", id: "reminder:2026-09-25:user_a", userId: "user_a", day: "2026-09-25" }); + expect(await runJob(job, { pb: async () => fakePocketBase(data).pb, fetchImpl: fetchImpl as unknown as typeof fetch })).toMatchObject({ sent: 1 }); + expect(bodies).toEqual(["reminder-2026-09-25"]); + + const allDone = { ...data, ss_classifications: [{ author: "user_a", anomaly: 7 }, { author: "user_a", anomaly: 8 }] }; + expect(await runJob(job, { pb: async () => fakePocketBase(allDone).pb })).toEqual({ sent: 0, reason: "all classified" }); + }); +}); + +describe("analytics.capture", () => { + it("uses the job id as PostHog's dedupe uuid", async () => { + vi.stubEnv("posthog_api_key", "phc_test"); + const fetchImpl = vi.fn(async () => new Response("{}")); + const job = toJob({ type: "analytics.capture", distinctId: "user_1", event: "classification_submitted", properties: { a: 1 }, timestamp: "2026-09-25T00:00:00Z" }); + await runJob(job, { fetchImpl: fetchImpl as unknown as typeof fetch }); + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://us.i.posthog.com/batch/"); + expect(JSON.parse(String(init.body)).batch[0]).toMatchObject({ uuid: job.id, event: "classification_submitted", distinct_id: "user_1" }); + }); + + it("retries PostHog outages but not rejected events", async () => { + vi.stubEnv("posthog_api_key", "phc_test"); + const job = toJob({ type: "analytics.capture", distinctId: "u", event: "e", properties: {}, timestamp: "t" }); + const outage = vi.fn(async () => new Response("down", { status: 503 })); + const bad = vi.fn(async () => new Response("bad", { status: 400 })); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const a = message(job); + await consumeJobBatch([a.msg], { deps: { fetchImpl: outage as unknown as typeof fetch } }); + expect(a.calls.retry).toHaveLength(1); + const b = message(job); + await consumeJobBatch([b.msg], { deps: { fetchImpl: bad as unknown as typeof fetch } }); + expect(b.calls).toEqual({ ack: 1, retry: [] }); + }); +}); + +describe("replayDeadLetters", () => { + it("re-enqueues parked jobs under their original ids", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await consumeJobBatch([message(pushJob(), 1).msg], { deps: { pb: async () => fakePocketBase({}).pb } }); // parks (no VAPID) + await consumeJobBatch([message({ junk: true }).msg]); + expect(await listDeadLetters()).toHaveLength(2); + + const result = await replayDeadLetters(); + expect(result).toEqual({ replayed: ["job-1"], skipped: ["invalid:msg-1"] }); + expect(sent.map((m) => m.body.id)).toEqual(["job-1"]); + expect((await listDeadLetters()).map((d) => d.id)).toEqual(["invalid:msg-1"]); + }); +}); diff --git a/src/server/jobs/queue.ts b/src/server/jobs/queue.ts new file mode 100644 index 00000000..beb53d89 --- /dev/null +++ b/src/server/jobs/queue.ts @@ -0,0 +1,107 @@ +// Producer side of the background jobs (SSC-39), plus the KV records that make +// them idempotent (receipts) and recoverable (parked dead letters). +// +// In the Worker, enqueueJobs() sends to Cloudflare Queues (`JOBS` binding) and +// returns once the queue has accepted the messages, so interactive responses +// never wait on push services, PostHog or fan-out work. When the queue is not +// bound or rejects the send (for example the Free plan's daily operation +// limit), the jobs run after the response via ctx.waitUntil instead, and +// failures are parked the same way. +import { platform } from "@/src/server/platform"; + +import { JOB_SCHEMA_VERSION, type Job, type JobPayload } from "./types"; + +export type EnqueueInput = JobPayload & { id?: string; delaySeconds?: number }; +export type EnqueueResult = { mode: "queued" | "background"; ids: string[] }; + +const QUEUE_SEND_BATCH = 100; // Queues sendBatch limit + +export function toJob(input: EnqueueInput, now = Date.now()): Job { + const { id, delaySeconds: _delay, ...payload } = input; + return { ...(payload as JobPayload), v: JOB_SCHEMA_VERSION, id: id ?? crypto.randomUUID(), createdAt: new Date(now).toISOString() } as Job; +} + +export async function enqueueJobs(inputs: EnqueueInput | EnqueueInput[]): Promise { + const list = Array.isArray(inputs) ? inputs : [inputs]; + const jobs = list.map((input) => ({ job: toJob(input), delaySeconds: input.delaySeconds })); + const ids = jobs.map(({ job }) => job.id); + if (!jobs.length) return { mode: "queued", ids }; + + const { sendJobs, waitUntil } = platform(); + if (sendJobs) { + try { + for (let i = 0; i < jobs.length; i += QUEUE_SEND_BATCH) { + await sendJobs(jobs.slice(i, i + QUEUE_SEND_BATCH).map(({ job, delaySeconds }) => ({ body: job, delaySeconds }))); + } + return { mode: "queued", ids }; + } catch (error) { + console.error(`[jobs] queue send failed, running ${jobs.length} job(s) in the background: ${String(error)}`); + } + } + + const background = import("./consumer").then(({ runJobsInBackground }) => runJobsInBackground(jobs.map(({ job }) => job))); + if (waitUntil) waitUntil(background); + else void background.catch((error) => console.error("[jobs] background run failed", error)); + return { mode: "background", ids }; +} + +// ── Receipts: "this job's side effect already happened" ────────────────────── + +const RECEIPT_PREFIX = "jobs:done:"; +const RECEIPT_TTL_SECONDS = 3 * 24 * 60 * 60; + +export async function hasReceipt(id: string): Promise { + return (await platform().kv.get(RECEIPT_PREFIX + id, "json")) !== null; +} + +export async function writeReceipt(id: string, summary: Record): Promise { + await platform().kv.put(RECEIPT_PREFIX + id, JSON.stringify({ at: new Date().toISOString(), ...summary }), { + expirationTtl: RECEIPT_TTL_SECONDS, + }); +} + +// ── Dead letters: jobs that exhausted their retries, kept for replay ───────── + +export const DEAD_PREFIX = "jobs:dead:"; +const DEAD_TTL_SECONDS = 14 * 24 * 60 * 60; + +export type DeadLetter = { + id: string; + /** The job, or the raw body when it failed validation. */ + job: Job | null; + body?: unknown; + error: string; + attempts: number; + failedAt: string; + source: "consumer" | "dead-letter-queue" | "background"; +}; + +export async function parkDeadLetter(entry: Omit): Promise { + const record: DeadLetter = { ...entry, error: entry.error.slice(0, 1000), failedAt: new Date().toISOString() }; + console.error(`[jobs] parked ${entry.job?.type ?? "invalid"} job ${entry.id} after ${entry.attempts} attempt(s): ${record.error}`); + await platform().kv.put(DEAD_PREFIX + entry.id, JSON.stringify(record), { expirationTtl: DEAD_TTL_SECONDS }); +} + +export async function listDeadLetters(limit = 50): Promise { + const kv = platform().kv; + const { keys } = await kv.list({ prefix: DEAD_PREFIX, limit }); + const records = await Promise.all(keys.map(({ name }) => kv.get(name, "json") as Promise)); + return records.filter((r): r is DeadLetter => r !== null); +} + +/** + * Re-enqueue parked jobs (all listed, or only `ids`) under their original ids, + * so receipts still stop a job whose side effect did happen from repeating. + * Invalid bodies cannot be replayed and stay parked. + */ +export async function replayDeadLetters(ids?: string[]): Promise<{ replayed: string[]; skipped: string[] }> { + const kv = platform().kv; + const wanted = ids ? new Set(ids) : null; + const parked = (await listDeadLetters(100)).filter((entry) => !wanted || wanted.has(entry.id)); + const replayable = parked.filter((entry) => entry.job); + if (replayable.length) { + await enqueueJobs(replayable.map(({ job }) => ({ ...job! }))); + await Promise.all(replayable.map((entry) => kv.delete(DEAD_PREFIX + entry.id))); + } + return { replayed: replayable.map((e) => e.id), skipped: parked.filter((e) => !e.job).map((e) => e.id) }; +} diff --git a/src/server/jobs/types.ts b/src/server/jobs/types.ts new file mode 100644 index 00000000..6dbe57ad --- /dev/null +++ b/src/server/jobs/types.ts @@ -0,0 +1,59 @@ +// Background job messages (SSC-39). Everything a queue consumer can receive, +// versioned so an old message left in the queue after a deploy is rejected +// cleanly (and parked for replay) instead of being misread. + +export const JOB_SCHEMA_VERSION = 1; + +export type Notification = { title: string; body: string; url: string }; + +export type JobPayload = + /** Server-side PostHog event. PostHog deduplicates on the job id (uuid). */ + | { type: "analytics.capture"; distinctId: string; event: string; properties: Record; timestamp: string } + /** Push to one user's devices; `endpoints` narrows a retry to the ones that failed. */ + | { type: "push.user"; userId: string; notification: Notification; topic?: string; endpoints?: string[] } + /** Fan-out: one push.user per subscribed user, a page at a time (admin broadcast). */ + | { type: "push.broadcast"; notification: Notification; runId: string; page: number } + /** Fan-out: one reminders.discovery-user per subscribed user, a page at a time (daily cron). */ + | { type: "reminders.discoveries"; day: string; page: number } + /** Remind one user about linked anomalies they have not classified yet. */ + | { type: "reminders.discovery-user"; userId: string; day: string }; + +export type JobType = JobPayload["type"]; + +export type Job = JobPayload & { + v: typeof JOB_SCHEMA_VERSION; + /** Stable across retries and redeliveries; the idempotency key. */ + id: string; + createdAt: string; +}; + +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const isString = (value: unknown): value is string => typeof value === "string" && value.length > 0; +const isNotification = (value: unknown): value is Notification => + isRecord(value) && isString(value.title) && typeof value.body === "string" && isString(value.url); + +/** Validates a message body; null means it cannot be processed by this version. */ +export function parseJob(body: unknown): Job | null { + if (!isRecord(body) || body.v !== JOB_SCHEMA_VERSION || !isString(body.id) || !isString(body.createdAt)) return null; + switch (body.type) { + case "analytics.capture": + return isString(body.distinctId) && isString(body.event) && isRecord(body.properties) && isString(body.timestamp) ? (body as Job) : null; + case "push.user": + return isString(body.userId) && + isNotification(body.notification) && + (body.endpoints === undefined || (Array.isArray(body.endpoints) && body.endpoints.every(isString))) + ? (body as Job) + : null; + case "push.broadcast": + return isNotification(body.notification) && isString(body.runId) && Number.isInteger(body.page) ? (body as Job) : null; + case "reminders.discoveries": + return isString(body.day) && Number.isInteger(body.page) ? (body as Job) : null; + case "reminders.discovery-user": + return isString(body.userId) && isString(body.day) ? (body as Job) : null; + default: + return null; + } +} + +/** Side effects that must not repeat when a message is delivered twice. */ +export const RECEIPT_TYPES: ReadonlySet = new Set(["push.user", "reminders.discovery-user"]); diff --git a/src/server/jobs/webpush.test.ts b/src/server/jobs/webpush.test.ts new file mode 100644 index 00000000..e6375330 --- /dev/null +++ b/src/server/jobs/webpush.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; + +import { b64urlDecode, b64urlEncode, encryptPayload, sendWebPush, vapidToken, type PushTarget } from "./webpush"; + +// RFC 8291 section 5 / appendix A example. +const RFC = { + plaintext: "When I grow up, I want to be a watermelon", + asPrivate: "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw", + asPublic: "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", + uaPublic: "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + salt: "DGv6ra1nlYgDCS1FRnbzlw", + authSecret: "BTBZMqHH6r4Tts7J_aSIgg", + body: + "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN", +}; + +async function importPrivate(d: string, publicKey: string, usage: "deriveBits" | "sign") { + const point = b64urlDecode(publicKey); + const jwk = { kty: "EC", crv: "P-256", d, x: b64urlEncode(point.slice(1, 33)), y: b64urlEncode(point.slice(33)) }; + const algorithm = usage === "sign" ? { name: "ECDSA", namedCurve: "P-256" } : { name: "ECDH", namedCurve: "P-256" }; + return crypto.subtle.importKey("jwk", jwk, algorithm, false, [usage]); +} + +describe("encryptPayload", () => { + it("matches the RFC 8291 example byte for byte", async () => { + const target: PushTarget = { endpoint: "https://push.example.net/x", p256dh: RFC.uaPublic, auth: RFC.authSecret }; + const body = await encryptPayload(target, new TextEncoder().encode(RFC.plaintext), { + salt: b64urlDecode(RFC.salt), + senderKeys: { privateKey: await importPrivate(RFC.asPrivate, RFC.asPublic, "deriveBits"), publicKey: b64urlDecode(RFC.asPublic) }, + }); + expect(b64urlEncode(body)).toBe(RFC.body); + }); +}); + +async function vapidKeys() { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const jwk = await crypto.subtle.exportKey("jwk", pair.privateKey); + const publicKey = b64urlEncode(await crypto.subtle.exportKey("raw", pair.publicKey)); + return { vapid: { publicKey, privateKey: jwk.d!, subject: "mailto:ops@example.test" }, verifyKey: pair.publicKey }; +} + +describe("vapidToken", () => { + it("signs an ES256 JWT for the push service origin", async () => { + const { vapid, verifyKey } = await vapidKeys(); + const token = await vapidToken("https://fcm.googleapis.com", vapid, 1_800_000_000); + const [header, claims, signature] = token.split("."); + expect(JSON.parse(new TextDecoder().decode(b64urlDecode(claims)))).toEqual({ + aud: "https://fcm.googleapis.com", + exp: 1_800_000_000 + 12 * 3600, + sub: "mailto:ops@example.test", + }); + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + verifyKey, + b64urlDecode(signature), + new TextEncoder().encode(`${header}.${claims}`), + ); + expect(valid).toBe(true); + }); +}); + +describe("sendWebPush", () => { + const target: PushTarget = { endpoint: "https://push.example.net/sub/1", p256dh: RFC.uaPublic, auth: RFC.authSecret }; + + it.each([ + [201, "sent"], + [410, "gone"], + [404, "gone"], + [429, "retry"], + [503, "retry"], + [400, "rejected"], + ])("maps HTTP %i to %s", async (status, outcome) => { + const { vapid } = await vapidKeys(); + const fetchImpl = vi.fn(async () => new Response("", { status })); + const result = await sendWebPush(target, "{}", vapid, { fetchImpl: fetchImpl as unknown as typeof fetch, topic: "t1" }); + expect(result.outcome).toBe(outcome); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit & { headers: Record }]; + expect(init.headers["content-encoding"]).toBe("aes128gcm"); + expect(init.headers.authorization).toMatch(/^vapid t=[^.]+\.[^.]+\.[^,]+, k=/); + expect(init.headers.topic).toBe("t1"); + }); + + it("treats a network failure as retryable", async () => { + const { vapid } = await vapidKeys(); + const fetchImpl = vi.fn(async () => { + throw new Error("connect ETIMEDOUT"); + }); + expect(await sendWebPush(target, "{}", vapid, { fetchImpl: fetchImpl as unknown as typeof fetch })).toMatchObject({ outcome: "retry", status: null }); + }); +}); diff --git a/src/server/jobs/webpush.ts b/src/server/jobs/webpush.ts new file mode 100644 index 00000000..001a87c9 --- /dev/null +++ b/src/server/jobs/webpush.ts @@ -0,0 +1,160 @@ +// Web Push for the Worker (SSC-39): VAPID (RFC 8292) and aes128gcm payload +// encryption (RFC 8291 / RFC 8188) on WebCrypto. The `web-push` npm package +// relies on Node's https and ECDH APIs; this runs natively in workerd and in +// Node 20+. + +export type PushTarget = { endpoint: string; p256dh: string; auth: string }; +export type VapidConfig = { publicKey: string; privateKey: string; subject: string }; + +export type PushResult = + | { endpoint: string; outcome: "sent" } + /** 404/410: the browser dropped the subscription; delete it. */ + | { endpoint: string; outcome: "gone"; status: number } + /** 429/5xx/network: worth retrying later. */ + | { endpoint: string; outcome: "retry"; status: number | null; error: string } + /** Any other 4xx: retrying will not help. */ + | { endpoint: string; outcome: "rejected"; status: number; error: string }; + +const encoder = new TextEncoder(); + +type Bytes = Uint8Array; + +export function b64urlDecode(value: string): Bytes { + const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +export function b64urlEncode(data: ArrayBuffer | Uint8Array): string { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +const concat = (...parts: Uint8Array[]): Bytes => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +}; + +async function hkdf(salt: Bytes, ikm: Bytes, info: Bytes, bytes: number): Promise { + const key = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]); + return new Uint8Array(await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt, info }, key, bytes * 8)); +} + +/** An uncompressed P-256 point (0x04 || x || y) as JWK coordinates. */ +function pointToJwk(point: Bytes) { + if (point.length !== 65 || point[0] !== 4) throw new Error("Expected an uncompressed P-256 public key"); + return { kty: "EC", crv: "P-256", x: b64urlEncode(point.slice(1, 33)), y: b64urlEncode(point.slice(33)) }; +} + +export type EncryptOptions = { + /** Test hooks for the RFC 8291 example; random in production. */ + salt?: Bytes; + senderKeys?: { privateKey: CryptoKey; publicKey: Bytes }; +}; + +/** RFC 8291 message encryption, one aes128gcm record. */ +export async function encryptPayload(target: PushTarget, payload: Uint8Array, options: EncryptOptions = {}): Promise { + const uaPublic = b64urlDecode(target.p256dh); + const authSecret = b64urlDecode(target.auth); + const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(16)); + + let sender = options.senderKeys; + if (!sender) { + const pair = (await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"])) as CryptoKeyPair; + sender = { privateKey: pair.privateKey, publicKey: new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)) }; + } + + const uaKey = await crypto.subtle.importKey("raw", uaPublic, { name: "ECDH", namedCurve: "P-256" }, false, []); + const ecdhSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: uaKey }, sender.privateKey, 256)); + + const keyInfo = concat(encoder.encode("WebPush: info\0"), uaPublic, sender.publicKey); + const ikm = await hkdf(authSecret, ecdhSecret, keyInfo, 32); + const cek = await hkdf(salt, ikm, encoder.encode("Content-Encoding: aes128gcm\0"), 16); + const nonce = await hkdf(salt, ikm, encoder.encode("Content-Encoding: nonce\0"), 12); + + const key = await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["encrypt"]); + // 0x02 marks the last (only) record; no padding. + const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, concat(payload, new Uint8Array([2])))); + + const header = new Uint8Array(16 + 4 + 1 + sender.publicKey.length); + header.set(salt, 0); + new DataView(header.buffer).setUint32(16, 4096); + header[20] = sender.publicKey.length; + header.set(sender.publicKey, 21); + return concat(header, ciphertext); +} + +let signingKey: { privateKey: string; key: Promise } | null = null; +const tokenCache = new Map(); + +function vapidSigningKey(vapid: VapidConfig): Promise { + if (signingKey?.privateKey !== vapid.privateKey) { + const jwk = { ...pointToJwk(b64urlDecode(vapid.publicKey)), d: vapid.privateKey }; + signingKey = { + privateKey: vapid.privateKey, + key: crypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]), + }; + } + return signingKey.key; +} + +/** VAPID JWT for a push service origin; cached per isolate until near expiry. */ +export async function vapidToken(audience: string, vapid: VapidConfig, now = Math.floor(Date.now() / 1000)): Promise { + const cached = tokenCache.get(audience); + if (cached && cached.expires - 600 > now) return cached.token; + + const expires = now + 12 * 60 * 60; + const header = b64urlEncode(encoder.encode(JSON.stringify({ typ: "JWT", alg: "ES256" }))); + const claims = b64urlEncode(encoder.encode(JSON.stringify({ aud: audience, exp: expires, sub: vapid.subject }))); + const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, await vapidSigningKey(vapid), encoder.encode(`${header}.${claims}`)); + const token = `${header}.${claims}.${b64urlEncode(signature)}`; + tokenCache.set(audience, { token, expires }); + return token; +} + +export type SendOptions = { + /** Seconds the push service keeps an undelivered message. */ + ttl?: number; + /** Replaces an undelivered message with the same topic (≤32 URL-safe chars). */ + topic?: string; + fetchImpl?: typeof fetch; +}; + +export async function sendWebPush(target: PushTarget, payload: string, vapid: VapidConfig, options: SendOptions = {}): Promise { + const endpoint = target.endpoint; + try { + const url = new URL(endpoint); + const body = await encryptPayload(target, encoder.encode(payload)); + const headers: Record = { + authorization: `vapid t=${await vapidToken(url.origin, vapid)}, k=${vapid.publicKey}`, + "content-encoding": "aes128gcm", + "content-type": "application/octet-stream", + ttl: String(options.ttl ?? 24 * 60 * 60), + urgency: "normal", + }; + if (options.topic) headers.topic = options.topic; + + const response = await (options.fetchImpl ?? fetch)(endpoint, { method: "POST", headers, body }); + if (response.status >= 200 && response.status < 300) return { endpoint, outcome: "sent" }; + const error = (await response.text().catch(() => "")).slice(0, 200) || response.statusText; + if (response.status === 404 || response.status === 410) return { endpoint, outcome: "gone", status: response.status }; + if (response.status === 429 || response.status >= 500) return { endpoint, outcome: "retry", status: response.status, error }; + return { endpoint, outcome: "rejected", status: response.status, error }; + } catch (error) { + return { endpoint, outcome: "retry", status: null, error: error instanceof Error ? error.message : String(error) }; + } +} + +/** Push `Topic` values must be ≤32 chars of the URL-safe base64 alphabet. */ +export function pushTopic(value: string): string { + return value.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32); +} diff --git a/src/server/platform.ts b/src/server/platform.ts new file mode 100644 index 00000000..1e5af5b6 --- /dev/null +++ b/src/server/platform.ts @@ -0,0 +1,79 @@ +// Cloudflare bindings, as seen by code shared between the app Worker and the +// Next.js dev/test server (SSC-37, SSC-39). +// +// The Worker installs the real bindings (Workers KV, Queues, waitUntil) with +// configurePlatform() before it runs a route handler, queue batch or cron. +// Anywhere else (next dev, `yarn start` for Cypress, vitest) the defaults +// apply: an in-memory KV and jobs that run in the background of the same +// process, so the app still works without Cloudflare. + +/** The subset of a Workers KV namespace we use. */ +export type KVLike = { + get(key: string, type: "json"): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; + delete(key: string): Promise; + list(options: { prefix: string; limit?: number; cursor?: string }): Promise<{ + keys: Array<{ name: string }>; + list_complete: boolean; + cursor?: string; + }>; +}; + +export type PlatformJobSender = (messages: Array<{ body: unknown; delaySeconds?: number }>) => Promise; + +export type Platform = { + kv: KVLike; + /** Cloudflare Queues producer; null runs jobs in the background instead. */ + sendJobs: PlatformJobSender | null; + /** Keeps background work alive after the response (Worker `ctx.waitUntil`). */ + waitUntil: ((promise: Promise) => void) | null; + /** True when kv is the in-memory stand-in rather than Workers KV. */ + localFallback: boolean; +}; + +export function createMemoryKV(): KVLike { + const store = new Map(); + const live = (key: string) => { + const entry = store.get(key); + if (entry && entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + store.delete(key); + return undefined; + } + return entry; + }; + return { + async get(key) { + const entry = live(key); + return entry ? JSON.parse(entry.value) : null; + }, + async put(key, value, options) { + store.set(key, { value, expiresAt: options?.expirationTtl ? Date.now() + options.expirationTtl * 1000 : null }); + }, + async delete(key) { + store.delete(key); + }, + async list({ prefix, limit = 1000 }) { + const keys = [...store.keys()].filter((name) => name.startsWith(prefix) && live(name)).sort(); + return { keys: keys.slice(0, limit).map((name) => ({ name })), list_complete: keys.length <= limit }; + }, + }; +} + +let fallback: Platform | null = null; +let installed: (() => Platform) | null = null; + +/** The Worker passes a resolver so waitUntil can follow the current request. */ +export function configurePlatform(resolver: (() => Platform) | null) { + installed = resolver; +} + +export function platform(): Platform { + if (installed) return installed(); + fallback ??= { kv: createMemoryKV(), sendJobs: null, waitUntil: null, localFallback: true }; + return fallback; +} + +/** Tests: forget the in-memory KV between cases. */ +export function resetLocalPlatform() { + fallback = null; +} diff --git a/src/server/snapshots/compute.ts b/src/server/snapshots/compute.ts new file mode 100644 index 00000000..c1e8a728 --- /dev/null +++ b/src/server/snapshots/compute.ts @@ -0,0 +1,146 @@ +// Producers for the public snapshots (SSC-37). They run on the Worker's cron +// trigger, never on a user request, and each makes a small, bounded number of +// PocketBase reads. Everything here must be safe to show to any visitor except +// fields the read side strips (see definitions.ts). +import type PocketBase from "pocketbase"; + +const DAY_MS = 24 * 60 * 60 * 1000; +/** Rows scanned for the unique-author / unique-project counts. */ +export const ACTIVITY_SCAN_LIMIT = 500; + +export type LandingStats = { + totalClassifications: number; + classificationsLast24h: number; + activeSailors24h: number; + activeProjects7d: number; + /** Classifications per project type over the last 7 days (from the scan). */ + projects: Array<{ type: string; classifications7d: number }>; + /** True when a window held more rows than ACTIVITY_SCAN_LIMIT, so counts are lower bounds. */ + sampled: boolean; +}; + +export async function computeLandingStats(pb: PocketBase, now: number): Promise { + const dayAgo = new Date(now - DAY_MS).toISOString(); + const weekAgo = new Date(now - 7 * DAY_MS).toISOString(); + const classifications = pb.collection("ss_classifications"); + + const [total, lastDay, lastWeek] = await Promise.all([ + classifications.getList(1, 1, { fields: "id" }), + classifications.getList(1, ACTIVITY_SCAN_LIMIT, { + filter: pb.filter("createdAt >= {:since} && author != null", { since: dayAgo }), + fields: "author", + }), + classifications.getList(1, ACTIVITY_SCAN_LIMIT, { + filter: pb.filter("createdAt >= {:since} && classificationtype != null", { since: weekAgo }), + fields: "classificationtype", + }), + ]); + + const authors = new Set(lastDay.items.map((row) => row.author).filter((v): v is string => typeof v === "string" && v.length > 0)); + const perType = new Map(); + for (const row of lastWeek.items) { + const type = row.classificationtype; + if (typeof type === "string" && type) perType.set(type, (perType.get(type) ?? 0) + 1); + } + + return { + totalClassifications: total.totalItems, + classificationsLast24h: lastDay.totalItems, + activeSailors24h: authors.size, + activeProjects7d: perType.size, + projects: [...perType.entries()] + .map(([type, classifications7d]) => ({ type, classifications7d })) + .sort((a, b) => b.classifications7d - a.classifications7d || a.type.localeCompare(b.type)), + sampled: lastDay.totalItems > lastDay.items.length || lastWeek.totalItems > lastWeek.items.length, + }; +} + +export type LeaderEntry = { user_id: string; username: string; full_name: string; avatar_url: string | null; count: number }; +export type SunspotLeaderboard = { probeLeaders: LeaderEntry[]; classificationLeaders: LeaderEntry[] }; + +const LEADERBOARD_SIZE = 10; + +function topCounts(counts: Map): Array<[string, number]> { + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, LEADERBOARD_SIZE); +} + +export async function computeSunspotLeaderboard(pb: PocketBase): Promise { + const [probes, sunspots] = await Promise.all([ + pb.collection("defensive_probes").getFullList({ fields: "userId,count" }), + pb.collection("ss_classifications").getFullList({ + filter: pb.filter("classificationtype = {:t}", { t: "sunspot" }), + fields: "author", + }), + ]); + + const probeCounts = new Map(); + for (const p of probes) { + if (!p.userId) continue; + probeCounts.set(p.userId, (probeCounts.get(p.userId) ?? 0) + (Number(p.count) || 0)); + } + const classificationCounts = new Map(); + for (const c of sunspots) { + if (!c.author) continue; + classificationCounts.set(c.author, (classificationCounts.get(c.author) ?? 0) + 1); + } + + const topProbes = topCounts(probeCounts); + const topClassifiers = topCounts(classificationCounts); + + // Only the ranked users need a profile, at most 2 × LEADERBOARD_SIZE. + const ids = [...new Set([...topProbes, ...topClassifiers].map(([id]) => id))]; + const profiles = ids.length + ? await pb.collection("profiles").getFullList({ + filter: ids.map((id) => pb.filter("userId = {:id}", { id })).join(" || "), + fields: "userId,username,fullName,avatarUrl", + }) + : []; + const byUser = new Map(profiles.map((p) => [p.userId as string, p])); + + const entry = ([userId, count]: [string, number]): LeaderEntry => { + const profile = byUser.get(userId); + return { + user_id: userId, + username: profile?.username || "Anonymous", + full_name: profile?.fullName || "Unknown", + avatar_url: profile?.avatarUrl ?? null, + count, + }; + }; + + return { probeLeaders: topProbes.map(entry), classificationLeaders: topClassifiers.map(entry) }; +} + +export type CommunityActivityItem = { id: number; author: string; authorId: string | null; type: string | null; at: string }; + +/** Kept larger than the 12 the UI shows so `?exclude=` still fills the list. */ +export const COMMUNITY_ACTIVITY_SIZE = 24; + +export async function computeCommunityActivity(pb: PocketBase, now: number): Promise { + const rows = await pb.collection("ss_classifications").getList(1, COMMUNITY_ACTIVITY_SIZE, { + filter: pb.filter("createdAt >= {:d}", { d: new Date(now - DAY_MS).toISOString() }), + sort: "-createdAt", + fields: "legacyId,author,classificationtype,createdAt", + }); + return rows.items.map((r) => ({ + id: r.legacyId as number, + author: (r.author as string | null)?.slice(0, 8) ?? "user", + authorId: (r.author as string | null) ?? null, + type: (r.classificationtype as string | null) ?? null, + at: r.createdAt as string, + })); +} + +export type HubTopProfile = { userId: string; username: string | null; score: number }; + +export async function computeHubTopProfiles(pb: PocketBase): Promise { + const result = await pb.collection("profiles").getList(1, 5, { + sort: "-classificationPoints,+updatedAt", + fields: "userId,username,classificationPoints", + }); + return result.items.map((p) => ({ + userId: p.userId as string, + username: (p.username as string | null) || null, + score: Number(p.classificationPoints ?? 0), + })); +} diff --git a/src/server/snapshots/store.test.ts b/src/server/snapshots/store.test.ts new file mode 100644 index 00000000..46124bc5 --- /dev/null +++ b/src/server/snapshots/store.test.ts @@ -0,0 +1,145 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { configurePlatform, createMemoryKV, type KVLike } from "@/src/server/platform"; +import { fakePocketBase } from "@/src/server/testing/fakePocketBase"; + +import { readAllSnapshots, readSnapshot, refreshSnapshots, resetSnapshotMemo, SNAPSHOT_BUNDLE_KEY } from "./store"; + +const NOW = Date.parse("2026-09-25T12:00:00Z"); +const minutes = (n: number) => n * 60_000; +const iso = (offsetMs: number) => new Date(NOW - offsetMs).toISOString(); + +let kv: KVLike; + +function seed() { + return fakePocketBase({ + ss_classifications: [ + { legacyId: 1, author: "user_a", classificationtype: "sunspot", anomaly: 1, createdAt: iso(minutes(5)) }, + { legacyId: 2, author: "user_b", classificationtype: "sunspot", anomaly: 2, createdAt: iso(minutes(30)) }, + { legacyId: 3, author: "user_a", classificationtype: "planet", anomaly: 3, createdAt: iso(minutes(60)) }, + { legacyId: 4, author: "user_c", classificationtype: "cloud", anomaly: 4, createdAt: iso(minutes(60 * 24 * 3)) }, + ], + defensive_probes: [ + { userId: "user_b", count: 4 }, + { userId: "user_a", count: 1 }, + { userId: "user_b", count: 2 }, + ], + profiles: [ + { userId: "user_a", username: "ada", fullName: "Ada", avatarUrl: null, classificationPoints: 30, updatedAt: "1" }, + { userId: "user_b", username: "bo", fullName: "Bo", avatarUrl: "b.png", classificationPoints: 50, updatedAt: "1" }, + ], + }); +} + +beforeEach(() => { + kv = createMemoryKV(); + resetSnapshotMemo(); + configurePlatform(() => ({ kv, sendJobs: null, waitUntil: null, localFallback: false })); +}); + +describe("refreshSnapshots", () => { + it("publishes every snapshot in one versioned KV write", async () => { + const put = vi.spyOn(kv, "put"); + const { pb } = seed(); + const report = await refreshSnapshots({ now: NOW, pb: async () => pb }); + + expect(report.results.every((r) => r.ok)).toBe(true); + expect(put).toHaveBeenCalledTimes(1); + expect(put.mock.calls[0][0]).toBe(SNAPSHOT_BUNDLE_KEY); + + resetSnapshotMemo(); + const stats = await readSnapshot("landing-stats", NOW); + expect(stats).toMatchObject({ status: "fresh", ageSeconds: 0 }); + expect(stats.data).toMatchObject({ + totalClassifications: 4, + classificationsLast24h: 3, + activeSailors24h: 2, + activeProjects7d: 3, + sampled: false, + }); + + const leaderboard = await readSnapshot("sunspot-leaderboard", NOW); + expect(leaderboard.data?.probeLeaders.map((e) => [e.username, e.count])).toEqual([["bo", 6], ["ada", 1]]); + expect(leaderboard.data?.classificationLeaders.map((e) => e.user_id)).toEqual(["user_a", "user_b"]); + + const top = await readSnapshot("hub-top-profiles", NOW); + expect(top.data).toEqual([ + { userId: "user_b", username: "bo", score: 50 }, + { userId: "user_a", username: "ada", score: 30 }, + ]); + }); + + it("keeps the last good data and records the error when a producer fails", async () => { + await refreshSnapshots({ now: NOW, pb: async () => seed().pb }); + const broken = fakePocketBase({}, { failOn: ["defensive_probes"] }); + const report = await refreshSnapshots({ now: NOW + minutes(40), pb: async () => broken.pb }); + + expect(report.results.find((r) => r.name === "sunspot-leaderboard")).toMatchObject({ ok: false, error: "defensive_probes unavailable" }); + resetSnapshotMemo(); + const read = await readSnapshot("sunspot-leaderboard", NOW + minutes(40)); + expect(read.status).toBe("stale"); + expect(read.generatedAt).toBe(new Date(NOW).toISOString()); + expect(read.data?.probeLeaders[0].username).toBe("bo"); + expect(read.lastError).toBe("defensive_probes unavailable"); + expect(read.lastAttemptAt).toBe(new Date(NOW + minutes(40)).toISOString()); + }); + + it("does not stop other snapshots when PocketBase auth fails", async () => { + const report = await refreshSnapshots({ + now: NOW, + pb: async () => { + throw new Error("auth failed"); + }, + }); + expect(report.results.every((r) => !r.ok && r.error === "auth failed")).toBe(true); + resetSnapshotMemo(); + const statuses = await readAllSnapshots(NOW); + expect(statuses.map((s) => [s.status, s.lastError])).toEqual(statuses.map(() => ["missing", "auth failed"])); + }); +}); + +describe("readSnapshot", () => { + it("reports missing before the first refresh", async () => { + expect(await readSnapshot("landing-stats", NOW)).toMatchObject({ status: "missing", data: null, generatedAt: null }); + }); + + it("turns stale after the section's max age but still serves the data", async () => { + await refreshSnapshots({ now: NOW, pb: async () => seed().pb }); + resetSnapshotMemo(); + expect((await readSnapshot("landing-stats", NOW + minutes(29))).status).toBe("fresh"); + resetSnapshotMemo(); + const stale = await readSnapshot("landing-stats", NOW + minutes(31)); + expect(stale.status).toBe("stale"); + expect(stale.data?.totalClassifications).toBe(4); + }); + + it("treats a section stored with an older schema as missing", async () => { + await kv.put( + SNAPSHOT_BUNDLE_KEY, + JSON.stringify({ + version: 1, + sections: { "landing-stats": { schema: 0, generatedAt: new Date(NOW).toISOString(), data: { old: true }, lastAttemptAt: "", lastError: null } }, + }), + ); + expect(await readSnapshot("landing-stats", NOW)).toMatchObject({ status: "missing", data: null }); + }); + + it("shares one KV read per isolate for 30 seconds", async () => { + const get = vi.spyOn(kv, "get"); + await readSnapshot("landing-stats", NOW); + await readSnapshot("sunspot-leaderboard", NOW + 1000); + expect(get).toHaveBeenCalledTimes(1); + }); + + it("builds the snapshots on demand without Cloudflare (next dev)", async () => { + configurePlatform(() => ({ kv, sendJobs: null, waitUntil: null, localFallback: true })); + const { pb, calls } = seed(); + vi.doMock("@/lib/pocketbase/adminClient", () => ({ createPocketbaseAdminClient: async () => pb })); + vi.resetModules(); + const fresh = await import("./store"); + const read = await fresh.readSnapshot("landing-stats"); + expect(read.status).toBe("fresh"); + expect(calls.length).toBeGreaterThan(0); + vi.doUnmock("@/lib/pocketbase/adminClient"); + }); +}); diff --git a/src/server/snapshots/store.ts b/src/server/snapshots/store.ts new file mode 100644 index 00000000..1df8c080 --- /dev/null +++ b/src/server/snapshots/store.ts @@ -0,0 +1,190 @@ +// Published public snapshots (SSC-37). +// +// The Worker's cron trigger computes every snapshot and writes them to Workers +// KV as ONE versioned bundle (`public-snapshots:v1`), so a refresh is a single +// KV write (Free plan: 1,000 writes/day). Requests only read the bundle: no +// PocketBase fan-out on the request path. +// +// Each section records when it was generated and when a refresh last failed, +// so readers can tell fresh, stale and missing data apart: +// fresh generated within the section's maxAgeSeconds +// stale older than that (the cron has been failing or not running); +// still served, flagged via `status` and `x-snapshot-status` +// missing never generated, or stored with an older section schema +import type PocketBase from "pocketbase"; + +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { platform } from "@/src/server/platform"; +import { + computeCommunityActivity, + computeHubTopProfiles, + computeLandingStats, + computeSunspotLeaderboard, + type CommunityActivityItem, + type HubTopProfile, + type LandingStats, + type SunspotLeaderboard, +} from "./compute"; + +export const SNAPSHOT_BUNDLE_KEY = "public-snapshots:v1"; + +type Definition = { + /** Bump when the shape of `data` changes; older stored sections read as missing. */ + schema: number; + maxAgeSeconds: number; + /** Served by /api/public/snapshots/[name]. Internal ones hold user ids. */ + public: boolean; + compute: (pb: PocketBase, now: number) => Promise; +}; + +export const snapshotDefinitions = { + "landing-stats": { schema: 1, maxAgeSeconds: 30 * 60, public: true, compute: computeLandingStats } as Definition, + "sunspot-leaderboard": { schema: 1, maxAgeSeconds: 30 * 60, public: true, compute: (pb) => computeSunspotLeaderboard(pb) } as Definition, + "community-activity": { schema: 1, maxAgeSeconds: 30 * 60, public: false, compute: computeCommunityActivity } as Definition, + "hub-top-profiles": { schema: 1, maxAgeSeconds: 30 * 60, public: false, compute: (pb) => computeHubTopProfiles(pb) } as Definition, +}; + +export type SnapshotName = keyof typeof snapshotDefinitions; +export type SnapshotData = Awaited>; + +/** The snapshot a cron tick refreshes: one per tick, in rotation (see SNAPSHOT_CRON). */ +export function snapshotForTick(scheduledTime: number, tickMs: number): SnapshotName { + const names = Object.keys(snapshotDefinitions) as SnapshotName[]; + return names[Math.floor(scheduledTime / tickMs) % names.length]; +} + +export const isSnapshotName = (name: string): name is SnapshotName => Object.hasOwn(snapshotDefinitions, name); + +export type StoredSection = { + schema: number; + generatedAt: string | null; + data: unknown; + lastAttemptAt: string; + lastError: string | null; +}; + +export type SnapshotBundle = { version: 1; sections: Partial> }; + +export type SnapshotRead = { + name: SnapshotName; + status: "fresh" | "stale" | "missing"; + data: T | null; + generatedAt: string | null; + ageSeconds: number | null; + lastAttemptAt: string | null; + lastError: string | null; +}; + +// Every read in an isolate within this window shares one KV read. +const MEMO_MS = 30_000; +let memo: { at: number; bundle: SnapshotBundle | null } | null = null; + +export function resetSnapshotMemo() { + memo = null; +} + +async function loadBundle(now: number): Promise { + if (memo && now - memo.at < MEMO_MS) return memo.bundle; + const raw = (await platform().kv.get(SNAPSHOT_BUNDLE_KEY, "json")) as SnapshotBundle | null; + const bundle = raw && raw.version === 1 && typeof raw.sections === "object" ? raw : null; + memo = { at: now, bundle }; + return bundle; +} + +export function describeSection(name: N, section: StoredSection | undefined, now: number): SnapshotRead> { + const definition = snapshotDefinitions[name]; + const usable = section && section.schema === definition.schema && section.generatedAt ? section : null; + const ageSeconds = usable ? Math.max(0, Math.round((now - Date.parse(usable.generatedAt!)) / 1000)) : null; + return { + name, + status: !usable ? "missing" : ageSeconds! > definition.maxAgeSeconds ? "stale" : "fresh", + data: usable ? (usable.data as SnapshotData) : null, + generatedAt: usable?.generatedAt ?? null, + ageSeconds, + lastAttemptAt: section?.lastAttemptAt ?? null, + lastError: section?.lastError ?? null, + }; +} + +let localRefresh: Promise | null = null; + +export async function readSnapshot(name: N, now = Date.now()): Promise>> { + let bundle = await loadBundle(now); + // next dev / Cypress: no cron runs, so build the in-memory bundle on first use. + if (!bundle?.sections[name] && platform().localFallback) { + localRefresh ??= refreshSnapshots({ now }).finally(() => (localRefresh = null)); + await localRefresh; + bundle = await loadBundle(now); + } + return describeSection(name, bundle?.sections[name], now); +} + +export async function readAllSnapshots(now = Date.now()) { + const bundle = await loadBundle(now); + return (Object.keys(snapshotDefinitions) as SnapshotName[]).map((name) => describeSection(name, bundle?.sections[name], now)); +} + +export type RefreshReport = { + at: string; + results: Array<{ name: SnapshotName; ok: boolean; error: string | null; ms: number }>; +}; + +/** + * Recompute the snapshots (all, or just `only`) and publish the bundle with one KV write. A failed + * section keeps its previous data and generatedAt (it turns stale after + * maxAgeSeconds) and records the error, so a PocketBase outage degrades to + * old numbers instead of empty ones. + */ +export async function refreshSnapshots( + options: { now?: number; pb?: () => Promise; only?: readonly SnapshotName[] } = {}, +): Promise { + const now = options.now ?? Date.now(); + const at = new Date(now).toISOString(); + const kv = platform().kv; + const previous = ((await kv.get(SNAPSHOT_BUNDLE_KEY, "json")) as SnapshotBundle | null) ?? null; + const sections: SnapshotBundle["sections"] = previous?.version === 1 ? { ...previous.sections } : {}; + + let client: Promise | null = null; + const pb = () => (client ??= (options.pb ?? createPocketbaseAdminClient)()); + + const names = options.only?.length ? [...options.only] : (Object.keys(snapshotDefinitions) as SnapshotName[]); + const results = await Promise.all( + names.map(async (name) => { + const definition = snapshotDefinitions[name] as Definition; + const started = Date.now(); + try { + const data = await definition.compute(await pb(), now); + sections[name] = { schema: definition.schema, generatedAt: at, data, lastAttemptAt: at, lastError: null }; + return { name, ok: true, error: null, ms: Date.now() - started }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const kept = sections[name]?.schema === definition.schema ? sections[name]! : null; + sections[name] = { + schema: definition.schema, + generatedAt: kept?.generatedAt ?? null, + data: kept?.data ?? null, + lastAttemptAt: at, + lastError: message.slice(0, 500), + }; + console.error(`[snapshots] ${name} refresh failed: ${message}`); + return { name, ok: false, error: message, ms: Date.now() - started }; + } + }), + ); + + const bundle: SnapshotBundle = { version: 1, sections }; + await kv.put(SNAPSHOT_BUNDLE_KEY, JSON.stringify(bundle)); + memo = { at: now, bundle }; + return { at, results }; +} + +/** Response headers that make snapshot freshness visible to clients and logs. */ +export function snapshotHeaders(read: SnapshotRead): Record { + const headers: Record = { + "x-snapshot-status": read.status, + // Shared data: browsers and the edge may reuse it briefly. + "cache-control": read.status === "missing" ? "no-store" : "public, max-age=60", + }; + if (read.generatedAt) headers["x-snapshot-generated-at"] = read.generatedAt; + return headers; +} diff --git a/src/server/testing/fakePocketBase.ts b/src/server/testing/fakePocketBase.ts new file mode 100644 index 00000000..6e97f55d --- /dev/null +++ b/src/server/testing/fakePocketBase.ts @@ -0,0 +1,72 @@ +// Minimal PocketBase stand-in for background-job and snapshot tests. Supports +// the filters those modules build: `field = {:x}` / `field >= {:x}` / `!= null` +// clauses joined with && and ||, and `anomaly = 5` literals. +import type PocketBase from "pocketbase"; + +type Row = Record; + +function matches(row: Row, filter: string | undefined): boolean { + if (!filter) return true; + return filter.split("||").some((alt) => + alt.split("&&").every((raw) => { + const clause = raw.replace(/[()]/g, "").trim(); + const m = clause.match(/^(\w+)\s*(>=|!=|=|>)\s*(.+)$/); + if (!m) return true; + const [, field, op, rawValue] = m; + const value = rawValue === "null" ? null : rawValue.replace(/^"|"$/g, ""); + const actual = row[field]; + if (op === "!=") return value === null ? actual != null && actual !== "" : String(actual) !== value; + if (op === "=") return String(actual) === value; + if (op === ">=") return String(actual) >= String(value); + return Number(actual) > Number(value); + }), + ); +} + +export function fakePocketBase(data: Record, options: { failOn?: string[] } = {}) { + const calls: string[] = []; + const deleted: Array<[string, string]> = []; + const pb = { + filter(expr: string, params: Record) { + return expr.replace(/\{:(\w+)\}/g, (_, key) => JSON.stringify(params[key])); + }, + collection(name: string) { + const rows = () => { + calls.push(name); + if (options.failOn?.includes(name)) throw new Error(`${name} unavailable`); + return data[name] ?? []; + }; + const sorted = (list: Row[], sort?: string) => { + if (!sort) return list; + const [key, dir] = sort.split(",")[0].startsWith("-") ? [sort.split(",")[0].slice(1), -1] : [sort.split(",")[0].replace(/^\+/, ""), 1]; + return [...list].sort((a, b) => (a[key] > b[key] ? dir : a[key] < b[key] ? -dir : 0)); + }; + return { + async getList(page: number, perPage: number, opts: { filter?: string; sort?: string } = {}) { + const all = sorted(rows().filter((r) => matches(r, opts.filter)), opts.sort); + return { + page, + perPage, + totalItems: all.length, + totalPages: Math.max(1, Math.ceil(all.length / perPage)), + items: all.slice((page - 1) * perPage, page * perPage), + }; + }, + async getFullList(opts: { filter?: string; sort?: string } = {}) { + return sorted(rows().filter((r) => matches(r, opts.filter)), opts.sort); + }, + async getFirstListItem(filter: string) { + const found = rows().find((r) => matches(r, filter)); + if (!found) throw new Error("not found"); + return found; + }, + async delete(id: string) { + deleted.push([name, id]); + data[name] = (data[name] ?? []).filter((r) => r.id !== id); + return true; + }, + }; + }, + }; + return { pb: pb as unknown as PocketBase, calls, deleted }; +} diff --git a/workers/api/src/index.ts b/workers/api/src/index.ts index 269f2bbb..029b3016 100644 --- a/workers/api/src/index.ts +++ b/workers/api/src/index.ts @@ -1,3 +1,6 @@ +// SSC-35 JSON API (/api/v1/*). Since SSC-31 it runs inside the app Worker +// (workers/app/src/index.ts) on the site's own origin, which supplies +// CLERK_ISSUER and CLERK_AUTHORIZED_PARTIES; it is no longer deployed alone. import { AuthError, verifyClerkJwt, type ClerkClaims } from "./jwt"; import { handleAnomalies } from "./anomalies"; import { createClassification, listClassifications } from "./classifications"; diff --git a/workers/api/wrangler.jsonc b/workers/api/wrangler.jsonc deleted file mode 100644 index 7350d5ee..00000000 --- a/workers/api/wrangler.jsonc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "../../node_modules/wrangler/config-schema.json", - "name": "starsailors-api", - "main": "src/index.ts", - "compatibility_date": "2026-07-24", - // No nodejs_compat and no limits.cpu_ms: this Worker must fit the Workers - // Free plan (10ms CPU, 50 subrequests). See the SSC-30 note in ../../wrangler.jsonc. - // CLERK_ISSUER and CLERK_AUTHORIZED_PARTIES come from GitHub Actions variables - // (deploy-api-worker.yml passes them with --var); use .dev.vars locally. - // Secrets (wrangler secret put): POCKETBASE_URL, POCKETBASE_ADMIN_EMAIL, POCKETBASE_ADMIN_PASSWORD. - // Same-origin route: browsers call /api/v1/* on starsailors.space, so there - // are no CORS preflights (each would be an extra billable request). - "routes": [ - { "pattern": "starsailors.space/api/v1/*", "zone_name": "starsailors.space" }, - { "pattern": "www.starsailors.space/api/v1/*", "zone_name": "starsailors.space" } - ], - "observability": { "enabled": true }, - "env": { - "staging": { - "name": "starsailors-api-staging", - // Direct *.workers.dev URL so staging can be smoke-tested without DNS. - "workers_dev": true, - "routes": [{ "pattern": "staging.starsailors.space/api/v1/*", "zone_name": "starsailors.space" }] - } - } -} diff --git a/workers/app/src/app.test.ts b/workers/app/src/app.test.ts new file mode 100644 index 00000000..4ae55e64 --- /dev/null +++ b/workers/app/src/app.test.ts @@ -0,0 +1,387 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// Stand-in for the platform fetch, installed before the Worker module wraps it. +const upstream = vi.hoisted(() => { + const fn = vi.fn(async () => new Response("ok")); + globalThis.fetch = fn as unknown as typeof fetch; + return fn; +}); + +// The Worker bundle swaps these modules via wrangler.jsonc `alias`; mirror that here. +vi.mock("@clerk/nextjs/server", () => import("./shims/clerk-nextjs-server")); +vi.mock("next/cache", () => import("./shims/next-cache")); +// Cron producers read PocketBase; hand them an in-memory stand-in. +const pocketbase = vi.hoisted(() => ({ data: {} as Record>> })); +vi.mock("@/lib/pocketbase/adminClient", async () => { + const { fakePocketBase } = await import("@/src/server/testing/fakePocketBase"); + return { createPocketbaseAdminClient: async () => fakePocketBase(pocketbase.data).pb }; +}); + +import { resetJwksCache } from "../../api/src/jwt"; +import { requestContext } from "./context"; +import { createMemoryKV, type KVLike } from "@/src/server/platform"; +import { resetSnapshotMemo, SNAPSHOT_BUNDLE_KEY } from "@/src/server/snapshots/store"; +import { DISCOVERY_REMINDER_CRON, SNAPSHOT_CRON, SNAPSHOT_TICK_MS } from "./background"; +import worker, { handle, issuerFromPublishableKey, resolveAuth, type Env } from "./index"; +import { auth } from "./shims/clerk-nextjs-server"; + +const ISS = "https://clerk.example.test"; +const ORIGIN = "https://starsailors.space"; +const NOW = 1_800_000_000; + +const b64u = (data: ArrayBuffer | string) => { + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : new Uint8Array(data); + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +let priv: CryptoKey; +let jwk: JsonWebKey; + +async function sign(claims: Record) { + const head = b64u(JSON.stringify({ alg: "RS256", kid: "k1", typ: "JWT" })); + const body = b64u(JSON.stringify(claims)); + const sig = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", priv, new TextEncoder().encode(`${head}.${body}`)); + return `${head}.${body}.${b64u(sig)}`; +} + +const claims = (sub: string) => ({ sub, sid: `sess_${sub}`, iss: ISS, exp: NOW + 60, nbf: NOW - 10, azp: ORIGIN }); + +let assetRequests: string[]; +let fetchMock: ReturnType; +let kv: KVLike; +let queued: Array<{ body: any; delaySeconds?: number }>; + +const env: Env = { + ASSETS: { + fetch: async (request: Request) => { + const { pathname } = new URL(request.url); + assetRequests.push(pathname); + return new Response(`asset:${pathname}`, { headers: { "content-type": "text/html" } }); + }, + }, + CLERK_ISSUER: ISS, + CLERK_AUTHORIZED_PARTIES: ORIGIN, + POCKETBASE_URL: "https://pb.example.test", + POCKETBASE_ADMIN_EMAIL: "a@b.c", + POCKETBASE_ADMIN_PASSWORD: "secret", + get PUBLIC_DATA() { + return kv; + }, + JOBS: { + sendBatch: async (messages) => { + queued.push(...messages); + }, + }, +}; + +const call = (path: string, init: RequestInit = {}) => + handle(new Request(`${ORIGIN}${path}`, init), env, { fetchImpl: fetchMock as unknown as typeof fetch, now: NOW }); + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, + true, + ["sign", "verify"], + ); + priv = pair.privateKey; + jwk = { ...(await crypto.subtle.exportKey("jwk", pair.publicKey)), kid: "k1" } as JsonWebKey; +}); + +beforeEach(() => { + resetJwksCache(); + resetSnapshotMemo(); + kv = createMemoryKV(); + queued = []; + pocketbase.data = {}; + vi.unstubAllEnvs(); + assetRequests = []; + fetchMock = vi.fn(async () => new Response(JSON.stringify({ keys: [jwk] }))); +}); + +describe("issuerFromPublishableKey", () => { + it("decodes the Clerk frontend API host", () => { + expect(issuerFromPublishableKey(`pk_live_${btoa("clerk.starsailors.space$")}`)).toBe("https://clerk.starsailors.space"); + expect(issuerFromPublishableKey("pk_test_!!!")).toBeNull(); + expect(issuerFromPublishableKey(undefined)).toBeNull(); + }); +}); + +describe("API route handlers", () => { + it("rejects a request without a session", async () => { + const res = await call("/api/auth/session"); + expect(res.status).toBe(401); + expect(res.headers.get("cache-control")).toBe("private, no-store"); + }); + + it("accepts a Clerk bearer token", async () => { + const res = await call("/api/auth/session", { headers: { authorization: `Bearer ${await sign(claims("user_1"))}` } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ authenticated: true }); + }); + + it("accepts the __session cookie clerk-js keeps", async () => { + const res = await call("/api/auth/session", { headers: { cookie: `a=1; __session=${await sign(claims("user_1"))}` } }); + expect(res.status).toBe(200); + }); + + it("verifies JWKS once per isolate, not per request", async () => { + const token = await sign(claims("user_1")); + await call("/api/auth/session", { headers: { authorization: `Bearer ${token}` } }); + await call("/api/auth/session", { headers: { authorization: `Bearer ${token}` } }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("treats forged and expired tokens as signed out", async () => { + const forged = (await sign(claims("user_1"))).replace(/\.[^.]+$/, ".AAAA"); + expect((await call("/api/auth/session", { headers: { authorization: `Bearer ${forged}` } })).status).toBe(401); + const expired = await sign({ ...claims("user_1"), exp: NOW - 600 }); + expect((await call("/api/auth/session", { headers: { authorization: `Bearer ${expired}` } })).status).toBe(401); + }); + + it("matches dynamic segments and reports unknown routes and methods", async () => { + expect((await call("/api/actions/notAnAction", { method: "POST", body: "{}" })).status).toBe(404); + expect((await call("/api/nope")).status).toBe(404); + const res = await call("/api/auth/session", { method: "DELETE" }); + expect(res.status).toBe(405); + expect(res.headers.get("allow")).toBe("GET"); + }); + + it("serves the SSC-35 JSON API under /api/v1", async () => { + expect((await call("/api/v1/me")).status).toBe(401); + }); +}); + +describe("resolveAuth", () => { + const url = new URL(`${ORIGIN}/api/actions/x`); + + it("ignores a cookie session on a cross-site mutation", async () => { + const cookie = `__session=${await sign(claims("user_1"))}`; + const noOrigin = await resolveAuth(new Request(url, { method: "POST", headers: { cookie } }), env, url, { now: NOW, fetchImpl: fetchMock as any }); + expect(noOrigin).toMatchObject({ userId: null, authError: "cross_origin" }); + const foreign = await resolveAuth(new Request(url, { method: "POST", headers: { cookie, origin: "https://evil.test" } }), env, url, { now: NOW, fetchImpl: fetchMock as any }); + expect(foreign.userId).toBeNull(); + const own = await resolveAuth(new Request(url, { method: "POST", headers: { cookie, origin: ORIGIN } }), env, url, { now: NOW, fetchImpl: fetchMock as any }); + expect(own.userId).toBe("user_1"); + }); + + it("accepts a bearer mutation without an Origin header", async () => { + const authorization = `Bearer ${await sign(claims("user_1"))}`; + const ctx = await resolveAuth(new Request(url, { method: "POST", headers: { authorization } }), env, url, { now: NOW, fetchImpl: fetchMock as any }); + expect(ctx.userId).toBe("user_1"); + }); + + it("rejects a token minted for another origin", async () => { + const authorization = `Bearer ${await sign({ ...claims("user_1"), azp: "https://evil.test" })}`; + const ctx = await resolveAuth(new Request(url, { headers: { authorization } }), env, url, { now: NOW, fetchImpl: fetchMock as any }); + expect(ctx).toMatchObject({ userId: null, authError: "bad_party" }); + }); +}); + +describe("auth() shim", () => { + it("keeps concurrent requests' identities apart", async () => { + const as = (userId: string, delay: number) => + requestContext.run({ userId, sessionId: null, claims: null, authError: null }, async () => { + await new Promise((r) => setTimeout(r, delay)); + return (await auth()).userId; + }); + expect(await Promise.all([as("user_a", 20), as("user_b", 0), as("user_c", 10)])).toEqual(["user_a", "user_b", "user_c"]); + }); + + it("throws outside a request, which routeAuth treats as signed out", async () => { + await expect(auth()).rejects.toThrow("Auth context unavailable"); + }); +}); + +describe("pages", () => { + it("serves a dynamic page from its exported placeholder", async () => { + const res = await call("/posts/123"); + expect(res.status).toBe(200); + expect(assetRequests).toEqual(["/posts/__static__"]); + }); + + it("serves the placeholder RSC payload for client navigations", async () => { + await call("/structures/balloon/clouds/an-5/one.txt?_rsc=1"); + expect(assetRequests).toEqual(["/structures/balloon/__static__/__static__/__static__.txt"]); + }); + + it("prefers literal segments over params", async () => { + await call("/planets/edit/7"); + expect(assetRequests).toEqual(["/planets/edit/__static__"]); + }); + + it("returns the 404 page for anything else", async () => { + const res = await call("/definitely/not/here"); + expect(res.status).toBe(404); + expect(assetRequests).toEqual(["/404"]); + }); +}); + +describe("PostHog proxy", () => { + it("forwards /ingest without the session cookie", async () => { + fetchMock = vi.fn(async () => new Response("ok")); + await call("/ingest/e/?ip=1", { method: "POST", body: "{}", headers: { cookie: "__session=secret", "content-type": "application/json" } }); + const [target, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(target).toBe("https://us.i.posthog.com/e/?ip=1"); + expect(new Headers(init.headers).get("cookie")).toBeNull(); + }); + + it("sends static assets to the assets host", async () => { + fetchMock = vi.fn(async () => new Response("ok")); + await call("/ingest/static/array.js"); + expect(fetchMock.mock.calls[0][0]).toBe("https://us-assets.i.posthog.com/static/array.js"); + }); +}); + +describe("budget instrumentation", () => { + it("reports outbound fetches per request", async () => { + upstream.mockClear(); + const proxied = await handle(new Request(`${ORIGIN}/ingest/decide`), env); + expect(upstream).toHaveBeenCalledTimes(1); + expect(proxied.headers.get("x-ssc-subrequests")).toBe("1"); + + const page = await call("/posts/1"); + expect(page.headers.get("x-ssc-subrequests")).toBe("0"); + }); +}); + +describe("public snapshots (SSC-37)", () => { + const publish = (generatedAt: string) => + kv.put( + SNAPSHOT_BUNDLE_KEY, + JSON.stringify({ + version: 1, + sections: { + "landing-stats": { schema: 1, generatedAt, data: { totalClassifications: 42 }, lastAttemptAt: generatedAt, lastError: null }, + "community-activity": { + schema: 1, + generatedAt, + data: [ + { id: 1, author: "user_aaa", authorId: "user_aaaaaaaa", type: "sunspot", at: generatedAt }, + { id: 2, author: "user_bbb", authorId: "user_bbbbbbbb", type: "cloud", at: generatedAt }, + ], + lastAttemptAt: generatedAt, + lastError: "PocketBase 502", + }, + }, + }), + ); + + it("serves a published snapshot from KV without touching PocketBase", async () => { + await publish(new Date().toISOString()); + upstream.mockClear(); + const res = await handle(new Request(`${ORIGIN}/api/public/snapshots/landing-stats`), env); + expect(res.status).toBe(200); + expect(res.headers.get("x-snapshot-status")).toBe("fresh"); + expect(res.headers.get("cache-control")).toBe("public, max-age=60"); + expect(res.headers.get("x-ssc-subrequests")).toBe("0"); + expect(upstream).not.toHaveBeenCalled(); + expect(await res.json()).toMatchObject({ status: "fresh", data: { totalClassifications: 42 } }); + }); + + it("flags stale data and reports missing snapshots as 503", async () => { + await publish(new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()); + const stale = await call("/api/public/snapshots/landing-stats"); + expect(stale.status).toBe(200); + expect(stale.headers.get("x-snapshot-status")).toBe("stale"); + + const missing = await call("/api/public/snapshots/sunspot-leaderboard"); + expect(missing.status).toBe(503); + expect(missing.headers.get("x-snapshot-status")).toBe("missing"); + expect(missing.headers.get("cache-control")).toBe("no-store"); + + expect((await call("/api/public/snapshots/hub-top-profiles")).status).toBe(404); + }); + + it("strips user ids from community activity and honours ?exclude", async () => { + await publish(new Date().toISOString()); + const rows = await (await call("/api/community-activity?exclude=user_aaaaaaaa")).json(); + expect(rows).toEqual([{ id: 2, author: "user_bbb", type: "cloud", at: expect.any(String) }]); + }); + + it("exposes freshness and the last refresh error on /api/public/status", async () => { + await publish(new Date().toISOString()); + const body = await (await call("/api/public/status")).json(); + expect(body.healthy).toBe(false); + expect(body.snapshots).toContainEqual(expect.objectContaining({ name: "community-activity", status: "fresh", lastError: "PocketBase 502" })); + expect(body.snapshots).toContainEqual(expect.objectContaining({ name: "sunspot-leaderboard", status: "missing" })); + }); + + it("publishes one snapshot per cron tick, rotating through all of them", async () => { + pocketbase.data = { + ss_classifications: [{ legacyId: 1, author: "user_a", classificationtype: "sunspot", createdAt: new Date().toISOString() }], + profiles: [{ userId: "user_a", username: "ada", classificationPoints: 3 }], + }; + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const first = Math.ceil(Date.now() / SNAPSHOT_TICK_MS) * SNAPSHOT_TICK_MS; + await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: first }, env, { waitUntil: () => {} }); + let bundle = (await kv.get(SNAPSHOT_BUNDLE_KEY, "json")) as any; + expect(Object.keys(bundle.sections)).toHaveLength(1); + expect(JSON.parse(log.mock.calls[0][0])).toMatchObject({ invocation: `cron ${SNAPSHOT_CRON}`, task: "snapshots" }); + for (let tick = 1; tick < 4; tick++) { + await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: first + tick * SNAPSHOT_TICK_MS }, env, { waitUntil: () => {} }); + } + bundle = (await kv.get(SNAPSHOT_BUNDLE_KEY, "json")) as any; + expect(Object.keys(bundle.sections).sort()).toEqual(["community-activity", "hub-top-profiles", "landing-stats", "sunspot-leaderboard"]); + expect(bundle.sections["sunspot-leaderboard"].data.classificationLeaders[0]).toMatchObject({ username: "ada", count: 1 }); + }); +}); + +describe("background jobs (SSC-39)", () => { + it("starts the daily reminder fan-out from its cron", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + await worker.scheduled({ cron: DISCOVERY_REMINDER_CRON, scheduledTime: Date.parse("2026-09-25T17:00:00Z") }, env, { waitUntil: () => {} }); + expect(queued.map((m) => m.body.id)).toEqual(["reminders:2026-09-25:p1"]); + }); + + it("queues the signed-in user's notification and answers 202 at once", async () => { + const res = await call("/api/notify-my-discoveries", { + method: "POST", + headers: { authorization: `Bearer ${await sign(claims("user_1"))}`, "content-type": "application/json" }, + // A body userId is ignored: the push goes to the session user only. + body: JSON.stringify({ userId: "someone_else", customMessage: { title: "Deployed", body: "3 targets", url: "/structures/telescope" } }), + }); + expect(res.status).toBe(202); + expect(queued).toHaveLength(1); + expect(queued[0].body).toMatchObject({ type: "push.user", userId: "user_1", notification: { title: "Deployed" } }); + expect((await call("/api/notify-my-discoveries", { method: "POST", body: "{}" })).status).toBe(401); + }); + + it("guards operator endpoints with INTERNAL_JOBS_TOKEN", async () => { + expect((await call("/api/send-test-notification", { method: "POST" })).status).toBe(503); + vi.stubEnv("INTERNAL_JOBS_TOKEN", "s3cret"); + expect((await call("/api/send-test-notification", { method: "POST", headers: { authorization: "Bearer nope" } })).status).toBe(401); + const res = await call("/api/internal/jobs", { headers: { authorization: "Bearer s3cret" } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ count: 0, parked: [] }); + expect((await call("/api/auto-notify-discoveries", { method: "POST", headers: { authorization: "Bearer s3cret" } })).status).toBe(202); + expect(queued[0].body.type).toBe("reminders.discoveries"); + }); + + it("consumes a queue batch and parks what it cannot process", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const acked: string[] = []; + await worker.queue( + { queue: "starsailors-jobs", messages: [{ id: "m1", body: { junk: true }, attempts: 1, ack: () => acked.push("m1"), retry: () => {} }] }, + env, + { waitUntil: () => {} }, + ); + expect(acked).toEqual(["m1"]); + expect((await kv.list({ prefix: "jobs:dead:" })).keys.map((k) => k.name)).toEqual(["jobs:dead:invalid:m1"]); + }); + + it("keeps wrangler.jsonc crons and queues in step with the code", () => { + const config = readFileSync("wrangler.jsonc", "utf8"); + expect(config).toContain(`"crons": ["${SNAPSHOT_CRON}", "${DISCOVERY_REMINDER_CRON}"]`); + expect(config).toContain(`"crons": ["${SNAPSHOT_CRON}"]`); + expect(config).toMatch(/"max_retries": 5, "dead_letter_queue": "starsailors-jobs-dlq"/); + }); +}); + +describe("generated route tables", () => { + it("match src/app", () => { + expect(() => execFileSync("node", ["scripts/cloudflare/generate-routes.mjs", "--check"], { stdio: "pipe" })).not.toThrow(); + }); +}); diff --git a/workers/app/src/background.ts b/workers/app/src/background.ts new file mode 100644 index 00000000..16ae48f3 --- /dev/null +++ b/workers/app/src/background.ts @@ -0,0 +1,40 @@ +// Cron and queue entry points of the app Worker (SSC-37, SSC-39). +// +// SNAPSHOT_CRON recompute the public snapshots into Workers KV +// DISCOVERY_REMINDER_CRON start the daily reminder fan-out (production only) +// queue JOBS / JOBS_DLQ run background jobs / park dead-lettered ones +// +// Both run with the same 10 ms CPU cap as requests on Workers Free, so each +// step is bounded: snapshot producers read a few PocketBase pages, and a queue +// batch is at most `max_batch_size` (4) jobs of a few subrequests each. +import { consumeJobBatch, type QueueMessageLike } from "@/src/server/jobs/consumer"; +import { enqueueJobs } from "@/src/server/jobs/queue"; +import { refreshSnapshots, snapshotForTick } from "@/src/server/snapshots/store"; + +// One snapshot per tick: refreshing all four in a single invocation measured +// 16-17 ms CPU on staging, over the 10 ms Free cap. 4 snapshots x 5 min = each +// refreshed every 20 min, inside the 30 min freshness window. +export const SNAPSHOT_CRON = "*/5 * * * *"; +export const SNAPSHOT_TICK_MS = 5 * 60 * 1000; +export const DISCOVERY_REMINDER_CRON = "0 17 * * *"; + +export async function runScheduled(cron: string, scheduledTime: number): Promise> { + if (cron === DISCOVERY_REMINDER_CRON) { + const day = new Date(scheduledTime).toISOString().slice(0, 10); + const queued = await enqueueJobs({ type: "reminders.discoveries", id: `reminders:${day}:p1`, day, page: 1 }); + return { task: "discovery-reminders", day, ...queued }; + } + // SNAPSHOT_CRON, and any cron added later without a handler of its own. + const only = [snapshotForTick(scheduledTime, SNAPSHOT_TICK_MS)]; + const report = await refreshSnapshots({ now: scheduledTime, only }); + return { task: "snapshots", ...report }; +} + +export type QueueBatchLike = { queue: string; messages: readonly QueueMessageLike[] }; + +export async function runQueueBatch(batch: QueueBatchLike) { + const outcomes = await consumeJobBatch(batch.messages, { deadLetterQueue: batch.queue.endsWith("-dlq") }); + const counts: Record = {}; + for (const { outcome } of outcomes) counts[outcome] = (counts[outcome] ?? 0) + 1; + return { queue: batch.queue, messages: batch.messages.length, ...counts }; +} diff --git a/workers/app/src/context.ts b/workers/app/src/context.ts new file mode 100644 index 00000000..3d8a1342 --- /dev/null +++ b/workers/app/src/context.ts @@ -0,0 +1,20 @@ +// Per-request state for code written against Next.js request APIs (auth(), +// currentUser()). AsyncLocalStorage keeps concurrent requests in one isolate +// apart; a module-level variable would leak identity between them. +import { AsyncLocalStorage } from "node:async_hooks"; + +export type RequestContext = { + userId: string | null; + sessionId: string | null; + claims: Record | null; + /** Why userId is null, for logs only. */ + authError: string | null; +}; + +export const requestContext = new AsyncLocalStorage(); + +export function currentRequestContext(): RequestContext { + const store = requestContext.getStore(); + if (!store) throw new Error("Auth context unavailable"); + return store; +} diff --git a/workers/app/src/generated/api-routes.ts b/workers/app/src/generated/api-routes.ts new file mode 100644 index 00000000..dc65f929 --- /dev/null +++ b/workers/app/src/generated/api-routes.ts @@ -0,0 +1,148 @@ +// Generated by scripts/cloudflare/generate-routes.mjs. Do not edit. +import type { RouteModule } from "../routeTypes"; +import * as r0 from "../../../../src/app/api/gameplay/deploy/rover/return/route"; +import * as r1 from "../../../../src/app/api/gameplay/deploy/rover/setup/route"; +import * as r2 from "../../../../src/app/api/gameplay/deploy/satellite/quick/route"; +import * as r3 from "../../../../src/app/api/gameplay/planet/comments/preferred/route"; +import * as r4 from "../../../../src/app/api/gameplay/classifications/configuration/route"; +import * as r5 from "../../../../src/app/api/gameplay/classifications/count/route"; +import * as r6 from "../../../../src/app/api/gameplay/classifications/exists/route"; +import * as r7 from "../../../../src/app/api/gameplay/classifications/options-counter/route"; +import * as r8 from "../../../../src/app/api/gameplay/deploy/awaiting/route"; +import * as r9 from "../../../../src/app/api/gameplay/deploy/rover/route"; +import * as r10 from "../../../../src/app/api/gameplay/deploy/satellite/route"; +import * as r11 from "../../../../src/app/api/gameplay/deploy/solar/route"; +import * as r12 from "../../../../src/app/api/gameplay/deploy/status/route"; +import * as r13 from "../../../../src/app/api/gameplay/hub/bootstrap/route"; +import * as r14 from "../../../../src/app/api/gameplay/hub/state/route"; +import * as r15 from "../../../../src/app/api/gameplay/inventory/lookup/route"; +import * as r16 from "../../../../src/app/api/gameplay/inventory/mine/route"; +import * as r17 from "../../../../src/app/api/gameplay/inventory/use/route"; +import * as r18 from "../../../../src/app/api/gameplay/leaderboards/sunspots/route"; +import * as r19 from "../../../../src/app/api/gameplay/milestones/weekly-progress/route"; +import * as r20 from "../../../../src/app/api/gameplay/missions/exists/route"; +import * as r21 from "../../../../src/app/api/gameplay/notifications/reject/route"; +import * as r22 from "../../../../src/app/api/gameplay/notifications/subscribe/route"; +import * as r23 from "../../../../src/app/api/gameplay/profile/classification-points/route"; +import * as r24 from "../../../../src/app/api/gameplay/profile/ensure/route"; +import * as r25 from "../../../../src/app/api/gameplay/profile/me/route"; +import * as r26 from "../../../../src/app/api/gameplay/profile/referral-status/route"; +import * as r27 from "../../../../src/app/api/gameplay/research/summary/route"; +import * as r28 from "../../../../src/app/api/gameplay/research/unlock/route"; +import * as r29 from "../../../../src/app/api/gameplay/routes/latest/route"; +import * as r30 from "../../../../src/app/api/gameplay/social/comments/route"; +import * as r31 from "../../../../src/app/api/gameplay/social/my/route"; +import * as r32 from "../../../../src/app/api/gameplay/social/votes/route"; +import * as r33 from "../../../../src/app/api/gameplay/storage/upload/route"; +import * as r34 from "../../../../src/app/api/gameplay/surveyor/comments/route"; +import * as r35 from "../../../../src/app/api/gameplay/telescope/viewport/route"; +import * as r36 from "../../../../src/app/api/gameplay/uploads/mine/route"; +import * as r37 from "../../../../src/app/api/gameplay/zoodex/entries/route"; +import * as r38 from "../../../../src/app/api/internal/snapshots/refresh/route"; +import * as r39 from "../../../../src/app/api/test/auth/login/route"; +import * as r40 from "../../../../src/app/api/zoodex/upload-image/gpt/route"; +import * as r41 from "../../../../src/app/api/auth/complete-guest-conversion/route"; +import * as r42 from "../../../../src/app/api/auth/guest/route"; +import * as r43 from "../../../../src/app/api/auth/session/route"; +import * as r44 from "../../../../src/app/api/gameplay/achievements/route"; +import * as r45 from "../../../../src/app/api/gameplay/active-planet/route"; +import * as r46 from "../../../../src/app/api/gameplay/anomalies/route"; +import * as r47 from "../../../../src/app/api/gameplay/classifications/[id]/route"; +import * as r48 from "../../../../src/app/api/gameplay/classifications/route"; +import * as r49 from "../../../../src/app/api/gameplay/extraction/[id]/route"; +import * as r50 from "../../../../src/app/api/gameplay/inventory/route"; +import * as r51 from "../../../../src/app/api/gameplay/linked-anomalies/route"; +import * as r52 from "../../../../src/app/api/gameplay/locations/route"; +import * as r53 from "../../../../src/app/api/gameplay/milestones/route"; +import * as r54 from "../../../../src/app/api/gameplay/mineral-deposits/route"; +import * as r55 from "../../../../src/app/api/gameplay/nps/route"; +import * as r56 from "../../../../src/app/api/gameplay/page-data/route"; +import * as r57 from "../../../../src/app/api/gameplay/planet-type/route"; +import * as r58 from "../../../../src/app/api/gameplay/solar/route"; +import * as r59 from "../../../../src/app/api/internal/jobs/route"; +import * as r60 from "../../../../src/app/api/public/snapshots/[name]/route"; +import * as r61 from "../../../../src/app/api/public/status/route"; +import * as r62 from "../../../../src/app/api/webhooks/clerk/route"; +import * as r63 from "../../../../src/app/api/actions/[name]/route"; +import * as r64 from "../../../../src/app/api/auto-notify-discoveries/route"; +import * as r65 from "../../../../src/app/api/community-activity/route"; +import * as r66 from "../../../../src/app/api/notify-my-discoveries/route"; +import * as r67 from "../../../../src/app/api/send-test-notification/route"; +import * as r68 from "../../../../src/app/api/storage/[bucket]/[...path]/route"; + +export const apiRoutes: Array<{ pattern: string; segments: RouteSegment[]; module: RouteModule }> = [ + { pattern: "/api/gameplay/deploy/rover/return", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"rover"},{"kind":"literal","value":"return"}], module: r0 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/rover/setup", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"rover"},{"kind":"literal","value":"setup"}], module: r1 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/satellite/quick", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"satellite"},{"kind":"literal","value":"quick"}], module: r2 as unknown as RouteModule }, + { pattern: "/api/gameplay/planet/comments/preferred", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"planet"},{"kind":"literal","value":"comments"},{"kind":"literal","value":"preferred"}], module: r3 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications/configuration", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"},{"kind":"literal","value":"configuration"}], module: r4 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications/count", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"},{"kind":"literal","value":"count"}], module: r5 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications/exists", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"},{"kind":"literal","value":"exists"}], module: r6 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications/options-counter", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"},{"kind":"literal","value":"options-counter"}], module: r7 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/awaiting", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"awaiting"}], module: r8 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/rover", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"rover"}], module: r9 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/satellite", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"satellite"}], module: r10 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/solar", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"solar"}], module: r11 as unknown as RouteModule }, + { pattern: "/api/gameplay/deploy/status", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"deploy"},{"kind":"literal","value":"status"}], module: r12 as unknown as RouteModule }, + { pattern: "/api/gameplay/hub/bootstrap", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"hub"},{"kind":"literal","value":"bootstrap"}], module: r13 as unknown as RouteModule }, + { pattern: "/api/gameplay/hub/state", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"hub"},{"kind":"literal","value":"state"}], module: r14 as unknown as RouteModule }, + { pattern: "/api/gameplay/inventory/lookup", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"inventory"},{"kind":"literal","value":"lookup"}], module: r15 as unknown as RouteModule }, + { pattern: "/api/gameplay/inventory/mine", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"inventory"},{"kind":"literal","value":"mine"}], module: r16 as unknown as RouteModule }, + { pattern: "/api/gameplay/inventory/use", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"inventory"},{"kind":"literal","value":"use"}], module: r17 as unknown as RouteModule }, + { pattern: "/api/gameplay/leaderboards/sunspots", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"leaderboards"},{"kind":"literal","value":"sunspots"}], module: r18 as unknown as RouteModule }, + { pattern: "/api/gameplay/milestones/weekly-progress", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"milestones"},{"kind":"literal","value":"weekly-progress"}], module: r19 as unknown as RouteModule }, + { pattern: "/api/gameplay/missions/exists", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"missions"},{"kind":"literal","value":"exists"}], module: r20 as unknown as RouteModule }, + { pattern: "/api/gameplay/notifications/reject", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"notifications"},{"kind":"literal","value":"reject"}], module: r21 as unknown as RouteModule }, + { pattern: "/api/gameplay/notifications/subscribe", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"notifications"},{"kind":"literal","value":"subscribe"}], module: r22 as unknown as RouteModule }, + { pattern: "/api/gameplay/profile/classification-points", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"profile"},{"kind":"literal","value":"classification-points"}], module: r23 as unknown as RouteModule }, + { pattern: "/api/gameplay/profile/ensure", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"profile"},{"kind":"literal","value":"ensure"}], module: r24 as unknown as RouteModule }, + { pattern: "/api/gameplay/profile/me", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"profile"},{"kind":"literal","value":"me"}], module: r25 as unknown as RouteModule }, + { pattern: "/api/gameplay/profile/referral-status", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"profile"},{"kind":"literal","value":"referral-status"}], module: r26 as unknown as RouteModule }, + { pattern: "/api/gameplay/research/summary", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"research"},{"kind":"literal","value":"summary"}], module: r27 as unknown as RouteModule }, + { pattern: "/api/gameplay/research/unlock", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"research"},{"kind":"literal","value":"unlock"}], module: r28 as unknown as RouteModule }, + { pattern: "/api/gameplay/routes/latest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"routes"},{"kind":"literal","value":"latest"}], module: r29 as unknown as RouteModule }, + { pattern: "/api/gameplay/social/comments", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"social"},{"kind":"literal","value":"comments"}], module: r30 as unknown as RouteModule }, + { pattern: "/api/gameplay/social/my", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"social"},{"kind":"literal","value":"my"}], module: r31 as unknown as RouteModule }, + { pattern: "/api/gameplay/social/votes", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"social"},{"kind":"literal","value":"votes"}], module: r32 as unknown as RouteModule }, + { pattern: "/api/gameplay/storage/upload", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"storage"},{"kind":"literal","value":"upload"}], module: r33 as unknown as RouteModule }, + { pattern: "/api/gameplay/surveyor/comments", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"surveyor"},{"kind":"literal","value":"comments"}], module: r34 as unknown as RouteModule }, + { pattern: "/api/gameplay/telescope/viewport", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"telescope"},{"kind":"literal","value":"viewport"}], module: r35 as unknown as RouteModule }, + { pattern: "/api/gameplay/uploads/mine", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"uploads"},{"kind":"literal","value":"mine"}], module: r36 as unknown as RouteModule }, + { pattern: "/api/gameplay/zoodex/entries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"zoodex"},{"kind":"literal","value":"entries"}], module: r37 as unknown as RouteModule }, + { pattern: "/api/internal/snapshots/refresh", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"internal"},{"kind":"literal","value":"snapshots"},{"kind":"literal","value":"refresh"}], module: r38 as unknown as RouteModule }, + { pattern: "/api/test/auth/login", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"login"}], module: r39 as unknown as RouteModule }, + { pattern: "/api/zoodex/upload-image/gpt", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"zoodex"},{"kind":"literal","value":"upload-image"},{"kind":"literal","value":"gpt"}], module: r40 as unknown as RouteModule }, + { pattern: "/api/auth/complete-guest-conversion", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"complete-guest-conversion"}], module: r41 as unknown as RouteModule }, + { pattern: "/api/auth/guest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"guest"}], module: r42 as unknown as RouteModule }, + { pattern: "/api/auth/session", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"session"}], module: r43 as unknown as RouteModule }, + { pattern: "/api/gameplay/achievements", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"achievements"}], module: r44 as unknown as RouteModule }, + { pattern: "/api/gameplay/active-planet", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"active-planet"}], module: r45 as unknown as RouteModule }, + { pattern: "/api/gameplay/anomalies", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"anomalies"}], module: r46 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications/[id]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"},{"kind":"param","name":"id"}], module: r47 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"}], module: r48 as unknown as RouteModule }, + { pattern: "/api/gameplay/extraction/[id]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"extraction"},{"kind":"param","name":"id"}], module: r49 as unknown as RouteModule }, + { pattern: "/api/gameplay/inventory", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"inventory"}], module: r50 as unknown as RouteModule }, + { pattern: "/api/gameplay/linked-anomalies", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"linked-anomalies"}], module: r51 as unknown as RouteModule }, + { pattern: "/api/gameplay/locations", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"locations"}], module: r52 as unknown as RouteModule }, + { pattern: "/api/gameplay/milestones", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"milestones"}], module: r53 as unknown as RouteModule }, + { pattern: "/api/gameplay/mineral-deposits", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"mineral-deposits"}], module: r54 as unknown as RouteModule }, + { pattern: "/api/gameplay/nps", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"nps"}], module: r55 as unknown as RouteModule }, + { pattern: "/api/gameplay/page-data", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"page-data"}], module: r56 as unknown as RouteModule }, + { pattern: "/api/gameplay/planet-type", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"planet-type"}], module: r57 as unknown as RouteModule }, + { pattern: "/api/gameplay/solar", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"solar"}], module: r58 as unknown as RouteModule }, + { pattern: "/api/internal/jobs", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"internal"},{"kind":"literal","value":"jobs"}], module: r59 as unknown as RouteModule }, + { pattern: "/api/public/snapshots/[name]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"public"},{"kind":"literal","value":"snapshots"},{"kind":"param","name":"name"}], module: r60 as unknown as RouteModule }, + { pattern: "/api/public/status", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"public"},{"kind":"literal","value":"status"}], module: r61 as unknown as RouteModule }, + { pattern: "/api/webhooks/clerk", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"webhooks"},{"kind":"literal","value":"clerk"}], module: r62 as unknown as RouteModule }, + { pattern: "/api/actions/[name]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"actions"},{"kind":"param","name":"name"}], module: r63 as unknown as RouteModule }, + { pattern: "/api/auto-notify-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auto-notify-discoveries"}], module: r64 as unknown as RouteModule }, + { pattern: "/api/community-activity", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"community-activity"}], module: r65 as unknown as RouteModule }, + { pattern: "/api/notify-my-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"notify-my-discoveries"}], module: r66 as unknown as RouteModule }, + { pattern: "/api/send-test-notification", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"send-test-notification"}], module: r67 as unknown as RouteModule }, + { pattern: "/api/storage/[bucket]/[...path]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"storage"},{"kind":"param","name":"bucket"},{"kind":"rest","name":"path"}], module: r68 as unknown as RouteModule }, +]; + +export type RouteSegment = + | { kind: "literal"; value: string } + | { kind: "param"; name: string } + | { kind: "rest"; name: string }; diff --git a/workers/app/src/generated/page-routes.ts b/workers/app/src/generated/page-routes.ts new file mode 100644 index 00000000..3b0c3c6d --- /dev/null +++ b/workers/app/src/generated/page-routes.ts @@ -0,0 +1,21 @@ +// Generated by scripts/cloudflare/generate-routes.mjs. Do not edit. +import type { RouteSegment } from "./api-routes"; + +export const STATIC_PARAM_PLACEHOLDER = "__static__"; + +// Each dynamic page is exported once, with every param set to the placeholder. +export const dynamicPages: Array<{ pattern: string; segments: RouteSegment[]; asset: string }> = [ + { pattern: "/planets/clouds/[id]", segments: [{"kind":"literal","value":"planets"},{"kind":"literal","value":"clouds"},{"kind":"param","name":"id"}], asset: "/planets/clouds/__static__" }, + { pattern: "/planets/edit/[id]", segments: [{"kind":"literal","value":"planets"},{"kind":"literal","value":"edit"},{"kind":"param","name":"id"}], asset: "/planets/edit/__static__" }, + { pattern: "/posts/surveyor/[id]", segments: [{"kind":"literal","value":"posts"},{"kind":"literal","value":"surveyor"},{"kind":"param","name":"id"}], asset: "/posts/surveyor/__static__" }, + { pattern: "/structures/balloon/[project]/[id]/[mission]", segments: [{"kind":"literal","value":"structures"},{"kind":"literal","value":"balloon"},{"kind":"param","name":"project"},{"kind":"param","name":"id"},{"kind":"param","name":"mission"}], asset: "/structures/balloon/__static__/__static__/__static__" }, + { pattern: "/structures/balloon/[project]", segments: [{"kind":"literal","value":"structures"},{"kind":"literal","value":"balloon"},{"kind":"param","name":"project"}], asset: "/structures/balloon/__static__" }, + { pattern: "/structures/seiscam/[project]/[id]/[mission]", segments: [{"kind":"literal","value":"structures"},{"kind":"literal","value":"seiscam"},{"kind":"param","name":"project"},{"kind":"param","name":"id"},{"kind":"param","name":"mission"}], asset: "/structures/seiscam/__static__/__static__/__static__" }, + { pattern: "/structures/telescope/[project]/[id]/[mission]", segments: [{"kind":"literal","value":"structures"},{"kind":"literal","value":"telescope"},{"kind":"param","name":"project"},{"kind":"param","name":"id"},{"kind":"param","name":"mission"}], asset: "/structures/telescope/__static__/__static__/__static__" }, + { pattern: "/structures/telescope/[project]", segments: [{"kind":"literal","value":"structures"},{"kind":"literal","value":"telescope"},{"kind":"param","name":"project"}], asset: "/structures/telescope/__static__" }, + { pattern: "/classify/[id]", segments: [{"kind":"literal","value":"classify"},{"kind":"param","name":"id"}], asset: "/classify/__static__" }, + { pattern: "/extraction/[id]", segments: [{"kind":"literal","value":"extraction"},{"kind":"param","name":"id"}], asset: "/extraction/__static__" }, + { pattern: "/next/[id]", segments: [{"kind":"literal","value":"next"},{"kind":"param","name":"id"}], asset: "/next/__static__" }, + { pattern: "/planets/[id]", segments: [{"kind":"literal","value":"planets"},{"kind":"param","name":"id"}], asset: "/planets/__static__" }, + { pattern: "/posts/[id]", segments: [{"kind":"literal","value":"posts"},{"kind":"param","name":"id"}], asset: "/posts/__static__" }, +]; diff --git a/workers/app/src/index.ts b/workers/app/src/index.ts new file mode 100644 index 00000000..ee8d7853 --- /dev/null +++ b/workers/app/src/index.ts @@ -0,0 +1,288 @@ +// Star Sailors app Worker (SSC-31). +// +// Pages are a static Next.js export served by Workers Static Assets; an asset +// hit never invokes this Worker. It only runs for: +// /api/v1/* the SSC-35 JSON API (workers/api) +// /api/* the existing src/app/api route handlers, run directly with +// Next/Clerk shims instead of the Next.js server +// /ingest/* the PostHog reverse proxy (formerly a next.config rewrite) +// anything else with no matching asset: a dynamic page (served from its +// exported placeholder HTML) or the 404 page +// None of these render React, so every request fits the Workers Free CPU budget. +// +// It also runs the cron trigger (public snapshots, SSC-37) and the jobs queue +// consumer (SSC-39); see background.ts. +import { AsyncLocalStorage } from "node:async_hooks"; + +import { configurePlatform, createMemoryKV, type KVLike, type PlatformJobSender } from "@/src/server/platform"; + +import { handle as handleApiV1 } from "../../api/src/index"; +import { AuthError, verifyClerkJwt, type ClerkClaims } from "../../api/src/jwt"; +import { runQueueBatch, runScheduled, type QueueBatchLike } from "./background"; +import { requestContext, type RequestContext } from "./context"; +import { apiRoutes } from "./generated/api-routes"; +import { dynamicPages } from "./generated/page-routes"; +import { matchSegments, pathParts } from "./match"; +import type { RouteModule } from "./routeTypes"; +import { toNextRequest } from "./shims/next-server"; + +export type Env = { + ASSETS: { fetch: (request: Request) => Promise }; + CLERK_ISSUER?: string; + CLERK_JWKS_URL?: string; + CLERK_AUTHORIZED_PARTIES?: string; + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?: string; + POCKETBASE_URL: string; + POCKETBASE_ADMIN_EMAIL: string; + POCKETBASE_ADMIN_PASSWORD: string; + posthog_region?: string; + /** Workers KV: published public snapshots, job receipts and parked jobs. */ + PUBLIC_DATA?: KVLike; + /** Cloudflare Queues producer for background jobs. */ + JOBS?: { sendBatch: PlatformJobSender }; +}; + +type ExecutionContextLike = { waitUntil(promise: Promise): void }; + +export type Deps = { fetchImpl?: typeof fetch; now?: number; ctx?: ExecutionContextLike }; + +// Shared code (src/server) reaches the bindings through platform(); waitUntil +// follows the current invocation. +const invocation = new AsyncLocalStorage<{ waitUntil: ((promise: Promise) => void) | null }>(); +let isolateKV: KVLike | null = null; + +function installPlatform(env: Env) { + if (!env.PUBLIC_DATA && !isolateKV) { + console.warn("[app-worker] PUBLIC_DATA KV is not bound; snapshots and job records live in isolate memory only"); + } + const kv = env.PUBLIC_DATA ?? (isolateKV ??= createMemoryKV()); + const sendJobs = env.JOBS ? (messages: Parameters[0]) => env.JOBS!.sendBatch(messages) : null; + configurePlatform(() => ({ + kv, + sendJobs, + waitUntil: invocation.getStore()?.waitUntil ?? null, + localFallback: !env.PUBLIC_DATA, + })); +} + +function inInvocation(env: Env, ctx: ExecutionContextLike | undefined, fn: () => Promise): Promise { + installPlatform(env); + return invocation.run({ waitUntil: ctx ? (promise) => ctx.waitUntil(promise) : null }, fn); +} + +// SSC-38 budget evidence: every Worker response carries `x-ssc-subrequests`, +// the number of outbound fetches it made (Free plan cap: 50). CPU time comes +// from Workers Logs / `wrangler tail`; see scripts/cloudflare/measure-budget.mjs. +const metrics = new AsyncLocalStorage<{ subrequests: number }>(); +const platformFetch = globalThis.fetch.bind(globalThis); +globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const store = metrics.getStore(); + if (store) store.subrequests++; + return platformFetch(input, init); +}) as typeof fetch; + +const json = (body: unknown, status: number, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", "cache-control": "private, no-store", ...headers }, + }); + +/** Clerk publishable keys are `pk_(test|live)_` + base64("$"). */ +export function issuerFromPublishableKey(key: string | undefined): string | null { + const encoded = key?.match(/^pk_(?:test|live)_(.+)$/)?.[1]; + if (!encoded) return null; + try { + const host = atob(encoded).replace(/\$$/, ""); + return /^[a-z0-9.-]+$/i.test(host) ? `https://${host}` : null; + } catch { + return null; + } +} + +function clerkIssuer(env: Env): string | null { + return env.CLERK_ISSUER?.trim() || issuerFromPublishableKey(env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY); +} + +function authorizedParties(env: Env, url: URL): string[] { + const configured = env.CLERK_AUTHORIZED_PARTIES?.split(",").map((s) => s.trim()).filter(Boolean); + return configured?.length ? configured : [url.origin]; +} + +function readCookie(header: string | null, name: string): string | null { + if (!header) return null; + for (const part of header.split(";")) { + const eq = part.indexOf("="); + if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return null; +} + +/** The session JWT clerk-js keeps in `__session` (or its suffixed twin). */ +function sessionCookie(header: string | null): string | null { + const plain = readCookie(header, "__session"); + if (plain) return plain; + const suffixed = header?.match(/(?:^|;\s*)__session_[A-Za-z0-9_-]+=([^;]+)/); + return suffixed ? suffixed[1].trim() : null; +} + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +export async function resolveAuth(request: Request, env: Env, url: URL, deps: Deps = {}): Promise { + const signedOut = (authError: string): RequestContext => ({ userId: null, sessionId: null, claims: null, authError }); + + const authorization = request.headers.get("authorization"); + const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7).trim() : null; + const token = bearer || sessionCookie(request.headers.get("cookie")); + if (!token) return signedOut("missing_token"); + + // Cookies ride along on cross-site form posts; bearer tokens do not. A + // cookie-authenticated mutation must come from one of our own origins. + const parties = authorizedParties(env, url); + if (!bearer && !SAFE_METHODS.has(request.method)) { + const origin = request.headers.get("origin"); + if (!origin || !parties.includes(origin)) return signedOut("cross_origin"); + } + + const issuer = clerkIssuer(env); + if (!issuer) return signedOut("issuer_unconfigured"); + + try { + const claims: ClerkClaims = await verifyClerkJwt(token, { + issuer, + jwksUrl: env.CLERK_JWKS_URL, + authorizedParties: parties, + now: deps.now, + fetchImpl: deps.fetchImpl, + }); + return { userId: claims.sub, sessionId: claims.sid ?? null, claims: { ...claims }, authError: null }; + } catch (error) { + if (error instanceof AuthError) return signedOut(error.code); + throw error; + } +} + +async function handleRoute(request: Request, env: Env, url: URL, deps: Deps): Promise { + const parts = pathParts(url.pathname); + if (!parts) return json({ error: "Bad request path" }, 400); + + for (const route of apiRoutes) { + const params = matchSegments(route.segments, parts); + if (!params) continue; + + const module: RouteModule = route.module; + const method = request.method.toUpperCase() as keyof RouteModule; + const handler = module[method] ?? (method === "HEAD" ? module.GET : undefined); + if (!handler) { + const allow = Object.keys(module).filter((k) => /^[A-Z]+$/.test(k) && typeof module[k as keyof RouteModule] === "function"); + if (method === "OPTIONS") return new Response(null, { status: 204, headers: { allow: allow.join(", ") } }); + return json({ error: "Method not allowed" }, 405, { allow: allow.join(", ") }); + } + + const context = await resolveAuth(request, env, url, deps); + const response = await requestContext.run(context, () => + handler(toNextRequest(request), { params: Promise.resolve(params) }), + ); + if (!response.headers.has("cache-control")) { + const copy = new Response(method === "HEAD" ? null : response.body, response); + copy.headers.set("cache-control", "private, no-store"); + return copy; + } + return method === "HEAD" ? new Response(null, response) : response; + } + + return json({ error: "Not found" }, 404); +} + +function posthogHosts(env: Env) { + const eu = (env.posthog_region || "US Cloud").toLowerCase().includes("eu"); + return eu + ? { ingest: "https://eu.i.posthog.com", assets: "https://eu-assets.i.posthog.com" } + : { ingest: "https://us.i.posthog.com", assets: "https://us-assets.i.posthog.com" }; +} + +async function proxyPosthog(request: Request, env: Env, url: URL, fetchImpl: typeof fetch): Promise { + const hosts = posthogHosts(env); + const rest = url.pathname.slice("/ingest".length) || "/"; + const target = rest.startsWith("/static/") ? `${hosts.assets}${rest}` : `${hosts.ingest}${rest}`; + const headers = new Headers(request.headers); + // PostHog never needs our session cookie or Clerk bearer token. + headers.delete("cookie"); + headers.delete("authorization"); + headers.delete("host"); + return fetchImpl(target + url.search, { + method: request.method, + headers, + body: SAFE_METHODS.has(request.method) ? undefined : request.body, + redirect: "manual", + }); +} + +async function servePage(request: Request, env: Env, url: URL): Promise { + // Client-side navigations fetch the page's RSC payload as `.txt`. + const rsc = url.pathname.endsWith(".txt"); + const parts = pathParts(rsc ? url.pathname.slice(0, -4) : url.pathname); + + if (parts && (request.method === "GET" || request.method === "HEAD")) { + for (const page of dynamicPages) { + if (!matchSegments(page.segments, parts)) continue; + const assetUrl = new URL(page.asset + (rsc ? ".txt" : ""), url); + assetUrl.search = url.search; + return env.ASSETS.fetch(new Request(assetUrl, request)); + } + } + + const notFound = await env.ASSETS.fetch(new Request(new URL("/404", url), { headers: request.headers })); + return new Response(notFound.body, { status: 404, headers: notFound.headers }); +} + +export async function handle(request: Request, env: Env, deps: Deps = {}): Promise { + const counter = { subrequests: 0 }; + const response = await metrics.run(counter, () => inInvocation(env, deps.ctx, () => route(request, env, deps))); + const measured = new Response(response.body, response); + measured.headers.set("x-ssc-subrequests", String(counter.subrequests)); + return measured; +} + +async function route(request: Request, env: Env, deps: Deps): Promise { + const url = new URL(request.url); + const { pathname } = url; + + try { + if (pathname === "/ingest" || pathname.startsWith("/ingest/")) { + return await proxyPosthog(request, env, url, deps.fetchImpl ?? fetch); + } + if (pathname.startsWith("/api/v1/")) { + const issuer = clerkIssuer(env); + if (!issuer) return json({ error: "issuer_unconfigured" }, 503); + return await handleApiV1(request, { ...env, CLERK_ISSUER: issuer, CLERK_AUTHORIZED_PARTIES: authorizedParties(env, url).join(",") }, deps); + } + if (pathname === "/api" || pathname.startsWith("/api/")) { + return await handleRoute(request, env, url, deps); + } + return await servePage(request, env, url); + } catch (error) { + console.error(`[app-worker] ${request.method} ${pathname} failed`, error); + return json({ error: "Internal server error" }, 500); + } +} + +/** Runs a cron or queue invocation and logs its outcome and subrequest count. */ +async function background(env: Env, ctx: ExecutionContextLike, label: string, fn: () => Promise>) { + const counter = { subrequests: 0 }; + const started = Date.now(); + try { + const summary = await metrics.run(counter, () => inInvocation(env, ctx, fn)); + console.log(JSON.stringify({ invocation: label, ...summary, subrequests: counter.subrequests, wallMs: Date.now() - started })); + } catch (error) { + console.error(`[app-worker] ${label} failed after ${counter.subrequests} subrequests`, error); + throw error; + } +} + +export default { + fetch: (request: Request, env: Env, ctx?: ExecutionContextLike) => handle(request, env, { ctx }), + scheduled: (controller: { cron: string; scheduledTime: number }, env: Env, ctx: ExecutionContextLike) => + background(env, ctx, `cron ${controller.cron}`, () => runScheduled(controller.cron, controller.scheduledTime)), + queue: (batch: QueueBatchLike, env: Env, ctx: ExecutionContextLike) => + background(env, ctx, `queue ${batch.queue}`, () => runQueueBatch(batch)), +}; diff --git a/workers/app/src/match.ts b/workers/app/src/match.ts new file mode 100644 index 00000000..1b2bfa01 --- /dev/null +++ b/workers/app/src/match.ts @@ -0,0 +1,33 @@ +import type { RouteSegment } from "./generated/api-routes"; + +export type Params = Record; + +/** Matches a decoded-segment path against a generated pattern. Tables are pre-sorted most specific first. */ +export function matchSegments(segments: RouteSegment[], parts: string[]): Params | null { + const params: Params = {}; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (seg.kind === "rest") { + const rest = parts.slice(i); + if (rest.length === 0) return null; + params[seg.name] = rest; + return params; + } + const part = parts[i]; + if (part === undefined) return null; + if (seg.kind === "literal") { + if (seg.value !== part) return null; + } else { + params[seg.name] = part; + } + } + return parts.length === segments.length ? params : null; +} + +export function pathParts(pathname: string): string[] | null { + try { + return pathname.split("/").filter(Boolean).map(decodeURIComponent); + } catch { + return null; + } +} diff --git a/workers/app/src/routeTypes.ts b/workers/app/src/routeTypes.ts new file mode 100644 index 00000000..00b4f701 --- /dev/null +++ b/workers/app/src/routeTypes.ts @@ -0,0 +1,6 @@ +export type RouteHandler = ( + request: Request, + context: { params: Promise> }, +) => Response | Promise; + +export type RouteModule = Partial>; diff --git a/workers/app/src/shims/clerk-nextjs-server.ts b/workers/app/src/shims/clerk-nextjs-server.ts new file mode 100644 index 00000000..dec5ae87 --- /dev/null +++ b/workers/app/src/shims/clerk-nextjs-server.ts @@ -0,0 +1,35 @@ +// `@clerk/nextjs/server` for the Worker bundle. Identity comes from the Clerk +// session JWT the router already verified locally (workers/api/src/jwt.ts), +// so auth() costs no Clerk API call. clerkClient()/currentUser() use the +// fetch-based @clerk/backend client and are only reached by the few routes +// that genuinely need Clerk's Backend API. +import { createClerkClient, type ClerkClient, type User } from "@clerk/backend"; + +import { currentRequestContext } from "../context"; + +export async function auth() { + const { userId, sessionId, claims } = currentRequestContext(); + return { + userId, + sessionId, + sessionClaims: claims, + isAuthenticated: Boolean(userId), + }; +} + +let client: ClerkClient | null = null; + +export async function clerkClient(): Promise { + if (!client) { + const secretKey = process.env.CLERK_SECRET_KEY; + if (!secretKey) throw new Error("CLERK_SECRET_KEY is not configured"); + client = createClerkClient({ secretKey, publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }); + } + return client; +} + +export async function currentUser(): Promise { + const { userId } = currentRequestContext(); + if (!userId) return null; + return (await clerkClient()).users.getUser(userId); +} diff --git a/workers/app/src/shims/next-cache.ts b/workers/app/src/shims/next-cache.ts new file mode 100644 index 00000000..d12b05c7 --- /dev/null +++ b/workers/app/src/shims/next-cache.ts @@ -0,0 +1,8 @@ +// `next/cache` for the Worker bundle. Pages are static assets rendered in the +// browser, so there is no server render cache to invalidate. +export function revalidatePath(_path: string, _type?: "layout" | "page"): void {} +export function revalidateTag(_tag: string): void {} +export function unstable_noStore(): void {} +export function unstable_cache Promise>(fn: T): T { + return fn; +} diff --git a/workers/app/src/shims/next-server.ts b/workers/app/src/shims/next-server.ts new file mode 100644 index 00000000..93fb5c77 --- /dev/null +++ b/workers/app/src/shims/next-server.ts @@ -0,0 +1,31 @@ +// The subset of `next/server` the route handlers under src/app/api use: +// NextResponse.json/redirect/next and NextRequest#nextUrl. + +export class NextResponse extends Response { + static json(body: T, init?: ResponseInit): NextResponse { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) headers.set("content-type", "application/json"); + return new NextResponse(JSON.stringify(body), { ...init, headers }); + } + + static redirect(url: string | URL, init?: number | ResponseInit): NextResponse { + const status = typeof init === "number" ? init : (init?.status ?? 307); + const headers = new Headers(typeof init === "object" ? init.headers : undefined); + headers.set("location", String(url)); + return new NextResponse(null, { status, headers }); + } + + static next(): NextResponse { + return new NextResponse(null, { status: 200 }); + } + + declare readonly __body?: Body; +} + +export type NextRequest = Request & { nextUrl: URL }; + +export function toNextRequest(request: Request): NextRequest { + const nextUrl = new URL(request.url); + Object.defineProperty(request, "nextUrl", { value: nextUrl, enumerable: false }); + return request as NextRequest; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 6011aeef..0b47c6eb 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,12 +1,28 @@ { "$schema": "./node_modules/wrangler/config-schema.json", "name": "starsailors-client", - "main": ".open-next/worker.js", + // SSC-31: no OpenNext. Pages are a static Next.js export (`out/`, built by + // `yarn cf:build`) served by Workers Static Assets without invoking the + // Worker. workers/app/src/index.ts only runs for /api/*, /ingest/* and + // paths with no asset (dynamic pages, 404) and never renders React. + "main": "workers/app/src/index.ts", "compatibility_date": "2026-07-24", + // nodejs_compat: route handlers read process.env (populated from vars and + // secrets) and a few use node:crypto / AsyncLocalStorage. "compatibility_flags": ["nodejs_compat"], "assets": { - "directory": ".open-next/assets", - "binding": "ASSETS" + "directory": "out", + "binding": "ASSETS", + "html_handling": "auto-trailing-slash", + "not_found_handling": "none" + }, + // Route handlers under src/app/api are bundled as-is; these replace the + // Next.js/Clerk server modules they import (see workers/app/src/shims). + "alias": { + "next/server": "./workers/app/src/shims/next-server.ts", + "next/cache": "./workers/app/src/shims/next-cache.ts", + "@clerk/nextjs/server": "./workers/app/src/shims/clerk-nextjs-server.ts", + "@clerk/nextjs/webhooks": "@clerk/backend/webhooks" }, "routes": [ { "pattern": "starsailors.space", "custom_domain": true }, @@ -22,13 +38,32 @@ "observability": { "enabled": true }, + // SSC-37: published public snapshots (one versioned bundle), plus SSC-39 job + // receipts and parked dead letters. The id is filled in at deploy time by + // scripts/cloudflare/ensure-resources.mjs, which creates the namespace if + // needed; `wrangler dev` uses a local simulator with the placeholder. + "kv_namespaces": [{ "binding": "PUBLIC_DATA", "id": "PUBLIC_DATA_KV_ID" }], + // SSC-39: non-critical work (push notifications, server-side analytics, + // reminder fan-out) runs from this queue instead of inside requests. + // max_batch_size 4 keeps a batch under the Free plan's 50 subrequests; + // max_retries 5 leaves room for the consumer to park a job itself (with its + // error) on attempt 5 before the platform moves it to the DLQ. + "queues": { + "producers": [{ "binding": "JOBS", "queue": "starsailors-jobs" }], + "consumers": [ + { "queue": "starsailors-jobs", "max_batch_size": 4, "max_batch_timeout": 5, "max_retries": 5, "dead_letter_queue": "starsailors-jobs-dlq" }, + { "queue": "starsailors-jobs-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } + ] + }, + // Must match workers/app/src/background.ts: snapshots every 5 minutes, one per tick, the + // unclassified-discovery reminder daily at 17:00 UTC. + "triggers": { "crons": ["*/5 * * * *", "0 17 * * *"] }, // Do not set limits.cpu_ms here. The CI Cloudflare account is on Workers // Free; Wrangler then fails the whole deploy with 100328 // ("CPU limits are not supported for the Free plan"). That blocked // production after SSC-24 (GitHub Actions runs 35413508850 / 35430528448). - // OpenNext SSR still cannot finish in the Free 10ms CPU cap (Error 1102). - // After this account is on Workers Paid, restore: - // "limits": { "cpu_ms": 60000 } + // The SSC-31 architecture is built to fit the Free 10ms cap instead; see + // docs/runbooks/cloudflare-cutover.md for the per-route budget. // `wrangler deploy --env staging` — a constant preview target. Runtime // secrets (POCKETBASE_URL, CLERK_SECRET_KEY, ...) are synced from the same @@ -46,7 +81,7 @@ "env": { "staging": { "name": "starsailors-client-staging", - "main": ".open-next/worker.js", + "main": "workers/app/src/index.ts", "compatibility_date": "2026-07-24", "compatibility_flags": ["nodejs_compat"], "workers_dev": true, @@ -55,8 +90,16 @@ // (starsailors.space, www.starsailors.space) to this Worker on deploy. "routes": [{ "pattern": "staging.starsailors.space", "custom_domain": true }], "assets": { - "directory": ".open-next/assets", - "binding": "ASSETS" + "directory": "out", + "binding": "ASSETS", + "html_handling": "auto-trailing-slash", + "not_found_handling": "none" + }, + "alias": { + "next/server": "./workers/app/src/shims/next-server.ts", + "next/cache": "./workers/app/src/shims/next-cache.ts", + "@clerk/nextjs/server": "./workers/app/src/shims/clerk-nextjs-server.ts", + "@clerk/nextjs/webhooks": "@clerk/backend/webhooks" }, "vars": { "NEXT_PUBLIC_POSTHOG_KEY": "phc_65umDftbbTkrm1V6azue6OeU4u5c8iJcaHm4JtJ95di", @@ -67,7 +110,18 @@ }, "observability": { "enabled": true - } + }, + // Separate KV and queues from production. Snapshots only: no reminder + // cron, since staging shares production's PocketBase and users. + "kv_namespaces": [{ "binding": "PUBLIC_DATA", "id": "PUBLIC_DATA_KV_ID_STAGING" }], + "queues": { + "producers": [{ "binding": "JOBS", "queue": "starsailors-jobs-staging" }], + "consumers": [ + { "queue": "starsailors-jobs-staging", "max_batch_size": 4, "max_batch_timeout": 5, "max_retries": 5, "dead_letter_queue": "starsailors-jobs-staging-dlq" }, + { "queue": "starsailors-jobs-staging-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } + ] + }, + "triggers": { "crons": ["*/5 * * * *"] } } } } diff --git a/yarn.lock b/yarn.lock index 4044def6..6989ebe7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -94,736 +94,6 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" -"@ast-grep/napi-darwin-arm64@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-darwin-arm64/-/napi-darwin-arm64-0.40.5.tgz#965f43ee9d93984ea010a2abbccb8df8a9e29800" - integrity sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ== - -"@ast-grep/napi-darwin-x64@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-darwin-x64/-/napi-darwin-x64-0.40.5.tgz#405a2cfaec843802704d0bd3157dd048f182473c" - integrity sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA== - -"@ast-grep/napi-linux-arm64-gnu@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-linux-arm64-gnu/-/napi-linux-arm64-gnu-0.40.5.tgz#d276d8cc9c62ede1c1f0a2dc5c22e50bed4cc845" - integrity sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w== - -"@ast-grep/napi-linux-arm64-musl@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-linux-arm64-musl/-/napi-linux-arm64-musl-0.40.5.tgz#ef32778682a052b9b7acbcf7a88cce8ccf2c6481" - integrity sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA== - -"@ast-grep/napi-linux-x64-gnu@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-linux-x64-gnu/-/napi-linux-x64-gnu-0.40.5.tgz#d2b6a04408c9b27de1946ce7ec2ca3f7492724ad" - integrity sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q== - -"@ast-grep/napi-linux-x64-musl@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-linux-x64-musl/-/napi-linux-x64-musl-0.40.5.tgz#0069d8dfe517551063d49eafed94176e49d288ea" - integrity sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg== - -"@ast-grep/napi-win32-arm64-msvc@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-win32-arm64-msvc/-/napi-win32-arm64-msvc-0.40.5.tgz#b7cae580ec15ad7c4e4465e3413372dfef2bcc3d" - integrity sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug== - -"@ast-grep/napi-win32-ia32-msvc@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-win32-ia32-msvc/-/napi-win32-ia32-msvc-0.40.5.tgz#cee60a7a6a3ef5c2e51078b87412e1ecaa94b4c0" - integrity sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg== - -"@ast-grep/napi-win32-x64-msvc@0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi-win32-x64-msvc/-/napi-win32-x64-msvc-0.40.5.tgz#13d1c700673090d7663fcee0820bafdc2ebe23c6" - integrity sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ== - -"@ast-grep/napi@^0.40.5": - version "0.40.5" - resolved "https://registry.yarnpkg.com/@ast-grep/napi/-/napi-0.40.5.tgz#75e506125440a4b3f77c91fae33af64ad5801969" - integrity sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A== - optionalDependencies: - "@ast-grep/napi-darwin-arm64" "0.40.5" - "@ast-grep/napi-darwin-x64" "0.40.5" - "@ast-grep/napi-linux-arm64-gnu" "0.40.5" - "@ast-grep/napi-linux-arm64-musl" "0.40.5" - "@ast-grep/napi-linux-x64-gnu" "0.40.5" - "@ast-grep/napi-linux-x64-musl" "0.40.5" - "@ast-grep/napi-win32-arm64-msvc" "0.40.5" - "@ast-grep/napi-win32-ia32-msvc" "0.40.5" - "@ast-grep/napi-win32-x64-msvc" "0.40.5" - -"@aws-crypto/sha1-browser@5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz#b0ee2d2821d3861f017e965ef3b4cb38e3b6a0f4" - integrity sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg== - dependencies: - "@aws-crypto/supports-web-crypto" "^5.2.0" - "@aws-crypto/util" "^5.2.0" - "@aws-sdk/types" "^3.222.0" - "@aws-sdk/util-locate-window" "^3.0.0" - "@smithy/util-utf8" "^2.0.0" - tslib "^2.6.2" - -"@aws-crypto/sha256-browser@5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" - integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== - dependencies: - "@aws-crypto/sha256-js" "^5.2.0" - "@aws-crypto/supports-web-crypto" "^5.2.0" - "@aws-crypto/util" "^5.2.0" - "@aws-sdk/types" "^3.222.0" - "@aws-sdk/util-locate-window" "^3.0.0" - "@smithy/util-utf8" "^2.0.0" - tslib "^2.6.2" - -"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz#c4fdb773fdbed9a664fc1a95724e206cf3860042" - integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== - dependencies: - "@aws-crypto/util" "^5.2.0" - "@aws-sdk/types" "^3.222.0" - tslib "^2.6.2" - -"@aws-crypto/supports-web-crypto@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz#a1e399af29269be08e695109aa15da0a07b5b5fb" - integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== - dependencies: - tslib "^2.6.2" - -"@aws-crypto/util@^5.2.0": - version "5.2.0" - resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-5.2.0.tgz#71284c9cffe7927ddadac793c14f14886d3876da" - integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== - dependencies: - "@aws-sdk/types" "^3.222.0" - "@smithy/util-utf8" "^2.0.0" - tslib "^2.6.2" - -"@aws-sdk/checksums@^3.1000.19": - version "3.1000.19" - resolved "https://registry.yarnpkg.com/@aws-sdk/checksums/-/checksums-3.1000.19.tgz#91d29b5fd576a42547c6da587541759a5b4981d3" - integrity sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/client-cloudfront@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-cloudfront/-/client-cloudfront-3.984.0.tgz#4cbdcb2c6c45b72db7466c89cb2bd95388702d37" - integrity sha512-couDuDLpJtoeWne/nYyJ+I+5ntBVdNgBVRTCoDaXuVV7OC3u/wz5Ps0+GogspEwMLEFoOJ8t691h3YXQtnpQTw== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.6" - "@aws-sdk/credential-provider-node" "^3.972.5" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.6" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.984.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.4" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.22.0" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.12" - "@smithy/middleware-retry" "^4.4.29" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.28" - "@smithy/util-defaults-mode-node" "^4.2.31" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-stream" "^4.5.10" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" - tslib "^2.6.2" - -"@aws-sdk/client-dynamodb@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-dynamodb/-/client-dynamodb-3.984.0.tgz#e6e03af181339d0bca1e30d905a51950d1442239" - integrity sha512-8/Oft9MWQtbG6p9f8eY5fsKC2CcO5YVDlwive8eUYS9mEbgnyQxm68OyH26WvsSTykQ9QkIbR+fOG56RsIBODw== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.6" - "@aws-sdk/credential-provider-node" "^3.972.5" - "@aws-sdk/dynamodb-codec" "^3.972.7" - "@aws-sdk/middleware-endpoint-discovery" "^3.972.3" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.6" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.984.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.4" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.22.0" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.12" - "@smithy/middleware-retry" "^4.4.29" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.28" - "@smithy/util-defaults-mode-node" "^4.2.31" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" - tslib "^2.6.2" - -"@aws-sdk/client-lambda@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-lambda/-/client-lambda-3.984.0.tgz#f96993aed8c4e92812b2a2b8ce6337b12f2520d0" - integrity sha512-kqwNBIGNxGVhINwgN/UQfdsQkaMjbu9PFV2EhATWouV+RT60uMjK9JENgLDwbgJmEVbbnPsh9HaZ5KKwPSdiDg== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.6" - "@aws-sdk/credential-provider-node" "^3.972.5" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.6" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.984.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.4" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.22.0" - "@smithy/eventstream-serde-browser" "^4.2.8" - "@smithy/eventstream-serde-config-resolver" "^4.3.8" - "@smithy/eventstream-serde-node" "^4.2.8" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.12" - "@smithy/middleware-retry" "^4.4.29" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.28" - "@smithy/util-defaults-mode-node" "^4.2.31" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-stream" "^4.5.10" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" - tslib "^2.6.2" - -"@aws-sdk/client-s3@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.984.0.tgz#ca8726d321d383c9b5f0f7c2fb48f69e5d583997" - integrity sha512-7ny2Slr93Y+QniuluvcfWwyDi32zWQfznynL56Tk0vVh7bWrvS/odm8WP2nInKicRVNipcJHY2YInur6Q/9V0A== - dependencies: - "@aws-crypto/sha1-browser" "5.2.0" - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.6" - "@aws-sdk/credential-provider-node" "^3.972.5" - "@aws-sdk/middleware-bucket-endpoint" "^3.972.3" - "@aws-sdk/middleware-expect-continue" "^3.972.3" - "@aws-sdk/middleware-flexible-checksums" "^3.972.4" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-location-constraint" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-sdk-s3" "^3.972.6" - "@aws-sdk/middleware-ssec" "^3.972.3" - "@aws-sdk/middleware-user-agent" "^3.972.6" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/signature-v4-multi-region" "3.984.0" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.984.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.4" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.22.0" - "@smithy/eventstream-serde-browser" "^4.2.8" - "@smithy/eventstream-serde-config-resolver" "^4.3.8" - "@smithy/eventstream-serde-node" "^4.2.8" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-blob-browser" "^4.2.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/hash-stream-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/md5-js" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.12" - "@smithy/middleware-retry" "^4.4.29" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.28" - "@smithy/util-defaults-mode-node" "^4.2.31" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-stream" "^4.5.10" - "@smithy/util-utf8" "^4.2.0" - "@smithy/util-waiter" "^4.2.8" - tslib "^2.6.2" - -"@aws-sdk/client-sqs@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/client-sqs/-/client-sqs-3.984.0.tgz#95cd234d9581fc1b9c77d6cf78b624a265981782" - integrity sha512-TDvHpOUWlpanc3xQ5Xw0y8L2hoojBFCCSmXQ/6rKqGOf1ScX3dMA+K9aF0Zp0iwjhSh4VvsHD42esl8XwQZDjA== - dependencies: - "@aws-crypto/sha256-browser" "5.2.0" - "@aws-crypto/sha256-js" "5.2.0" - "@aws-sdk/core" "^3.973.6" - "@aws-sdk/credential-provider-node" "^3.972.5" - "@aws-sdk/middleware-host-header" "^3.972.3" - "@aws-sdk/middleware-logger" "^3.972.3" - "@aws-sdk/middleware-recursion-detection" "^3.972.3" - "@aws-sdk/middleware-sdk-sqs" "^3.972.5" - "@aws-sdk/middleware-user-agent" "^3.972.6" - "@aws-sdk/region-config-resolver" "^3.972.3" - "@aws-sdk/types" "^3.973.1" - "@aws-sdk/util-endpoints" "3.984.0" - "@aws-sdk/util-user-agent-browser" "^3.972.3" - "@aws-sdk/util-user-agent-node" "^3.972.4" - "@smithy/config-resolver" "^4.4.6" - "@smithy/core" "^3.22.0" - "@smithy/fetch-http-handler" "^5.3.9" - "@smithy/hash-node" "^4.2.8" - "@smithy/invalid-dependency" "^4.2.8" - "@smithy/md5-js" "^4.2.8" - "@smithy/middleware-content-length" "^4.2.8" - "@smithy/middleware-endpoint" "^4.4.12" - "@smithy/middleware-retry" "^4.4.29" - "@smithy/middleware-serde" "^4.2.9" - "@smithy/middleware-stack" "^4.2.8" - "@smithy/node-config-provider" "^4.3.8" - "@smithy/node-http-handler" "^4.4.8" - "@smithy/protocol-http" "^5.3.8" - "@smithy/smithy-client" "^4.11.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-base64" "^4.3.0" - "@smithy/util-body-length-browser" "^4.2.0" - "@smithy/util-body-length-node" "^4.2.1" - "@smithy/util-defaults-mode-browser" "^4.3.28" - "@smithy/util-defaults-mode-node" "^4.2.31" - "@smithy/util-endpoints" "^3.2.8" - "@smithy/util-middleware" "^4.2.8" - "@smithy/util-retry" "^4.2.8" - "@smithy/util-utf8" "^4.2.0" - tslib "^2.6.2" - -"@aws-sdk/core@^3.973.6", "@aws-sdk/core@^3.976.0": - version "3.976.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" - integrity sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA== - dependencies: - "@aws-sdk/types" "^3.974.2" - "@aws-sdk/xml-builder" "^3.972.36" - "@aws/lambda-invoke-store" "^0.3.0" - "@smithy/core" "^3.29.4" - "@smithy/signature-v4" "^5.6.5" - "@smithy/types" "^4.16.1" - bowser "^2.11.0" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-env@^3.972.60": - version "3.972.60" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" - integrity sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-http@^3.972.62": - version "3.972.62" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" - integrity sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/fetch-http-handler" "^5.6.6" - "@smithy/node-http-handler" "^4.9.6" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-ini@^3.973.5": - version "3.973.5" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" - integrity sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/credential-provider-env" "^3.972.60" - "@aws-sdk/credential-provider-http" "^3.972.62" - "@aws-sdk/credential-provider-login" "^3.972.67" - "@aws-sdk/credential-provider-process" "^3.972.60" - "@aws-sdk/credential-provider-sso" "^3.973.4" - "@aws-sdk/credential-provider-web-identity" "^3.972.66" - "@aws-sdk/nested-clients" "^3.997.34" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/credential-provider-imds" "^4.4.9" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-login@^3.972.67": - version "3.972.67" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" - integrity sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/nested-clients" "^3.997.34" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-node@^3.972.5": - version "3.972.71" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" - integrity sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg== - dependencies: - "@aws-sdk/credential-provider-env" "^3.972.60" - "@aws-sdk/credential-provider-http" "^3.972.62" - "@aws-sdk/credential-provider-ini" "^3.973.5" - "@aws-sdk/credential-provider-process" "^3.972.60" - "@aws-sdk/credential-provider-sso" "^3.973.4" - "@aws-sdk/credential-provider-web-identity" "^3.972.66" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/credential-provider-imds" "^4.4.9" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-process@^3.972.60": - version "3.972.60" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" - integrity sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-sso@^3.973.4": - version "3.973.4" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" - integrity sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/nested-clients" "^3.997.34" - "@aws-sdk/token-providers" "3.1092.0" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/credential-provider-web-identity@^3.972.66": - version "3.972.66" - resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" - integrity sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/nested-clients" "^3.997.34" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/dynamodb-codec@^3.972.7": - version "3.973.34" - resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.34.tgz#529710dddc59456436dce9458c038151358812d6" - integrity sha512-/7kfOufSN9t/g7OR6gNpCsSj/4nR7WdRnd54qSlnRuzQN7JmOz6ulJSwrUpqkDk/mkFgQqUIFBfnAZ0OTw2pxw== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/endpoint-cache@^3.972.9": - version "3.972.9" - resolved "https://registry.yarnpkg.com/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.9.tgz#fd0731a33ea7ff69be5fd647dd7fc75a5854371d" - integrity sha512-LFvdgq8SriaskUcjpBMDE7J2c9RmuT5v3gU36/znV71EU5DKUis4FmGFjCMelKCCViFeVrQADBAlIiOYRhEx6Q== - dependencies: - mnemonist "0.38.3" - tslib "^2.6.2" - -"@aws-sdk/middleware-bucket-endpoint@^3.972.3": - version "3.972.38" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.38.tgz#dc55c2713fcb1a187b1b16bc806535b31a622054" - integrity sha512-+AbKxH8M0ex/1gQIgBjhXhj4pyjTL3de7CrW8zJofCXH0ClNq0XQ8W6OEfVj4kEZwy1yOZCpimjQiCwvUkqwNg== - dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.65" - tslib "^2.6.2" - -"@aws-sdk/middleware-endpoint-discovery@^3.972.3": - version "3.972.25" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.25.tgz#251d2be29c82027e8e3f9810b9fe0964894358f3" - integrity sha512-5G2aVPmbWC5dFI76D0vsNg2L0fgWP4uybcetf+06dzeZuRtpihnow88fOl5zm3+ABdIw27FKgyFzV4yB5Bm29Q== - dependencies: - "@aws-sdk/endpoint-cache" "^3.972.9" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/middleware-expect-continue@^3.972.3": - version "3.972.34" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.34.tgz#33a7e0cb3a1b0359dd2ab1024d352db9c901f38c" - integrity sha512-Jdclsf50Pmrl+MUQzhlhhCUiE5++KgyVUiLFgVpfIjCG7w5cOG11lc7D3L5h/NUtgoKguIXKXGw4/VA0KdKsIg== - dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.65" - tslib "^2.6.2" - -"@aws-sdk/middleware-flexible-checksums@^3.972.4": - version "3.974.44" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.44.tgz#8b4ef1505ac7d207a3756a44af8d04cc6abdace5" - integrity sha512-SutgvALQgcEKa3nFEQWQg2r6xMnuafWZbmO0sq0kfWV0meMbPi9/vbwlH091TS0q4VH0eYggwxFB6ibkP/3Bag== - dependencies: - "@aws-sdk/checksums" "^3.1000.19" - tslib "^2.6.2" - -"@aws-sdk/middleware-host-header@^3.972.3": - version "3.972.35" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.35.tgz#4ebf2cae7f302b270cbf96f2141441c3ce4b2742" - integrity sha512-hxpHbH/faHP5rZGeN4nCUJz1SN9VQ3jbwi5QNRVTvU6Oh1uirQVlrYmMIPt1gZ/3vv0IYNa+1k1IQ5UD6dQfjQ== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/middleware-location-constraint@^3.972.3": - version "3.972.31" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.31.tgz#98c29d2a389cfba7d9276d855145d5f7944474c8" - integrity sha512-dX4n/A8c4oh8Xcc0qBpa61uIjdEObozwtSr7X+AdST9DHy8GUkWHhf8iMIr974vYFVtvgBdUpprO3wCNMCzBig== - dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.65" - tslib "^2.6.2" - -"@aws-sdk/middleware-logger@^3.972.3": - version "3.972.34" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.34.tgz#029cef2ae50466062b341db208b45d0f7f09a6b4" - integrity sha512-+7grJdqdLmHI8UxCgzsSOPBJUqM+aaNDBP0SuicONhflOZoFFnPyqKCEmCJP+5UakVvCHuHrtYeS4TfA3+gDhw== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/middleware-recursion-detection@^3.972.3": - version "3.972.36" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.36.tgz#a35e8279a2e69732dbcfd27661b19a3942fdfdb8" - integrity sha512-SjWu1KVO4wpxex7jlO2ZdKlR4eeAZBFfTWMh2PSl3rwHGYdDISLHt4qsG2zXxN2SoSXEkKVE+Gu3U/esUwqvag== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/middleware-sdk-s3@^3.972.6", "@aws-sdk/middleware-sdk-s3@^3.972.65": - version "3.972.65" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz#9b49df5deb68dee5d5da4d630a6ce3676408de5e" - integrity sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/signature-v4-multi-region" "^3.996.41" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/middleware-sdk-sqs@^3.972.5": - version "3.972.37" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.37.tgz#1c69c7ff957bee8fdd32fbe1a704775f7c80bba6" - integrity sha512-ObKPWZWpog+4zZQ2q+LdBwf/nm+HF2afgBxCtyHeqjRlsnATuKOV3dPYnzCGyjRZ/RHTEre4LRrYRcIxlWhzVQ== - dependencies: - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/middleware-ssec@^3.972.3": - version "3.972.31" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.31.tgz#557ca596b8da85bc5b5f1eeee76882e966711008" - integrity sha512-kTWR3tKA9x5MXFFd2L59X+ROCO8tbZg2zXV/2jJfKVp6ueBcOMSlE4TMfExEBM6J68j49bCaWrN10kzE2BvQ1Q== - dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.65" - tslib "^2.6.2" - -"@aws-sdk/middleware-user-agent@^3.972.6": - version "3.972.64" - resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.64.tgz#4bd5cfb76d376b54878a1b5ca9d49b317bf75586" - integrity sha512-jCXY8TbntpdD001HJF9JeFhN/La7v7HxKKEIf4rB/XCsIvAaPuJ0hDDQmSouGKc+fOJu0bY4jYX95VTE+7qugw== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/nested-clients@^3.997.34": - version "3.997.34" - resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" - integrity sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/signature-v4-multi-region" "^3.996.41" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/fetch-http-handler" "^5.6.6" - "@smithy/node-http-handler" "^4.9.6" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/region-config-resolver@^3.972.3": - version "3.972.38" - resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.38.tgz#832fa5f6a5d640086474ce66e4e4581620e260cd" - integrity sha512-8Z9/e0naoeSSIbVFBI7hE6XczMR+humy4frYzQY1FGFUxUOzDcUtYuT2Of0d10M9k/5CTXoIJQz6VN5AIty6cQ== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/signature-v4-multi-region@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.984.0.tgz#ddcd91add853425f818e2478f13158fb6545ee27" - integrity sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA== - dependencies: - "@aws-sdk/middleware-sdk-s3" "^3.972.6" - "@aws-sdk/types" "^3.973.1" - "@smithy/protocol-http" "^5.3.8" - "@smithy/signature-v4" "^5.3.8" - "@smithy/types" "^4.12.0" - tslib "^2.6.2" - -"@aws-sdk/signature-v4-multi-region@^3.996.41": - version "3.996.41" - resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" - integrity sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng== - dependencies: - "@aws-sdk/types" "^3.974.2" - "@smithy/signature-v4" "^5.6.5" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/token-providers@3.1092.0": - version "3.1092.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" - integrity sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ== - dependencies: - "@aws-sdk/core" "^3.976.0" - "@aws-sdk/nested-clients" "^3.997.34" - "@aws-sdk/types" "^3.974.2" - "@smithy/core" "^3.29.4" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.1", "@aws-sdk/types@^3.974.2": - version "3.974.2" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" - integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== - dependencies: - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws-sdk/util-endpoints@3.984.0": - version "3.984.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz#a2cb22dbeee710be9558e8678370a44b75bbf162" - integrity sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ== - dependencies: - "@aws-sdk/types" "^3.973.1" - "@smithy/types" "^4.12.0" - "@smithy/url-parser" "^4.2.8" - "@smithy/util-endpoints" "^3.2.8" - tslib "^2.6.2" - -"@aws-sdk/util-locate-window@^3.0.0": - version "3.965.8" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz#d9a6bede3c136f433441391615da68c924692487" - integrity sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g== - dependencies: - tslib "^2.6.2" - -"@aws-sdk/util-user-agent-browser@^3.972.3": - version "3.972.35" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.35.tgz#ecee246fa8ab3103ceb683e368bb838d59867906" - integrity sha512-9aymmO5ENUPB1i96d39rGTUf4l2t9eR0LqWkByMmIoXDTph5h9VfmtltULEOv1ddh7hgG6ZyEjjDMxpwXbTFXw== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/util-user-agent-node@^3.972.4": - version "3.973.50" - resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.50.tgz#477a85e8f984853e8e58b0f8155d1e692c0080e8" - integrity sha512-vVgiKYH4u9VbdxunM+zuwtwvE/JBCTp1tVjfhNQYsifytUvoqiTu0WUt1y7V4GltA8hz3b2HYaRm2dFI/MVtyw== - dependencies: - "@aws-sdk/core" "^3.976.0" - tslib "^2.6.2" - -"@aws-sdk/xml-builder@^3.972.36": - version "3.972.36" - resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" - integrity sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA== - dependencies: - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@aws/lambda-invoke-store@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz#708802d987f8e17bdf4af4de1031660ce1cdd65f" - integrity sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ== - "@babel/code-frame@^7.10.4": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" @@ -1013,26 +283,6 @@ resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz" integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== -"@dotenvx/dotenvx@1.31.0": - version "1.31.0" - resolved "https://registry.yarnpkg.com/@dotenvx/dotenvx/-/dotenvx-1.31.0.tgz#987764fde318d8e738612d013808e612388e30b1" - integrity sha512-GeDxvtjiRuoyWVU9nQneId879zIyNdL05bS7RKiqMkfBSKpHMWHLoRyRqjYWLaXmX/llKO1hTlqHDmatkQAjPA== - dependencies: - commander "^11.1.0" - dotenv "^16.4.5" - eciesjs "^0.4.10" - execa "^5.1.1" - fdir "^6.2.0" - ignore "^5.3.0" - object-treeify "1.1.33" - picomatch "^4.0.2" - which "^4.0.0" - -"@ecies/ciphers@^0.2.5": - version "0.2.6" - resolved "https://registry.yarnpkg.com/@ecies/ciphers/-/ciphers-0.2.6.tgz#e7cdc4688de3c224e03d479e3227bcece44cbeab" - integrity sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g== - "@emnapi/core@^1.4.3", "@emnapi/core@^1.7.1": version "1.8.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" @@ -1079,11 +329,6 @@ resolved "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz" integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== -"@esbuild/aix-ppc64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz#830d6476cbbca0c005136af07303646b419f1162" - integrity sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q== - "@esbuild/aix-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" @@ -1099,11 +344,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== -"@esbuild/android-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz#d11d4fc299224e729e2190cacadbcc00e7a9fd67" - integrity sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A== - "@esbuild/android-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" @@ -1119,11 +359,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== -"@esbuild/android-arm@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.4.tgz#5660bd25080553dd2a28438f2a401a29959bd9b1" - integrity sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ== - "@esbuild/android-arm@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" @@ -1139,11 +374,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== -"@esbuild/android-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.4.tgz#18ddde705bf984e8cd9efec54e199ac18bc7bee1" - integrity sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ== - "@esbuild/android-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" @@ -1159,11 +389,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== -"@esbuild/darwin-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz#b0b7fb55db8fc6f5de5a0207ae986eb9c4766e67" - integrity sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g== - "@esbuild/darwin-arm64@0.27.2": version "0.27.2" resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz" @@ -1179,11 +404,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== -"@esbuild/darwin-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz#e6813fdeba0bba356cb350a4b80543fbe66bf26f" - integrity sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A== - "@esbuild/darwin-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" @@ -1199,11 +419,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== -"@esbuild/freebsd-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz#dc11a73d3ccdc308567b908b43c6698e850759be" - integrity sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ== - "@esbuild/freebsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" @@ -1219,11 +434,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== -"@esbuild/freebsd-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz#91da08db8bd1bff5f31924c57a81dab26e93a143" - integrity sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ== - "@esbuild/freebsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" @@ -1239,11 +449,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== -"@esbuild/linux-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz#efc15e45c945a082708f9a9f73bfa8d4db49728a" - integrity sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ== - "@esbuild/linux-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" @@ -1259,11 +464,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== -"@esbuild/linux-arm@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz#9b93c3e54ac49a2ede6f906e705d5d906f6db9e8" - integrity sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ== - "@esbuild/linux-arm@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" @@ -1279,11 +479,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== -"@esbuild/linux-ia32@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz#be8ef2c3e1d99fca2d25c416b297d00360623596" - integrity sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ== - "@esbuild/linux-ia32@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" @@ -1299,11 +494,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== -"@esbuild/linux-loong64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz#b0840a2707c3fc02eec288d3f9defa3827cd7a87" - integrity sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA== - "@esbuild/linux-loong64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" @@ -1319,11 +509,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== -"@esbuild/linux-mips64el@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz#2a198e5a458c9f0e75881a4e63d26ba0cf9df39f" - integrity sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg== - "@esbuild/linux-mips64el@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" @@ -1339,11 +524,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== -"@esbuild/linux-ppc64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz#64f4ae0b923d7dd72fb860b9b22edb42007cf8f5" - integrity sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag== - "@esbuild/linux-ppc64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" @@ -1359,11 +539,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== -"@esbuild/linux-riscv64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz#fb2844b11fdddd39e29d291c7cf80f99b0d5158d" - integrity sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA== - "@esbuild/linux-riscv64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" @@ -1379,11 +554,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== -"@esbuild/linux-s390x@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz#1466876e0aa3560c7673e63fdebc8278707bc750" - integrity sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g== - "@esbuild/linux-s390x@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" @@ -1399,11 +569,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== -"@esbuild/linux-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz#c10fde899455db7cba5f11b3bccfa0e41bf4d0cd" - integrity sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA== - "@esbuild/linux-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" @@ -1419,11 +584,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== -"@esbuild/netbsd-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz#02e483fbcbe3f18f0b02612a941b77be76c111a4" - integrity sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ== - "@esbuild/netbsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" @@ -1439,11 +599,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== -"@esbuild/netbsd-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz#ec401fb0b1ed0ac01d978564c5fc8634ed1dc2ed" - integrity sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw== - "@esbuild/netbsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" @@ -1459,11 +614,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== -"@esbuild/openbsd-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz#f272c2f41cfea1d91b93d487a51b5c5ca7a8c8c4" - integrity sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A== - "@esbuild/openbsd-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" @@ -1479,11 +629,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== -"@esbuild/openbsd-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz#2e25950bc10fa9db1e5c868e3d50c44f7c150fd7" - integrity sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw== - "@esbuild/openbsd-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" @@ -1514,11 +659,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== -"@esbuild/sunos-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz#cd596fa65a67b3b7adc5ecd52d9f5733832e1abd" - integrity sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q== - "@esbuild/sunos-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" @@ -1534,11 +674,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== -"@esbuild/win32-arm64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz#b4dbcb57b21eeaf8331e424c3999b89d8951dc88" - integrity sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ== - "@esbuild/win32-arm64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" @@ -1554,11 +689,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== -"@esbuild/win32-ia32@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz#410842e5d66d4ece1757634e297a87635eb82f7a" - integrity sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg== - "@esbuild/win32-ia32@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" @@ -1574,11 +704,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== -"@esbuild/win32-x64@0.25.4": - version "0.25.4" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz#0b17ec8a70b2385827d52314c1253160a0b9bacc" - integrity sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ== - "@esbuild/win32-x64@0.27.2": version "0.27.2" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" @@ -2041,11 +1166,6 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" -"@isaacs/cliui@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" - integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== - "@istanbuljs/schema@^0.1.2": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -2064,14 +1184,6 @@ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/source-map@^0.3.2": - version "0.3.11" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.11.tgz#b21835cbd36db656b857c2ad02ebd413cc13a9ba" - integrity sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" @@ -2085,7 +1197,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.31": +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -2175,47 +1287,6 @@ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.13.tgz#71e6c48d978d569a220f080ff832aec1a78d1d17" integrity sha512-B5E82pX3VXu6Ib5mDuZEqGwT8asocZe3OMMnaM+Yfs0TRlmSQCBQUUXR9BkXQeGVboOWS1pTsRkS9wzFd8PABw== -"@noble/ciphers@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.3.0.tgz#f64b8ff886c240e644e5573c097f86e5b43676dc" - integrity sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw== - -"@noble/curves@^1.9.7": - version "1.9.7" - resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" - integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== - dependencies: - "@noble/hashes" "1.8.0" - -"@noble/hashes@1.8.0", "@noble/hashes@^1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" - integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== - -"@node-minify/core@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@node-minify/core/-/core-8.0.6.tgz#f67ccd05e12273247da77e3039bb4dde6b4242e4" - integrity sha512-/vxN46ieWDLU67CmgbArEvOb41zlYFOkOtr9QW9CnTrBLuTyGgkyNWC2y5+khvRw3Br58p2B5ZVSx/PxCTru6g== - dependencies: - "@node-minify/utils" "8.0.6" - glob "9.3.5" - mkdirp "1.0.4" - -"@node-minify/terser@^8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@node-minify/terser/-/terser-8.0.6.tgz#2dca4137440631e7d72adf27d2a30a9ca10ba015" - integrity sha512-grQ1ipham743ch2c3++C8Isk6toJnxJSyDiwUI/IWUCh4CZFD6aYVw6UAY40IpCnjrq5aXGwiv5OZJn6Pr0hvg== - dependencies: - "@node-minify/utils" "8.0.6" - terser "5.16.9" - -"@node-minify/utils@8.0.6": - version "8.0.6" - resolved "https://registry.yarnpkg.com/@node-minify/utils/-/utils-8.0.6.tgz#80ab4e016c97beef72a802b24110bfffb7623fd9" - integrity sha512-csY4qcR7jUwiZmkreNTJhcypQfts2aY2CK+a+rXgXUImZiZiySh0FvwHjRnlqWKvg+y6ae9lHFzDRjBTmqlTIQ== - dependencies: - gzip-size "6.0.0" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -2242,45 +1313,6 @@ resolved "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== -"@opennextjs/aws@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@opennextjs/aws/-/aws-4.1.0.tgz#2dfd754660cd460b77bcf1c403c02e8c9187d30a" - integrity sha512-GuKkdUbnJhLvtwTiJlytNtWIcSYN3+Dzi+OusUlRgf+Ur7dGoXdzBopd+VFiSdTBduPOvr9lPFDg6BYNjEV3wQ== - dependencies: - "@ast-grep/napi" "^0.40.5" - "@aws-sdk/client-cloudfront" "3.984.0" - "@aws-sdk/client-dynamodb" "3.984.0" - "@aws-sdk/client-lambda" "3.984.0" - "@aws-sdk/client-s3" "3.984.0" - "@aws-sdk/client-sqs" "3.984.0" - "@node-minify/core" "^8.0.6" - "@node-minify/terser" "^8.0.6" - "@tsconfig/node18" "^1.0.3" - aws4fetch "^1.0.20" - chalk "^5.6.2" - cookie "^1.0.2" - esbuild "0.25.4" - express "^5.1.0" - path-to-regexp "^6.3.0" - urlpattern-polyfill "^10.1.0" - yaml "^2.8.1" - -"@opennextjs/cloudflare@^1.20.2": - version "1.20.2" - resolved "https://registry.yarnpkg.com/@opennextjs/cloudflare/-/cloudflare-1.20.2.tgz#87df72fd827be38a700b1645c4f58192566ef301" - integrity sha512-iFBjABnaDk3be27F5EpxyMLMGPbVnnArFx5I3Y8Rf6BSx5nBV8h0UuJiMKrx3+whDU5ahIy4d8sfbvWvMiF1Kg== - dependencies: - "@ast-grep/napi" "^0.40.5" - "@dotenvx/dotenvx" "1.31.0" - "@opennextjs/aws" "4.1.0" - ci-info "^4.2.0" - cloudflare "^4.4.1" - comment-json "^4.5.1" - enquirer "^2.4.1" - glob "^12.0.0" - ts-tqdm "^0.8.6" - yargs "^18.0.0" - "@oxc-resolver/binding-android-arm-eabi@11.16.1": version "11.16.1" resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.16.1.tgz#20bea8518a072d2acfcdd0688ddefd70296822e3" @@ -3462,313 +2494,6 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.2.0.tgz#7c594e1a64336d2008d99d814056d459421504d4" integrity sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw== -"@smithy/config-resolver@^4.4.6": - version "4.6.13" - resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.6.13.tgz#0d4e182e2ac8cba3226aa0abe78d5235ccd3e9ef" - integrity sha512-tRHcxpFb7ExGfx95eaROg1um3W0xeTCfNO0mwIq2dNSVq6v3UNs8tsHM0Otu0KjW4EIqMdo+BqoYn8paIRfd0w== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/core@^3.22.0", "@smithy/core@^3.29.4", "@smithy/core@^3.29.8": - version "3.29.8" - resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.29.8.tgz#6466029ff27285257cf9bb4864fdb632c870ccf0" - integrity sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA== - dependencies: - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/credential-provider-imds@^4.4.9": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.13.tgz#33dce458f03ee3a262b5379eff37e335eaea70a9" - integrity sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q== - dependencies: - "@smithy/core" "^3.29.8" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/eventstream-serde-browser@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.4.13.tgz#ad9a6bc82600587f157567a784e6750e688f8380" - integrity sha512-vyCLTgRR0tFUBcDw5CjrK2MXBt+IpMowHLJj55WLq+OTFWbhgXJ+b9ynA30iqO+JDHafTILPA7zFU+mQaoSEMg== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/eventstream-serde-config-resolver@^4.3.8": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.5.13.tgz#5cd4ff9774c8108084e6606205fdc3fb71598c7d" - integrity sha512-hvfqabCnuH++945c4cPSP1tCT/BPt1bphDa3arAb6B3DZ+BUn6ZJLL69mC4iiQIQoiaIkZsaz9P1/oqNJlur5Q== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/eventstream-serde-node@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.4.13.tgz#3ffe049c2e54ef3d442d312b9a9a3719a8bb66dc" - integrity sha512-oo9G/rLDbo5AzDKdsUPpeJ2HlNcMrz8ifGLu3Ahb4M0zD0BEIJH5Y7fXtojbm/VjL+3VO0NxhSPcook0lM0kAw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/fetch-http-handler@^5.3.9", "@smithy/fetch-http-handler@^5.6.6": - version "5.6.10" - resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.10.tgz#a50908b47d16a8f5c56e98762c2e27339a8c19ab" - integrity sha512-5/Yj9mS2JjTsB3B8ZX7euh77mrY9aXW23ag1yAmFykSRmA6vldqBrgqmSeQ50EjY+5SB8+aE4w14B6LKbBVEhQ== - dependencies: - "@smithy/core" "^3.29.8" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/hash-blob-browser@^4.2.9": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.4.13.tgz#de8ec8f2e155469d6579e91aeab2168d23734ef0" - integrity sha512-JGCuiDhZzekGDCjLqP1fHROCn9EP5ljDL8B1iwC+EDrioGRNHQC1bhpNoKkghVJA3KgyhKKWFLpdKCbkGuhIdw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/hash-node@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.4.13.tgz#9602f4dc6fb298f3ae38af6a82012b58e6b96570" - integrity sha512-PGGdjB5eazZFgckmMWnURK99tD3i5T1Y0Wfi1zs5cgwnEAg0w6ZawHkUmNzZ5Tae6Qd0DmxOw0VPYG0vPQrpGA== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/hash-stream-node@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.4.13.tgz#71cdb12865fb6ef8ad0b442851174b58d73f4308" - integrity sha512-W8VygA7zPy+RdcCnBetHXwYGfWkXwCqsI8v3P3k6w0S4hUZbNNuy17Y13x+chndMOtoVdEp56hv1SetuFbTH1g== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/invalid-dependency@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.4.13.tgz#21688e7e5ccbcbef91c1e98797aad3b7b1d0ef49" - integrity sha512-m5yVhT6Xcbgh2cnAJc/5iAn6kxx8MCc8YBdXqY6CoA3yFkZs/8TyeD0D0doNswBxR88MtHRd5nkwxUTCuAD34A== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/is-array-buffer@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" - integrity sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA== - dependencies: - tslib "^2.6.2" - -"@smithy/md5-js@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.4.13.tgz#dd43f5e618bbff96da3bbc351edbb5f695b9bded" - integrity sha512-XMm0JMEScFgFFu7PoWwuiOJLIfeIYWglzhGtbEHBXtOKQ+0yk2yaHak5n0ecWAr2amMmV+0H08Si9DPCIMqbZQ== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/middleware-content-length@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.4.13.tgz#43e5f407c9e659bd0bb16cf81fa7194a35f90a6d" - integrity sha512-uNhk/kiHttuM7dMXxEQEu6SNKUfc1E7dvaK5fxdth2KcaJDUbhGcji/P4N526tNruhB1YA/JmJej5CDz6PzWZQ== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/middleware-endpoint@^4.4.12": - version "4.6.13" - resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.6.13.tgz#43ed2d09b8d25237ac092b8de4f8d41cd359c353" - integrity sha512-HKDYC6eesfWY9IzSUTwdsWpQfjzgytm5+r2qYFKX9Qt20RLfmdZX9iFQuaNOoi305oJAT2ve+SHM2gjNfoo3Xw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/middleware-retry@^4.4.29": - version "4.7.13" - resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.7.13.tgz#a17ccda3e32236f6d970d2cf1faa7c352de37ce1" - integrity sha512-xAGX+PJSx1ht0N0c2R9xHxxKSDJYDzW3QHzPLckBMoMBdL5a/sBKcWug7T1lvir92PW0T9A10sJmaboonh+gtw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/middleware-serde@^4.2.9": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.4.13.tgz#4980497cc9f5e0f17dafbc55d0a7a7bc73e49898" - integrity sha512-zlpwBX4XvGsOpsE8LdYDbu5a7e/RHMN+pK9+xnlHkSGFOtfiXQD202SQmQyQGoL4ULfrPpMcdbi+4IRjgoa3HQ== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/middleware-stack@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.4.13.tgz#a71ab7d820e90cf7c345c1f6d5b0ed0678c1b860" - integrity sha512-J+jsaDBvbpWnD8h39zAPTFzlESQNhr/PBP/Lh1Exp+grk2r7yVkd4IzXNhIGWMWFfGgC2ojnukodxsHuFWXpKg== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/node-config-provider@^4.3.8": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.5.13.tgz#625c4d26b5880ae4e880c560ab0b1c6635c9788a" - integrity sha512-nMws+Ogj4eoAr5laTmhIcxbQVOLAJ79bp7Rv8PuCV+/B06U2KpWbX9Eq9niL9JjtWMfTL1pb4kC07gqZvayP6Q== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/node-http-handler@^4.4.8", "@smithy/node-http-handler@^4.9.6": - version "4.9.10" - resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.10.tgz#e03a874620cec491f787787b8bf6072d45adabc7" - integrity sha512-ETQz9v/Z+nTQc6fRWTXxUpxJqwpmzB3Tn3WKAdHwWkeT+m+HE5czs6GNG8vW+4vyxXSls65RVcvOZwk7Q/PS/Q== - dependencies: - "@smithy/core" "^3.29.8" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/protocol-http@^5.3.8": - version "5.5.13" - resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.5.13.tgz#53c95075e80526581b776563fc1240e72cba8d09" - integrity sha512-2LWM8ga9iQW+aAVaFsqRVmP7U+gaAg0uNTpRzANZt+Ebbs5hE+OKp05EgTm3poWyfMg+sML6BoeHUWigrV3W0g== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/signature-v4@^5.3.8", "@smithy/signature-v4@^5.6.5": - version "5.6.9" - resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.9.tgz#09ede51303925677a401f9759b18041ff46a1070" - integrity sha512-g5rnEii/mkT0mjVJmlsaOfyNBtHNTecD9Lo4NP8D5HzMUEnZNpz7/FbvBCjNcV4vteHFAxOGiLUYNxPkDZZAPw== - dependencies: - "@smithy/core" "^3.29.8" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/smithy-client@^4.11.1": - version "4.14.13" - resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.14.13.tgz#5e1496c1a2fc4e593f8f932fa1dd5be7b53dfbb5" - integrity sha512-wXNjb6446B97JHGYynof1rUVDaXwZ38c+F9cHZ2lwB8n7nis6qN7NqoEJGQfRXJX8tXRsxQRtXCLrYQhcKPU9g== - dependencies: - "@smithy/core" "^3.29.8" - "@smithy/types" "^4.16.1" - tslib "^2.6.2" - -"@smithy/types@^4.12.0", "@smithy/types@^4.16.1": - version "4.16.1" - resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" - integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== - dependencies: - tslib "^2.6.2" - -"@smithy/url-parser@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.4.13.tgz#f7b03c778b1128b5df22e41596dadbadef64ead9" - integrity sha512-JOyOUXnQ+FAbBauX8vNY9Jickj+gyTUFTRoG8MTdSp37gN7EAYjbCFCGUV0Szgstf9rkkcvHY0pSUb6EXDwYwA== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-base64@^4.3.0": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.5.13.tgz#ab6b660bcad353fda2789ef01b3a3cb7dbb4ba6e" - integrity sha512-BnEXDggDlVYstUQ48OttdREMtJs4foi2Akjmp8i6l9aXGZqCKWZqKQzsze9xLwKNvUKXvDfuXC3jNVguXPSfkg== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-body-length-browser@^4.2.0": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.4.13.tgz#e157b308cb2936322513aeb27302e0dded80a9ac" - integrity sha512-OOphLlQTgDkU57i36Rs7W7ZCYr8+mn0WiJ8jdqdkxBxYlBv6NI7A3etNugTRiBSba7uqQ9qeqqaqH0kkahscCw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-body-length-node@^4.2.1": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.4.13.tgz#146a706fd6abbabf291b2891bac46415b3d53a69" - integrity sha512-7Q+FJ+086SUay8135lQpa6+8pBtyT3kwQa2dxfZwykzHxaZ3DzLLqfgZTkkZW/1RBRoHzlVWo/FEIjHyO9EDvw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-buffer-from@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" - integrity sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA== - dependencies: - "@smithy/is-array-buffer" "^2.2.0" - tslib "^2.6.2" - -"@smithy/util-defaults-mode-browser@^4.3.28": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.5.13.tgz#1ec65b576262d3cab83c18900b3407ae62481797" - integrity sha512-AECeHmLVWY/1+Uqk09vMEnDPOQ2xJjfp9zjimf1hlIgw4eNFIjEdjVsudArNPEcKZwiY93tjXYvPFT62Hy4uzw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-defaults-mode-node@^4.2.31": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.4.13.tgz#33907511f09af65ece7511a1d85bce8236959a7d" - integrity sha512-yU8YDw+IYzjUXQja2e1YLa7mHbH8AIa5p4HKk9ZYAEh2cz+gHJkXrcnLCvw1lQZ2kAId1c/ouX8x7foE6XiEVA== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-endpoints@^3.2.8": - version "3.6.13" - resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.6.13.tgz#40918f6373bb17d898a758308f42296bc38a43a6" - integrity sha512-NW6AQUVQvPNMZH11AE8nniwMaBblZtJlrW0SALxMtpETidMkOw5+UMS/k1NwVSrVDdmRDSQtlTYgXuCPB4vTLA== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-middleware@^4.2.8": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.4.13.tgz#b70f5aa9f78244a95a9cd209792c32a2976818dd" - integrity sha512-sl1N7i1M8jcdCA/dFv4KKYhKT5a6Wp2nBDyCAXn1Y89tx3Q/pNQFhFZ+lL0zi9dDBUCm5QoE8fRYoRbbHaVdfw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-retry@^4.2.8": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.5.13.tgz#f4094334f07cc3a3aed6eb4076cb3ddcf219e535" - integrity sha512-tqb3HEKrJh08xyWhOQ4fnoEMS/Or3PW9dJHfoybUhuW1SPXmZ3wipKSCcjgt4kjmxCMzNNtiYh1UXWKtIRveOw== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-stream@^4.5.10": - version "4.7.13" - resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.7.13.tgz#66ecd0cfe66aca52bffeda533c718eb1b429f6db" - integrity sha512-0X7zxaUaaymQSwoo8iI7vDFtzPJ3UI/1FWMfnY7+UbLHTMob3BuUEfvITQC2AIeP0KyFddY5PZ41z6nCnYgIsQ== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-utf8@^2.0.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz#dd96d7640363259924a214313c3cf16e7dd329c5" - integrity sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A== - dependencies: - "@smithy/util-buffer-from" "^2.2.0" - tslib "^2.6.2" - -"@smithy/util-utf8@^4.2.0": - version "4.4.13" - resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.4.13.tgz#ef59c0c45e8b7a3975f4882a020eb5cfae6bb625" - integrity sha512-ufwrU+Oys/iRPwjT4NKQG879t79iTQAs1bEFZycv9SFKHaKqNgkGqE3odv2PFImv/l/HRBgo3y3Pw2lzZF4/wQ== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - -"@smithy/util-waiter@^4.2.8": - version "4.5.13" - resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.5.13.tgz#83c300db7d23f78fca42e5ca42e3a421a6876267" - integrity sha512-EPiggLDNkq0VtXCKOojXID/s6oGiLvo5yRfKII9SB+8zEwP0xnpnsCJRpbi50z7zyF+Irr+N6ooG2JwRkEkGsg== - dependencies: - "@smithy/core" "^3.29.8" - tslib "^2.6.2" - "@speed-highlight/core@^1.2.7": version "1.2.17" resolved "https://registry.yarnpkg.com/@speed-highlight/core/-/core-1.2.17.tgz#ce4e49dc56a00f675c5e16f0c98a24bb5aec1c57" @@ -3848,11 +2573,6 @@ dependencies: "@babel/runtime" "^7.12.5" -"@tsconfig/node18@^1.0.3": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tsconfig/node18/-/node18-1.0.3.tgz#b14aed11bda116950a57fb5d223dd050e47f4fe1" - integrity sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ== - "@tweenjs/tween.js@~23.1.3": version "23.1.3" resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz" @@ -3898,14 +2618,6 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/node-fetch@^2.6.4": - version "2.6.13" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.13.tgz#e0c9b7b5edbdb1b50ce32c127e85e880872d56ee" - integrity sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw== - dependencies: - "@types/node" "*" - form-data "^4.0.4" - "@types/node@*", "@types/node@^22.7.8": version "22.19.3" resolved "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz" @@ -3913,13 +2625,6 @@ dependencies: undici-types "~6.21.0" -"@types/node@^18.11.18": - version "18.19.130" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.130.tgz#da4c6324793a79defb7a62cba3947ec5add00d59" - integrity sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg== - dependencies: - undici-types "~5.26.4" - "@types/offscreencanvas@^2019.6.4": version "2019.7.3" resolved "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz" @@ -4318,31 +3023,11 @@ resolved "https://registry.npmjs.org/@webgpu/types/-/types-0.1.68.tgz" integrity sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA== -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - -accepts@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" - integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== - dependencies: - mime-types "^3.0.0" - negotiator "^1.0.0" - acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.5.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" - integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== - acorn@^8.9.0: version "8.16.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" @@ -4353,13 +3038,6 @@ agent-base@^7.1.0, agent-base@^7.1.2: resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== -agentkeepalive@^4.2.1: - version "4.6.0" - resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" - integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== - dependencies: - humanize-ms "^1.2.1" - aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" @@ -4395,7 +3073,7 @@ ansi-regex@^5.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1, ansi-regex@^6.2.2: +ansi-regex@^6.0.1: version "6.2.2" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== @@ -4412,7 +3090,7 @@ ansi-styles@^5.0.0: resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== -ansi-styles@^6.1.0, ansi-styles@^6.2.1: +ansi-styles@^6.1.0: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== @@ -4541,11 +3219,6 @@ array-includes@^3.1.6, array-includes@^3.1.8, array-includes@^3.1.9: is-string "^1.1.1" math-intrinsics "^1.1.0" -array-timsort@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" - integrity sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ== - array.prototype.findlast@^1.2.5: version "1.2.5" resolved "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz" @@ -4693,11 +3366,6 @@ aws4@^1.8.0: resolved "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -aws4fetch@^1.0.20: - version "1.0.20" - resolved "https://registry.yarnpkg.com/aws4fetch/-/aws4fetch-1.0.20.tgz#090d6c65e32c6df645dd5e5acf04cc56da575cbe" - integrity sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g== - axe-core@^4.10.0: version "4.11.0" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz" @@ -4722,11 +3390,6 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -balanced-match@^4.0.2: - version "4.0.4" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" - integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== - base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz" @@ -4776,26 +3439,6 @@ bn.js@^4.0.0: resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz" integrity sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw== -body-parser@^2.2.1: - version "2.3.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.3.0.tgz#6d8662f4d8c336028b8ac9aa24251b0ca64ba437" - integrity sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw== - dependencies: - bytes "^3.1.2" - content-type "^2.0.0" - debug "^4.4.3" - http-errors "^2.0.1" - iconv-lite "^0.7.2" - on-finished "^2.4.1" - qs "^6.15.2" - raw-body "^3.0.2" - type-is "^2.1.0" - -bowser@^2.11.0: - version "2.14.1" - resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" - integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== - brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" @@ -4811,13 +3454,6 @@ brace-expansion@^2.0.1: dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.8.tgz#135ad0d8d808eb18eb5e0ec9a21f3a0b92ef18cf" - integrity sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg== - dependencies: - balanced-match "^4.0.2" - braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" @@ -4835,11 +3471,6 @@ buffer-equal-constant-time@^1.0.1: resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - buffer@^5.7.1: version "5.7.1" resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" @@ -4856,11 +3487,6 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -bytes@^3.1.2, bytes@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - cac@^6.7.14: version "6.7.14" resolved "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz" @@ -4941,11 +3567,6 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.6.2: - version "5.6.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" - integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== - check-error@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz" @@ -4983,11 +3604,6 @@ ci-info@^4.1.0: resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz" integrity sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA== -ci-info@^4.2.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c" - integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== - class-variance-authority@^0.7.0: version "0.7.1" resolved "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz" @@ -5034,28 +3650,6 @@ client-only@0.0.1: resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== -cliui@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" - integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== - dependencies: - string-width "^7.2.0" - strip-ansi "^7.1.0" - wrap-ansi "^9.0.0" - -cloudflare@^4.4.1: - version "4.5.0" - resolved "https://registry.yarnpkg.com/cloudflare/-/cloudflare-4.5.0.tgz#dd7521270382663823288fd4f36d4d804f7a4663" - integrity sha512-fPcbPKx4zF45jBvQ0z7PCdgejVAPBBCZxwqk1k7krQNfpM07Cfj97/Q6wBzvYqlWXx/zt1S9+m8vnfCe06umbQ== - dependencies: - "@types/node" "^18.11.18" - "@types/node-fetch" "^2.6.4" - abort-controller "^3.0.0" - agentkeepalive "^4.2.1" - form-data-encoder "1.7.2" - formdata-node "^4.3.2" - node-fetch "^2.6.7" - clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" @@ -5095,16 +3689,6 @@ commander@7: resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== -commander@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" - integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== - -commander@^2.20.0: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@^4.0.0: version "4.1.1" resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz" @@ -5115,14 +3699,6 @@ commander@^6.2.1: resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -comment-json@^4.5.1: - version "4.6.2" - resolved "https://registry.yarnpkg.com/comment-json/-/comment-json-4.6.2.tgz#235d8a908e211855b0068248a794afddb87670af" - integrity sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w== - dependencies: - array-timsort "^1.0.3" - esprima "^4.0.1" - common-tags@^1.8.0: version "1.8.2" resolved "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz" @@ -5138,31 +3714,6 @@ concat-map@0.0.1: resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -content-disposition@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.1.0.tgz#f3db789c752d45564cc7e9e1e0b31790d4a38e17" - integrity sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g== - -content-type@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" - integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== - -content-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" - integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== - -cookie-signature@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" - integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== - -cookie@^0.7.1: - version "0.7.2" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - cookie@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" @@ -5639,11 +4190,6 @@ delayed-stream@~1.0.0: resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -depd@^2.0.0, depd@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - dequal@2.0.3, dequal@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" @@ -5705,11 +4251,6 @@ dom-accessibility-api@^0.6.3: resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz" integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== -dotenv@^16.4.5: - version "16.6.1" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" - integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== - dotenv@^17.2.1: version "17.2.3" resolved "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz" @@ -5729,7 +4270,7 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexer@^0.1.2, duplexer@~0.1.1: +duplexer@~0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -5754,26 +4295,6 @@ ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer "^5.0.1" -eciesjs@^0.4.10: - version "0.4.18" - resolved "https://registry.yarnpkg.com/eciesjs/-/eciesjs-0.4.18.tgz#5f1a40b2171a1fdd97854bdfc31620bb8a1dbbbe" - integrity sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ== - dependencies: - "@ecies/ciphers" "^0.2.5" - "@noble/ciphers" "^1.3.0" - "@noble/curves" "^1.9.7" - "@noble/hashes" "^1.8.0" - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -emoji-regex@^10.3.0: - version "10.6.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" - integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== - emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" @@ -5784,11 +4305,6 @@ emoji-regex@^9.2.2: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -encodeurl@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" - integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== - end-of-stream@^1.1.0: version "1.4.5" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz" @@ -5796,7 +4312,7 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" -enquirer@^2.3.6, enquirer@^2.4.1: +enquirer@^2.3.6: version "2.4.1" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz" integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== @@ -5944,37 +4460,6 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild@0.25.4: - version "0.25.4" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.4.tgz#bb9a16334d4ef2c33c7301a924b8b863351a0854" - integrity sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q== - optionalDependencies: - "@esbuild/aix-ppc64" "0.25.4" - "@esbuild/android-arm" "0.25.4" - "@esbuild/android-arm64" "0.25.4" - "@esbuild/android-x64" "0.25.4" - "@esbuild/darwin-arm64" "0.25.4" - "@esbuild/darwin-x64" "0.25.4" - "@esbuild/freebsd-arm64" "0.25.4" - "@esbuild/freebsd-x64" "0.25.4" - "@esbuild/linux-arm" "0.25.4" - "@esbuild/linux-arm64" "0.25.4" - "@esbuild/linux-ia32" "0.25.4" - "@esbuild/linux-loong64" "0.25.4" - "@esbuild/linux-mips64el" "0.25.4" - "@esbuild/linux-ppc64" "0.25.4" - "@esbuild/linux-riscv64" "0.25.4" - "@esbuild/linux-s390x" "0.25.4" - "@esbuild/linux-x64" "0.25.4" - "@esbuild/netbsd-arm64" "0.25.4" - "@esbuild/netbsd-x64" "0.25.4" - "@esbuild/openbsd-arm64" "0.25.4" - "@esbuild/openbsd-x64" "0.25.4" - "@esbuild/sunos-x64" "0.25.4" - "@esbuild/win32-arm64" "0.25.4" - "@esbuild/win32-ia32" "0.25.4" - "@esbuild/win32-x64" "0.25.4" - esbuild@0.28.1: version "0.28.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" @@ -6071,16 +4556,6 @@ esbuild@~0.28.0: "@esbuild/win32-ia32" "0.28.2" "@esbuild/win32-x64" "0.28.2" -escalade@^3.1.1: - version "3.2.0" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" - integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== - -escape-html@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" @@ -6282,11 +4757,6 @@ espree@^9.6.0, espree@^9.6.1: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.4.1" -esprima@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - esquery@^1.4.2: version "1.7.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" @@ -6318,11 +4788,6 @@ esutils@^2.0.2: resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - event-stream@=3.3.4: version "3.3.4" resolved "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz" @@ -6336,11 +4801,6 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - eventemitter2@6.4.7: version "6.4.7" resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz" @@ -6361,7 +4821,7 @@ execa@4.1.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" -execa@5.1.1, execa@^5.1.1: +execa@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -6388,40 +4848,6 @@ expect-type@^1.2.1: resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz" integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== -express@^5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" - integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== - dependencies: - accepts "^2.0.0" - body-parser "^2.2.1" - content-disposition "^1.0.0" - content-type "^1.0.5" - cookie "^0.7.1" - cookie-signature "^1.2.1" - debug "^4.4.0" - depd "^2.0.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - finalhandler "^2.1.0" - fresh "^2.0.0" - http-errors "^2.0.0" - merge-descriptors "^2.0.0" - mime-types "^3.0.0" - on-finished "^2.4.1" - once "^1.4.0" - parseurl "^1.3.3" - proxy-addr "^2.0.7" - qs "^6.14.0" - range-parser "^1.2.1" - router "^2.2.0" - send "^1.1.0" - serve-static "^2.2.0" - statuses "^2.0.1" - type-is "^2.0.1" - vary "^1.1.2" - extend@~3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" @@ -6495,7 +4921,7 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" -fdir@^6.2.0, fdir@^6.5.0: +fdir@^6.5.0: version "6.5.0" resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== @@ -6536,18 +4962,6 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" - integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== - dependencies: - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - on-finished "^2.4.1" - parseurl "^1.3.3" - statuses "^2.0.1" - find-up@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" @@ -6582,7 +4996,7 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" -foreground-child@^3.1.0, foreground-child@^3.3.1: +foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -6595,11 +5009,6 @@ forever-agent@~0.6.1: resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw== -form-data-encoder@1.7.2: - version "1.7.2" - resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" - integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== - form-data@^4.0.4, form-data@~4.0.4: version "4.0.5" resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz" @@ -6618,19 +5027,6 @@ formatly@^0.3.0: dependencies: fd-package-json "^2.0.0" -formdata-node@^4.3.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" - integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== - dependencies: - node-domexception "1.0.0" - web-streams-polyfill "4.0.0-beta.3" - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - framer-motion@^12.0.5: version "12.23.26" resolved "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz" @@ -6640,11 +5036,6 @@ framer-motion@^12.0.5: motion-utils "^12.23.6" tslib "^2.4.0" -fresh@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" - integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== - from@~0: version "0.1.7" resolved "https://registry.npmjs.org/from/-/from-0.1.7.tgz" @@ -6697,16 +5088,6 @@ generator-function@^2.0.0: resolved "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz" integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== -get-caller-file@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" - integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== - -get-east-asian-width@^1.0.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9" - integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA== - get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" @@ -6801,16 +5182,6 @@ glob@10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@9.3.5: - version "9.3.5" - resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" - integrity sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q== - dependencies: - fs.realpath "^1.0.0" - minimatch "^8.0.2" - minipass "^4.2.4" - path-scurry "^1.6.1" - glob@^10.4.1: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" @@ -6823,18 +5194,6 @@ glob@^10.4.1: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-12.0.0.tgz#4f75198719ab443ea433fdc023629b853532a443" - integrity sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw== - dependencies: - foreground-child "^3.3.1" - jackspeak "^4.1.1" - minimatch "^10.1.1" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^2.0.0" - glob@^7.1.3: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" @@ -6889,13 +5248,6 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== -gzip-size@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" - integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== - dependencies: - duplexer "^0.1.2" - has-bigints@^1.0.2: version "1.1.0" resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz" @@ -6972,17 +5324,6 @@ html2canvas@^1.4.1: css-line-break "^2.1.0" text-segmentation "^1.0.3" -http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" - integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== - dependencies: - depd "~2.0.0" - inherits "~2.0.4" - setprototypeof "~1.2.0" - statuses "~2.0.2" - toidentifier "~1.0.1" - http-proxy-agent@^7.0.2: version "7.0.2" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz" @@ -7023,13 +5364,6 @@ human-signals@^2.1.0: resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -humanize-ms@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" - integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== - dependencies: - ms "^2.0.0" - iconv-lite@0.6, iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" @@ -7037,19 +5371,12 @@ iconv-lite@0.6, iconv-lite@0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@^0.7.2, iconv-lite@~0.7.0: - version "0.7.3" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" - integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore@^5.2.0, ignore@^5.3.0: +ignore@^5.2.0: version "5.3.2" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== @@ -7095,7 +5422,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.1, inherits@~2.0.4: +inherits@2, inherits@^2.0.1: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7119,11 +5446,6 @@ internal-slot@^1.1.0: resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: version "3.0.5" resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz" @@ -7283,11 +5605,6 @@ is-promise@^2.1.0: resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== -is-promise@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" - integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== - is-regex@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz" @@ -7379,11 +5696,6 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -isexe@^3.1.1: - version "3.1.5" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.5.tgz#42e368f68d5e10dadfee4fda7b550bc2d8892dc9" - integrity sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w== - isstream@~0.1.2: version "0.1.2" resolved "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz" @@ -7457,13 +5769,6 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" -jackspeak@^4.1.1: - version "4.2.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483" - integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg== - dependencies: - "@isaacs/cliui" "^9.0.0" - jiti@^1.21.7: version "1.21.7" resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz" @@ -7778,11 +6083,6 @@ lru-cache@^10.2.0, lru-cache@^10.4.3: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== -lru-cache@^11.0.0: - version "11.5.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" - integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== - lucide-react@^0.394.0: version "0.394.0" resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.394.0.tgz" @@ -7836,16 +6136,6 @@ math-intrinsics@^1.1.0: resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== -media-typer@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" - integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== - -merge-descriptors@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" - integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz" @@ -7879,11 +6169,6 @@ mime-db@1.52.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-db@^1.54.0: - version "1.54.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== - mime-types@^2.1.12, mime-types@~2.1.19: version "2.1.35" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" @@ -7891,13 +6176,6 @@ mime-types@^2.1.12, mime-types@~2.1.19: dependencies: mime-db "1.52.0" -mime-types@^3.0.0, mime-types@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" - integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== - dependencies: - mime-db "^1.54.0" - mimic-fn@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" @@ -7925,13 +6203,6 @@ minimalistic-assert@^1.0.0: resolved "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@^10.1.1: - version "10.2.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - dependencies: - brace-expansion "^5.0.5" - minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" @@ -7939,13 +6210,6 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^8.0.2: - version "8.0.7" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.7.tgz#954766e22da88a3e0a17ad93b58c15c9d8a579de" - integrity sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg== - dependencies: - brace-expansion "^2.0.1" - minimatch@^9.0.1, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.5" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" @@ -7958,28 +6222,11 @@ minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -minipass@^4.2.4: - version "4.2.8" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.8.tgz#f0010f64393ecfc1d1ccb5f582bcaf45f48e1a3a" - integrity sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ== - "minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: version "7.1.3" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== -mkdirp@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -mnemonist@0.38.3: - version "0.38.3" - resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.3.tgz#35ec79c1c1f4357cfda2fe264659c2775ccd7d9d" - integrity sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw== - dependencies: - obliterator "^1.6.1" - motion-dom@^12.23.23: version "12.23.23" resolved "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz" @@ -7992,7 +6239,7 @@ motion-utils@^12.23.6: resolved "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz" integrity sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ== -ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -8021,11 +6268,6 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== - next@^15.5.13: version "15.5.13" resolved "https://registry.yarnpkg.com/next/-/next-15.5.13.tgz#92a5d5a209318ce16497e36de3533259fe5bbd9d" @@ -8052,18 +6294,6 @@ node-addon-api@^7.0.0: resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -node-domexception@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" - integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== - -node-fetch@^2.6.7: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz" @@ -8101,11 +6331,6 @@ object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object-treeify@1.1.33: - version "1.1.33" - resolved "https://registry.yarnpkg.com/object-treeify/-/object-treeify-1.1.33.tgz#f06fece986830a3cba78ddd32d4c11d1f76cdf40" - integrity sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A== - object.assign@^4.1.4, object.assign@^4.1.7: version "4.1.7" resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" @@ -8157,18 +6382,6 @@ object.values@^1.1.6, object.values@^1.2.1: define-properties "^1.2.1" es-object-atoms "^1.0.0" -obliterator@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-1.6.1.tgz#dea03e8ab821f6c4d96a299e17aef6a3af994ef3" - integrity sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig== - -on-finished@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - once@^1.3.0, once@^1.3.1, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -8275,11 +6488,6 @@ parse5@^7.2.1: dependencies: entities "^6.0.0" -parseurl@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" @@ -8300,7 +6508,7 @@ path-parse@^1.0.7: resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^1.10.1, path-scurry@^1.11.1, path-scurry@^1.6.1: +path-scurry@^1.10.1, path-scurry@^1.11.1: version "1.11.1" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== @@ -8308,24 +6516,11 @@ path-scurry@^1.10.1, path-scurry@^1.11.1, path-scurry@^1.6.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-scurry@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" - integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== - dependencies: - lru-cache "^11.0.0" - minipass "^7.1.2" - -path-to-regexp@6.3.0, path-to-regexp@^6.3.0: +path-to-regexp@6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== -path-to-regexp@^8.0.0: - version "8.4.2" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" - integrity sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA== - pathe@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz" @@ -8596,14 +6791,6 @@ prop-types@^15.6.0, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" -proxy-addr@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz" @@ -8634,14 +6821,6 @@ punycode@^2.1.0, punycode@^2.3.1: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@^6.14.0, qs@^6.15.2: - version "6.15.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" - integrity sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A== - dependencies: - es-define-property "^1.0.1" - side-channel "^1.1.1" - qs@~6.14.1: version "6.14.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" @@ -8654,21 +6833,6 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -range-parser@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" - integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== - -raw-body@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" - integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== - dependencies: - bytes "~3.1.2" - http-errors "~2.0.1" - iconv-lite "~0.7.0" - unpipe "~1.0.0" - rc-cascader@~3.34.0: version "3.34.0" resolved "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz" @@ -9258,17 +7422,6 @@ rollup@^4.43.0: "@rollup/rollup-win32-x64-msvc" "4.54.0" fsevents "~2.3.2" -router@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" - integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== - dependencies: - debug "^4.4.0" - depd "^2.0.0" - is-promise "^4.0.0" - parseurl "^1.3.3" - path-to-regexp "^8.0.0" - rrweb-cssom@^0.8.0: version "0.8.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz" @@ -9388,33 +7541,6 @@ semver@^7.8.4: resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== -send@^1.1.0, send@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" - integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== - dependencies: - debug "^4.4.3" - encodeurl "^2.0.0" - escape-html "^1.0.3" - etag "^1.8.1" - fresh "^2.0.0" - http-errors "^2.0.1" - mime-types "^3.0.2" - ms "^2.1.3" - on-finished "^2.4.1" - range-parser "^1.2.1" - statuses "^2.0.2" - -serve-static@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" - integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== - dependencies: - encodeurl "^2.0.0" - escape-html "^1.0.3" - parseurl "^1.3.3" - send "^1.2.0" - server-only@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/server-only/-/server-only-0.0.1.tgz#0f366bb6afb618c37c9255a314535dc412cd1c9e" @@ -9451,11 +7577,6 @@ set-proto@^1.0.0: es-errors "^1.3.0" es-object-atoms "^1.0.0" -setprototypeof@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - sharp@0.35.2: version "0.35.2" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.35.2.tgz#4437256b654557e2b48279d1e67f086e7872d06a" @@ -9545,14 +7666,6 @@ side-channel-list@^1.0.0: es-errors "^1.3.0" object-inspect "^1.13.3" -side-channel-list@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" - integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.4" - side-channel-map@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz" @@ -9585,17 +7698,6 @@ side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -side-channel@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" - integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.4" - side-channel-list "^1.0.1" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - siginfo@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz" @@ -9639,19 +7741,6 @@ smol-toml@^1.5.2: resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map-support@~0.5.20: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - split2@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" @@ -9724,11 +7813,6 @@ stats.js@^0.17.0: resolved "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz" integrity sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw== -statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" - integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== - std-env@^3.9.0: version "3.10.0" resolved "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz" @@ -9781,15 +7865,6 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string-width@^7.0.0, string-width@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" - integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== - dependencies: - emoji-regex "^10.3.0" - get-east-asian-width "^1.0.0" - strip-ansi "^7.1.0" - string.prototype.includes@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz" @@ -9879,13 +7954,6 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" -strip-ansi@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" - integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== - dependencies: - ansi-regex "^6.2.2" - strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz" @@ -10027,16 +8095,6 @@ tailwindcss@^3.4.1: resolve "^1.22.8" sucrase "^3.35.0" -terser@5.16.9: - version "5.16.9" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.9.tgz#7a28cb178e330c484369886f2afd623d9847495f" - integrity sha512-HPa/FdTB9XGI2H1/keLFZHxl6WNvAI4YalHGtDQTlMnJcoqSab1UwL4l1hGEhs6/GmLHBZIg/YgB++jcbzoOEg== - dependencies: - "@jridgewell/source-map" "^0.3.2" - acorn "^8.5.0" - commander "^2.20.0" - source-map-support "~0.5.20" - test-exclude@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" @@ -10176,11 +8234,6 @@ toggle-selection@^1.0.6: resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz" integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ== -toidentifier@~1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - tough-cookie@^5.0.0, tough-cookie@^5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz" @@ -10195,11 +8248,6 @@ tr46@^5.1.0: dependencies: punycode "^2.3.1" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - tree-kill@1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz" @@ -10235,11 +8283,6 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -ts-tqdm@^0.8.6: - version "0.8.6" - resolved "https://registry.yarnpkg.com/ts-tqdm/-/ts-tqdm-0.8.6.tgz#bf0f1b3b373f38c5ccc8d0453a8f2462f805ed03" - integrity sha512-3X3M1PZcHtgQbnwizL+xU8CAgbYbeLHrrDwL9xxcZZrV5J+e7loJm1XrXozHjSkl44J0Zg0SgA8rXbh83kCkcQ== - tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz" @@ -10250,7 +8293,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.0: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -10305,15 +8348,6 @@ type-fest@^0.8.0: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@^2.0.1, type-is@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.1.0.tgz#71d1a7053293582e16ac9f3ebaf1ab9aa49e5570" - integrity sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA== - dependencies: - content-type "^2.0.0" - media-typer "^1.1.0" - mime-types "^3.0.0" - typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz" @@ -10374,11 +8408,6 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== - undici-types@~6.21.0: version "6.21.0" resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz" @@ -10401,11 +8430,6 @@ universalify@^2.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== -unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - unrs-resolver@^1.6.2: version "1.11.1" resolved "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz" @@ -10445,11 +8469,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -urlpattern-polyfill@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz#1b2517e614136c73ba32948d5e7a3a063cba8e74" - integrity sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw== - use-callback-ref@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz" @@ -10492,11 +8511,6 @@ uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -vary@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - verror@1.10.0: version "1.10.0" resolved "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz" @@ -10594,11 +8608,6 @@ web-push@^3.6.7: jws "^4.0.0" minimist "^1.2.5" -web-streams-polyfill@4.0.0-beta.3: - version "4.0.0-beta.3" - resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" - integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== - web-vitals@^4.2.4: version "4.2.4" resolved "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz" @@ -10614,11 +8623,6 @@ webgl-sdf-generator@1.1.1: resolved "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz" integrity sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA== -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz" @@ -10644,14 +8648,6 @@ whatwg-url@^14.0.0, whatwg-url@^14.1.1: tr46 "^5.1.0" webidl-conversions "^7.0.0" -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz" @@ -10712,13 +8708,6 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -which@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" - integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== - dependencies: - isexe "^3.1.1" - why-is-node-running@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz" @@ -10795,15 +8784,6 @@ wrap-ansi@^8.1.0: string-width "^5.0.1" strip-ansi "^7.0.1" -wrap-ansi@^9.0.0: - version "9.0.2" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" - integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== - dependencies: - ansi-styles "^6.2.1" - string-width "^7.0.0" - strip-ansi "^7.1.0" - wrappy@1: version "1.0.2" resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" @@ -10834,33 +8814,6 @@ xtend@^4.0.0: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== -y18n@^5.0.5: - version "5.0.8" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" - integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== - -yaml@^2.8.1: - version "2.9.0" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" - integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== - -yargs-parser@^22.0.0: - version "22.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" - integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== - -yargs@^18.0.0: - version "18.0.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" - integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== - dependencies: - cliui "^9.0.1" - escalade "^3.1.1" - get-caller-file "^2.0.5" - string-width "^7.2.0" - y18n "^5.0.5" - yargs-parser "^22.0.0" - yauzl@^2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz"