From 4dfcb62a76dfdc9240811c620e158cd1744bc209 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Sat, 19 Sep 2026 15:59:35 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=E2=98=81=EF=B8=8F?= =?UTF-8?q?=20=E2=86=9D=20[SSC-30]:=20Unblock=20Cloudflare=20deploys=20by?= =?UTF-8?q?=20dropping=20Free-plan=20cpu=5Fms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- wrangler.jsonc | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/wrangler.jsonc b/wrangler.jsonc index 472e5143..6011aeef 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -22,13 +22,14 @@ "observability": { "enabled": true }, - // OpenNext SSR cannot finish within the Workers Free 10ms CPU cap - // (Cloudflare Error 1102 / exceededCpu on starsailors.space, 2026-09-18). - // Requires Workers Paid. 60s covers cold start + Clerk + a few PocketBase - // round-trips without taking the 5-minute maximum. - "limits": { - "cpu_ms": 60000 - }, + // 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 } + // `wrangler deploy --env staging` — a constant preview target. Runtime // secrets (POCKETBASE_URL, CLERK_SECRET_KEY, ...) are synced from the same // repo secrets as production on purpose — this shares the one ecosystem @@ -66,9 +67,6 @@ }, "observability": { "enabled": true - }, - "limits": { - "cpu_ms": 60000 } } } From 2c024653f7916ff9c066731864ad29adb2e33501 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Mon, 21 Sep 2026 09:51:04 +0800 Subject: [PATCH 02/21] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=F0=9F=94=90=20?= =?UTF-8?q?=E2=86=9D=20[SSC-31=20SSC-33]:=20Stage=20Vercel=20hosting=20and?= =?UTF-8?q?=20guarded=20playtests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 ++ .github/workflows/cutover-vercel-domain.yml | 83 +++++++++++++ .../workflows/deploy-cloudflare-staging.yml | 21 +--- .github/workflows/deploy-vercel.yml | 74 ++++++++++++ src/app/api/test/staging/playtest/route.ts | 114 ++++++++++++++++++ src/lib/server/stagingPlaytestAuth.test.ts | 39 ++++++ src/lib/server/stagingPlaytestAuth.ts | 56 +++++++++ 7 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/cutover-vercel-domain.yml create mode 100644 .github/workflows/deploy-vercel.yml create mode 100644 src/app/api/test/staging/playtest/route.ts create mode 100644 src/lib/server/stagingPlaytestAuth.test.ts create mode 100644 src/lib/server/stagingPlaytestAuth.ts diff --git a/.env.example b/.env.example index 2b31b942..76ffc270 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,13 @@ NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/game # Signing secret for the /api/webhooks/clerk endpoint (Clerk dashboard > Webhooks > your endpoint) CLERK_WEBHOOK_SIGNING_SECRET= +# Staging-only browser playtest account lifecycle (SSC-33). Keep disabled in +# every other environment. The secret is operator-held and must never be +# committed or exposed to the browser. +STAGING_PLAYTEST_AUTH_ENABLED=false +STAGING_PLAYTEST_HOST=staging.starsailors.space +STAGING_PLAYTEST_AUTH_SECRET= + # Pocketbase Configuration # Local: http://localhost:8095; production Compose: http://pocketbase:8090 POCKETBASE_URL=http://localhost:8095 diff --git a/.github/workflows/cutover-vercel-domain.yml b/.github/workflows/cutover-vercel-domain.yml new file mode 100644 index 00000000..1d72c5a0 --- /dev/null +++ b/.github/workflows/cutover-vercel-domain.yml @@ -0,0 +1,83 @@ +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" >/dev/null 2>&1; then + npx --yes vercel@latest domains add "$domain" "$VERCEL_PROJECT_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-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index d0b47e78..05c36a3d 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -1,4 +1,4 @@ -name: Deploy to Cloudflare Workers (Staging) +name: Deploy to Cloudflare Workers (Legacy Staging) # Constant preview target for signal-k/client: same OpenNext Cloudflare # adapter as production, deployed to the wrangler.jsonc `env.staging` @@ -11,22 +11,9 @@ name: Deploy to Cloudflare Workers (Staging) # Clerk instance as production (not a separate staging backend) so # accounts/data stay one ecosystem; only the frontend Worker differs. on: - push: - branches: [staging] - paths: - - "src/**" - - "public/**" - - "package.json" - - "yarn.lock" - - "next.config.*" - - "wrangler.jsonc" - - "open-next.config.*" - - "tsconfig.json" - - "postcss.config.*" - - "tailwind.config.*" - - "components.json" - - ".github/workflows/deploy-cloudflare-staging.yml" - - ".github/workflows/open-next-worker.yml" + # 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: {} concurrency: diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml new file mode 100644 index 00000000..de046f5e --- /dev/null +++ b/.github/workflows/deploy-vercel.yml @@ -0,0 +1,74 @@ +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 }} + 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: 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 + + - name: Pull staging preview configuration + if: github.ref_name == 'staging' + run: npx --yes vercel@latest pull --yes --environment=preview --git-branch=staging + + - name: Build Vercel output + run: npx --yes vercel@latest build + + - name: Deploy production + if: github.ref_name == 'main' + run: | + deployment_url="$(npx --yes vercel@latest deploy --prebuilt --prod)" + 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)" + npx --yes vercel@latest alias set "$deployment_url" staging.starsailors.space + echo "deployment_url=$deployment_url" >> "$GITHUB_STEP_SUMMARY" + echo "staging_url=https://staging.starsailors.space" >> "$GITHUB_STEP_SUMMARY" diff --git a/src/app/api/test/staging/playtest/route.ts b/src/app/api/test/staging/playtest/route.ts new file mode 100644 index 00000000..f6d9bbcd --- /dev/null +++ b/src/app/api/test/staging/playtest/route.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; + +import { clerkClient } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { createPocketbaseAdminClient } from "@/lib/pocketbase/adminClient"; +import { + authorizesStagingPlaytest, + isStagingPlaytestMetadata, + STAGING_PLAYTEST_MARKER, +} from "@/lib/server/stagingPlaytestAuth"; + +export const dynamic = "force-dynamic"; + +type PlaytestUser = { + id: string; + privateMetadata: unknown; +}; + +// Every selector is an account identifier written by this client. The list is +// deliberately explicit rather than deleting an arbitrary PocketBase user or +// relying on unverified cascade rules in the shared staging/prod database. +const ACCOUNT_RECORDS: ReadonlyArray = [ + ["ss_comments", "author"], + ["votes", "userId"], + ["user_mineral_inventory", "userId"], + ["survey_rewards", "userId"], + ["researched", "userId"], + ["missions", "userId"], + ["defensive_probes", "userId"], + ["nps_surveys", "userId"], + ["uploads", "author"], + ["zoo", "author"], + ["zoo", "owner"], + ["linked_anomalies", "author"], + ["routes", "author"], + ["inventory", "owner"], + ["mineral_deposits", "owner"], + ["ss_classifications", "author"], + ["referrals", "referreeId"], + ["ss_hub_state", "userId"], + ["profiles", "userId"], +]; + +function notFound() { + // Do not reveal whether the guard, a user id, or a secret was wrong. + return NextResponse.json({ error: "Not found" }, { status: 404 }); +} + +async function deletePocketBaseRecords(userId: string): Promise { + const pb = await createPocketbaseAdminClient(); + let deleted = 0; + + for (const [collection, field] of ACCOUNT_RECORDS) { + const records = await pb.collection(collection).getFullList({ + filter: pb.filter(`${field} = {:userId}`, { userId }), + fields: "id", + }); + await Promise.all(records.map((record) => pb.collection(collection).delete(record.id))); + deleted += records.length; + } + + return deleted; +} + +export async function POST(request: Request) { + if (!authorizesStagingPlaytest(request)) return notFound(); + + const id = randomUUID(); + const client = await clerkClient(); + const user = await client.users.createUser({ + // RFC 2606's .test domain guarantees this address cannot be delivered. + // Clerk does not need it verified because the server mints the short-lived + // ticket below; normal sign-up verification remains untouched. + emailAddress: [`ssc-playtest-${id}@example.test`], + externalId: `ssc-playtest-${id}`, + privateMetadata: { + starSailorsPlaytest: { + marker: STAGING_PLAYTEST_MARKER, + createdAt: new Date().toISOString(), + }, + }, + skipPasswordChecks: true, + skipPasswordRequirement: true, + }); + const token = await client.signInTokens.createSignInToken({ + userId: user.id, + expiresInSeconds: 60, + }); + + console.info("[staging-playtest] provisioned", { userId: user.id }); + return NextResponse.json({ userId: user.id, ticket: token.token }); +} + +export async function DELETE(request: Request) { + if (!authorizesStagingPlaytest(request)) return notFound(); + + const body = (await request.json().catch(() => null)) as { userId?: unknown } | null; + if (!body || typeof body.userId !== "string" || !body.userId) return notFound(); + + const client = await clerkClient(); + const user = (await client.users.getUser(body.userId).catch(() => null)) as PlaytestUser | null; + if (!user || !isStagingPlaytestMetadata(user.privateMetadata)) return notFound(); + + const deletedRecords = await deletePocketBaseRecords(user.id); + await client.users.deleteUser(user.id); + const stillExists = await client.users.getUser(user.id).then(() => true).catch(() => false); + if (stillExists) { + return NextResponse.json({ error: "Could not verify test-account deletion" }, { status: 502 }); + } + + console.info("[staging-playtest] cleaned", { userId: user.id, deletedRecords }); + return NextResponse.json({ deleted: true, deletedRecords }); +} diff --git a/src/lib/server/stagingPlaytestAuth.test.ts b/src/lib/server/stagingPlaytestAuth.test.ts new file mode 100644 index 00000000..58b7782f --- /dev/null +++ b/src/lib/server/stagingPlaytestAuth.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + STAGING_PLAYTEST_MARKER, + authorizesStagingPlaytest, + isStagingPlaytestMetadata, + type StagingPlaytestConfig, +} from "./stagingPlaytestAuth"; + +const config: StagingPlaytestConfig = { + enabled: true, + host: "staging.starsailors.space", + secret: "a-long-staging-only-secret", +}; + +function request(host: string, secret?: string | null) { + return new Request("https://example.test/api/test/staging/playtest", { + headers: { + host, + ...(secret ? { "x-staging-playtest-secret": secret } : {}), + }, + }); +} + +describe("staging playtest auth guard", () => { + it("requires the enabled flag, exact staging host, and operator secret", () => { + expect(authorizesStagingPlaytest(request("staging.starsailors.space", config.secret), config)).toBe(true); + expect(authorizesStagingPlaytest(request("starsailors.space", config.secret), config)).toBe(false); + expect(authorizesStagingPlaytest(request("staging.starsailors.space"), config)).toBe(false); + expect(authorizesStagingPlaytest(request("staging.starsailors.space", "wrong"), config)).toBe(false); + expect(authorizesStagingPlaytest(request("staging.starsailors.space", config.secret), { ...config, enabled: false })).toBe(false); + }); + + it("recognizes only the server-owned playtest marker", () => { + expect(isStagingPlaytestMetadata({ starSailorsPlaytest: { marker: STAGING_PLAYTEST_MARKER } })).toBe(true); + expect(isStagingPlaytestMetadata({ starSailorsPlaytest: { marker: "other" } })).toBe(false); + expect(isStagingPlaytestMetadata({})).toBe(false); + }); +}); 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 + ); +} From 97a456d413458bb7b2464d323cba29fc95c234f7 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Mon, 21 Sep 2026 09:57:35 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=F0=9F=8C=90=F0=9F=A7=AD=20=E2=86=9D=20[S?= =?UTF-8?q?SC-31]:=20Target=20Vercel=20deployments=20to=20project=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cutover-vercel-domain.yml | 4 ++-- .github/workflows/deploy-vercel.yml | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cutover-vercel-domain.yml b/.github/workflows/cutover-vercel-domain.yml index 1d72c5a0..eb828f61 100644 --- a/.github/workflows/cutover-vercel-domain.yml +++ b/.github/workflows/cutover-vercel-domain.yml @@ -46,8 +46,8 @@ jobs: # 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" >/dev/null 2>&1; then - npx --yes vercel@latest domains add "$domain" "$VERCEL_PROJECT_ID" + 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 \ diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml index de046f5e..d2323649 100644 --- a/.github/workflows/deploy-vercel.yml +++ b/.github/workflows/deploy-vercel.yml @@ -50,25 +50,25 @@ jobs: - name: Pull production configuration if: github.ref_name == 'main' - run: npx --yes vercel@latest pull --yes --environment=production + 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 + 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 + 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)" + 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)" - npx --yes vercel@latest alias set "$deployment_url" staging.starsailors.space + deployment_url="$(npx --yes vercel@latest deploy --prebuilt --scope "$VERCEL_ORG_ID")" + 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" From 9ad9fc13966dc7d467f861cf2813a61d1e0cce7e Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Mon, 21 Sep 2026 10:04:00 +0800 Subject: [PATCH 04/21] =?UTF-8?q?=E2=98=81=EF=B8=8F=F0=9F=A7=AD=20?= =?UTF-8?q?=E2=86=9D=20[SSC-31]:=20Cut=20staging=20over=20after=20Vercel?= =?UTF-8?q?=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-vercel.yml | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml index d2323649..aaac78ff 100644 --- a/.github/workflows/deploy-vercel.yml +++ b/.github/workflows/deploy-vercel.yml @@ -28,6 +28,8 @@ jobs: 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 @@ -45,6 +47,16 @@ jobs: 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 @@ -69,6 +81,46 @@ jobs: 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 + 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" '.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" From 1d8341b40f95a5c39835f264914dd0ef1f15a464 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Mon, 21 Sep 2026 10:09:05 +0800 Subject: [PATCH 05/21] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=F0=9F=93=A1=20?= =?UTF-8?q?=E2=86=9D=20[SSC-31]:=20Complete=20staged=20domain=20verificati?= =?UTF-8?q?on=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy-vercel.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-vercel.yml b/.github/workflows/deploy-vercel.yml index aaac78ff..935f6aeb 100644 --- a/.github/workflows/deploy-vercel.yml +++ b/.github/workflows/deploy-vercel.yml @@ -86,7 +86,14 @@ jobs: # 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 - npx --yes vercel@latest domains add "$domain" "$VERCEL_PROJECT_ID" --scope "$VERCEL_ORG_ID" + 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 \ From b5c4fd2027eec50f0dd4a7763eddf1f822f7e290 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 08:42:43 +0000 Subject: [PATCH 06/21] =?UTF-8?q?=F0=9F=A7=B1=E2=98=81=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-31=20SSC-38]:=20Serve=20Star=20Sailors=20as=20?= =?UTF-8?q?a=20static=20shell=20plus=20a=20thin=20Worker=20API=20on=20Work?= =?UTF-8?q?ers=20Free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace OpenNext SSR with a Cloudflare-native build that fits the Free-plan 10 ms CPU budget: - Pages are a static Next.js export (`yarn cf:build` -> out/) served by Workers Static Assets; page hits never invoke the Worker. - workers/app runs the existing src/app/api route handlers directly with next/server, next/cache and @clerk/nextjs/server shims, mounts the SSC-35 /api/v1 API, proxies /ingest to PostHog, and maps dynamic pages to their exported placeholder HTML. Clerk session JWTs (cookie or bearer) are verified locally; cookie mutations require an allowed Origin. - Server actions become /api/actions/[name] with fetch-based client stubs; Clerk's internal server actions are stubbed for the export build only. - Dynamic pages export one placeholder and read params from the URL; middleware redirects move to the browser. - Remove OpenNext, Vercel deploy/cutover workflows and the standalone API Worker deploy; staging deploys on push to `staging` again. - CI builds the export and bundles the Worker on every PR. - SSC-38: per-response x-ssc-subrequests, measure-budget script and a dispatch workflow using wrangler tail for CPU, plus the cutover, rollback and smoke-test runbook with local workerd results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013Leu9r82ABhUyGAoFMEd8Y --- .github/workflows/ci.yml | 27 + ...t-worker.yml => cloudflare-app-worker.yml} | 16 +- .github/workflows/cutover-vercel-domain.yml | 83 - .github/workflows/deploy-api-worker.yml | 92 - .../workflows/deploy-cloudflare-staging.yml | 28 +- .github/workflows/deploy-cloudflare.yml | 12 +- .github/workflows/deploy-vercel.yml | 133 -- .../workflows/measure-cloudflare-budget.yml | 66 + .gitignore | 3 +- Makefile | 2 +- docs/runbooks/cloudflare-cutover.md | 178 ++ next.config.mjs | 114 +- open-next.config.ts | 3 - package.json | 7 +- public/_headers | 9 + scripts/cloudflare/build-static.mjs | 66 + scripts/cloudflare/generate-routes.mjs | 99 + scripts/cloudflare/measure-budget.mjs | 237 ++ .../_components/LandingSignedInRedirect.tsx | 19 + src/app/actions/classification-actions.ts | 90 +- src/app/actions/deploy-actions.ts | 346 +-- src/app/actions/gameplay.ts | 147 +- src/app/actions/mineral-actions.ts | 96 +- src/app/actions/profile-actions.ts | 215 +- src/app/actions/social-actions.ts | 93 +- src/app/api/actions/[name]/route.ts | 34 + src/app/classify/[id]/ClassifyPageClient.tsx | 10 + src/app/classify/[id]/page.tsx | 19 +- .../extraction/[id]/ExtractionPageClient.tsx | 147 ++ src/app/extraction/[id]/page.tsx | 151 +- src/app/next/[id]/LegacyClassifyRedirect.tsx | 9 + src/app/next/[id]/page.tsx | 13 +- src/app/page.tsx | 7 +- src/app/planets/[id]/PlanetRedirect.tsx | 9 + src/app/planets/[id]/page.tsx | 13 +- .../planets/clouds/[id]/CloudPageClient.tsx | 10 + src/app/planets/clouds/[id]/page.tsx | 17 +- .../planets/edit/[id]/EditPlanetClient.tsx | 143 ++ src/app/planets/edit/[id]/page.tsx | 146 +- src/app/posts/[id]/SinglePostClient.tsx | 159 ++ src/app/posts/[id]/page.tsx | 162 +- .../surveyor/[id]/SurveyorPostClient.tsx | 135 ++ src/app/posts/surveyor/[id]/page.tsx | 136 +- .../[project]/BalloonProjectClient.tsx | 107 + .../[id]/[mission]/BalloonClassifyClient.tsx | 115 + .../balloon/[project]/[id]/[mission]/page.tsx | 117 +- src/app/structures/balloon/[project]/page.tsx | 111 +- .../[id]/[mission]/SeiscamProjectClient.tsx | 78 + .../seiscam/[project]/[id]/[mission]/page.tsx | 80 +- .../[project]/TelescopeProjectClient.tsx | 94 + .../[mission]/TelescopeClassifyPageClient.tsx | 7 +- .../[mission]/TelescopeClassifyPageNoSsr.tsx | 14 + .../[project]/[id]/[mission]/page.tsx | 19 +- .../structures/telescope/[project]/page.tsx | 99 +- src/app/viewports/satellite/deploy/page.tsx | 1 - .../DailyMinorPlanet/DailyMinorPlanet.tsx | 4 +- .../routing/DynamicRouteRedirect.tsx | 18 + src/lib/actions/callAction.ts | 27 + .../clerk-keyless-actions.static.js | 8 + .../cloudflare/clerk-server-actions.static.js | 4 + src/lib/routing/staticParams.test.ts | 31 + src/lib/routing/staticParams.ts | 35 + src/lib/routing/useRouteParams.ts | 23 + src/server/actions/classification-actions.ts | 80 + src/server/actions/deploy-actions.ts | 333 +++ src/server/actions/gameplay.ts | 137 ++ src/server/actions/mineral-actions.ts | 87 + src/server/actions/profile-actions.ts | 204 ++ src/server/actions/registry.ts | 42 + src/server/actions/social-actions.ts | 85 + workers/api/src/index.ts | 3 + workers/api/wrangler.jsonc | 26 - workers/app/src/app.test.ts | 228 ++ workers/app/src/context.ts | 20 + workers/app/src/generated/api-routes.ts | 142 ++ workers/app/src/generated/page-routes.ts | 21 + workers/app/src/index.ts | 235 ++ workers/app/src/match.ts | 33 + workers/app/src/routeTypes.ts | 6 + workers/app/src/shims/clerk-nextjs-server.ts | 35 + workers/app/src/shims/next-cache.ts | 8 + workers/app/src/shims/next-server.ts | 31 + wrangler.jsonc | 41 +- yarn.lock | 2075 +---------------- 84 files changed, 3993 insertions(+), 4372 deletions(-) rename .github/workflows/{open-next-worker.yml => cloudflare-app-worker.yml} (87%) delete mode 100644 .github/workflows/cutover-vercel-domain.yml delete mode 100644 .github/workflows/deploy-api-worker.yml delete mode 100644 .github/workflows/deploy-vercel.yml create mode 100644 .github/workflows/measure-cloudflare-budget.yml create mode 100644 docs/runbooks/cloudflare-cutover.md delete mode 100644 open-next.config.ts create mode 100644 public/_headers create mode 100644 scripts/cloudflare/build-static.mjs create mode 100644 scripts/cloudflare/generate-routes.mjs create mode 100644 scripts/cloudflare/measure-budget.mjs create mode 100644 src/app/(landing)/_components/LandingSignedInRedirect.tsx create mode 100644 src/app/api/actions/[name]/route.ts create mode 100644 src/app/classify/[id]/ClassifyPageClient.tsx create mode 100644 src/app/extraction/[id]/ExtractionPageClient.tsx create mode 100644 src/app/next/[id]/LegacyClassifyRedirect.tsx create mode 100644 src/app/planets/[id]/PlanetRedirect.tsx create mode 100644 src/app/planets/clouds/[id]/CloudPageClient.tsx create mode 100644 src/app/planets/edit/[id]/EditPlanetClient.tsx create mode 100644 src/app/posts/[id]/SinglePostClient.tsx create mode 100644 src/app/posts/surveyor/[id]/SurveyorPostClient.tsx create mode 100644 src/app/structures/balloon/[project]/BalloonProjectClient.tsx create mode 100644 src/app/structures/balloon/[project]/[id]/[mission]/BalloonClassifyClient.tsx create mode 100644 src/app/structures/seiscam/[project]/[id]/[mission]/SeiscamProjectClient.tsx create mode 100644 src/app/structures/telescope/[project]/TelescopeProjectClient.tsx create mode 100644 src/app/structures/telescope/[project]/[id]/[mission]/TelescopeClassifyPageNoSsr.tsx create mode 100644 src/components/routing/DynamicRouteRedirect.tsx create mode 100644 src/lib/actions/callAction.ts create mode 100644 src/lib/cloudflare/clerk-keyless-actions.static.js create mode 100644 src/lib/cloudflare/clerk-server-actions.static.js create mode 100644 src/lib/routing/staticParams.test.ts create mode 100644 src/lib/routing/staticParams.ts create mode 100644 src/lib/routing/useRouteParams.ts create mode 100644 src/server/actions/classification-actions.ts create mode 100644 src/server/actions/deploy-actions.ts create mode 100644 src/server/actions/gameplay.ts create mode 100644 src/server/actions/mineral-actions.ts create mode 100644 src/server/actions/profile-actions.ts create mode 100644 src/server/actions/registry.ts create mode 100644 src/server/actions/social-actions.ts delete mode 100644 workers/api/wrangler.jsonc create mode 100644 workers/app/src/app.test.ts create mode 100644 workers/app/src/context.ts create mode 100644 workers/app/src/generated/api-routes.ts create mode 100644 workers/app/src/generated/page-routes.ts create mode 100644 workers/app/src/index.ts create mode 100644 workers/app/src/match.ts create mode 100644 workers/app/src/routeTypes.ts create mode 100644 workers/app/src/shims/clerk-nextjs-server.ts create mode 100644 workers/app/src/shims/next-cache.ts create mode 100644 workers/app/src/shims/next-server.ts 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 87% rename from .github/workflows/open-next-worker.yml rename to .github/workflows/cloudflare-app-worker.yml index 7a77fcd1..210ed9a6 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 }} @@ -82,7 +88,9 @@ 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. + command: ${{ inputs.wrangler_command }} --var NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_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 05c36a3d..0fa0c291 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,9 +11,23 @@ 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. + push: + branches: [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: @@ -22,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 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..3b0d86ae --- /dev/null +++ b/.github/workflows/measure-cloudflare-budget.yml @@ -0,0 +1,66 @@ +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_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/.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/docs/runbooks/cloudflare-cutover.md b/docs/runbooks/cloudflare-cutover.md new file mode 100644 index 00000000..4d217d0e --- /dev/null +++ b/docs/runbooks/cloudflare-cutover.md @@ -0,0 +1,178 @@ +# Cloudflare-native hosting: cutover, rollback and smoke tests + +Tickets: SSC-31 (architecture), 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 | +| Anything else | `404.html`, status 404 | Yes | + +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. + +## 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` | + +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** 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. +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/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 | + +Error rate 0% on every route. The Worker bundle is 1.76 MB (326 kB gzip), +within the 3 MB Free limit. 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/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..7c081d94 --- /dev/null +++ b/scripts/cloudflare/measure-budget.mjs @@ -0,0 +1,237 @@ +#!/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 }, + { 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 }, + { 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; + } + } +} + +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"} |`, + ), + "", + `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, 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)); +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/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/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/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 2946378d..1ae6f310 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"; @@ -22,7 +22,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/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/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/workers/api/src/index.ts b/workers/api/src/index.ts index 2d11a9fa..e2f874c2 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 { getProfileByUserId, type PocketbaseEnv, type Profile } from "./pocketbase"; 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..d903ce70 --- /dev/null +++ b/workers/app/src/app.test.ts @@ -0,0 +1,228 @@ +import { execFileSync } from "node:child_process"; +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")); + +import { resetJwksCache } from "../../api/src/jwt"; +import { requestContext } from "./context"; +import { 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; + +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", +}; + +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(); + 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("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/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..dc90329c --- /dev/null +++ b/workers/app/src/generated/api-routes.ts @@ -0,0 +1,142 @@ +// 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/test/auth/login/route"; +import * as r39 from "../../../../src/app/api/test/staging/playtest/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/webhooks/clerk/route"; +import * as r60 from "../../../../src/app/api/actions/[name]/route"; +import * as r61 from "../../../../src/app/api/auto-notify-discoveries/route"; +import * as r62 from "../../../../src/app/api/community-activity/route"; +import * as r63 from "../../../../src/app/api/notify-my-discoveries/route"; +import * as r64 from "../../../../src/app/api/send-test-notification/route"; +import * as r65 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/test/auth/login", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"login"}], module: r38 as unknown as RouteModule }, + { pattern: "/api/test/staging/playtest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"staging"},{"kind":"literal","value":"playtest"}], 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/webhooks/clerk", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"webhooks"},{"kind":"literal","value":"clerk"}], module: r59 as unknown as RouteModule }, + { pattern: "/api/actions/[name]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"actions"},{"kind":"param","name":"name"}], module: r60 as unknown as RouteModule }, + { pattern: "/api/auto-notify-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auto-notify-discoveries"}], module: r61 as unknown as RouteModule }, + { pattern: "/api/community-activity", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"community-activity"}], module: r62 as unknown as RouteModule }, + { pattern: "/api/notify-my-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"notify-my-discoveries"}], module: r63 as unknown as RouteModule }, + { pattern: "/api/send-test-notification", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"send-test-notification"}], module: r64 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: r65 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..640d1af6 --- /dev/null +++ b/workers/app/src/index.ts @@ -0,0 +1,235 @@ +// 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. +import { AsyncLocalStorage } from "node:async_hooks"; + +import { handle as handleApiV1 } from "../../api/src/index"; +import { AuthError, verifyClerkJwt, type ClerkClaims } from "../../api/src/jwt"; +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; +}; + +export type Deps = { fetchImpl?: typeof fetch; now?: number }; + +// 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, () => 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); + } +} + +export default { + fetch: (request: Request, env: Env) => handle(request, env), +}; 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..6762d495 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 }, @@ -26,9 +42,8 @@ // 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 +61,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 +70,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", 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" From 12fe6dec7b3f5d3f4a3c0d2e458b17dd88fb5fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 10:25:53 +0000 Subject: [PATCH 07/21] =?UTF-8?q?=F0=9F=93=AC=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-37=20SSC-39=20SSC-38]:=20Precompute=20public?= =?UTF-8?q?=20data=20into=20KV=20on=20a=20cron=20and=20move=20notification?= =?UTF-8?q?s=20and=20analytics=20onto=20Cloudflare=20Queues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSC-37: a cron trigger (every 10 min) computes the landing stats, sunspot leaderboard, community activity and hub top profiles, and publishes them to Workers KV as one versioned bundle. Public requests read only the snapshot (0 subrequests) and report fresh/stale/missing through the response body, headers, /api/public/status and the UI. A failed producer keeps its last good data and records the error. SSC-39: push notifications, server-side PostHog events and the daily discovery-reminder fan-out run from a Cloudflare Queue. Handlers are idempotent (PostHog uuid, KV receipts, per-day ids, retries only to failed endpoints) and retry with backoff. Exhausted or invalid jobs are parked in KV and can be replayed through /api/internal/jobs. Web Push now runs on WebCrypto (verified against the RFC 8291 vector), and the notification endpoints that were open now require a session or the operator token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013Leu9r82ABhUyGAoFMEd8Y --- .github/workflows/cloudflare-app-worker.yml | 13 +- .github/workflows/sync-cloudflare-secrets.yml | 16 + docs/runbooks/cloudflare-cutover.md | 124 +++++++- scripts/cloudflare/ensure-resources.mjs | 108 +++++++ scripts/cloudflare/measure-budget.mjs | 45 ++- .../(landing)/_components/LandingStats.tsx | 96 ++++-- src/app/api/auto-notify-discoveries/route.ts | 249 +-------------- src/app/api/community-activity/route.ts | 42 ++- .../gameplay/leaderboards/sunspots/route.ts | 65 +--- src/app/api/internal/jobs/route.ts | 34 +++ .../api/internal/snapshots/refresh/route.ts | 15 + src/app/api/notify-my-discoveries/route.ts | 280 +++-------------- src/app/api/public/snapshots/[name]/route.ts | 24 ++ src/app/api/public/status/route.ts | 26 ++ src/app/api/send-test-notification/route.ts | 150 ++------- src/app/leaderboards/sunspots/page.tsx | 9 + .../deploy/Telescope/TelescopeActions.ts | 17 +- src/lib/server/hub-leaderboard.ts | 28 +- src/lib/server/internalAuth.ts | 20 ++ src/lib/server/posthog.ts | 40 +-- src/lib/server/stats.test.ts | 13 - src/lib/server/stats.ts | 50 --- src/server/jobs/consumer.ts | 96 ++++++ src/server/jobs/handlers.ts | 208 +++++++++++++ src/server/jobs/jobs.test.ts | 285 ++++++++++++++++++ src/server/jobs/queue.ts | 107 +++++++ src/server/jobs/types.ts | 59 ++++ src/server/jobs/webpush.test.ts | 90 ++++++ src/server/jobs/webpush.ts | 160 ++++++++++ src/server/platform.ts | 79 +++++ src/server/snapshots/compute.ts | 146 +++++++++ src/server/snapshots/store.test.ts | 145 +++++++++ src/server/snapshots/store.ts | 182 +++++++++++ src/server/testing/fakePocketBase.ts | 72 +++++ workers/app/src/app.test.ts | 155 +++++++++- workers/app/src/background.ts | 35 +++ workers/app/src/generated/api-routes.ts | 120 ++++---- workers/app/src/index.ts | 59 +++- wrangler.jsonc | 33 +- 39 files changed, 2603 insertions(+), 892 deletions(-) create mode 100644 scripts/cloudflare/ensure-resources.mjs create mode 100644 src/app/api/internal/jobs/route.ts create mode 100644 src/app/api/internal/snapshots/refresh/route.ts create mode 100644 src/app/api/public/snapshots/[name]/route.ts create mode 100644 src/app/api/public/status/route.ts create mode 100644 src/lib/server/internalAuth.ts delete mode 100644 src/lib/server/stats.test.ts delete mode 100644 src/lib/server/stats.ts create mode 100644 src/server/jobs/consumer.ts create mode 100644 src/server/jobs/handlers.ts create mode 100644 src/server/jobs/jobs.test.ts create mode 100644 src/server/jobs/queue.ts create mode 100644 src/server/jobs/types.ts create mode 100644 src/server/jobs/webpush.test.ts create mode 100644 src/server/jobs/webpush.ts create mode 100644 src/server/platform.ts create mode 100644 src/server/snapshots/compute.ts create mode 100644 src/server/snapshots/store.test.ts create mode 100644 src/server/snapshots/store.ts create mode 100644 src/server/testing/fakePocketBase.ts create mode 100644 workers/app/src/background.ts diff --git a/.github/workflows/cloudflare-app-worker.yml b/.github/workflows/cloudflare-app-worker.yml index 210ed9a6..e254cb71 100644 --- a/.github/workflows/cloudflare-app-worker.yml +++ b/.github/workflows/cloudflare-app-worker.yml @@ -77,6 +77,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: @@ -90,7 +100,8 @@ jobs: # top-level env correctly; it just logs a harmless warning. # The publishable key is public; the Worker derives the Clerk issuer # (JWKS) from it to verify session JWTs locally. - command: ${{ inputs.wrangler_command }} --var NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + # 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/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/docs/runbooks/cloudflare-cutover.md b/docs/runbooks/cloudflare-cutover.md index 4d217d0e..acd89204 100644 --- a/docs/runbooks/cloudflare-cutover.md +++ b/docs/runbooks/cloudflare-cutover.md @@ -1,6 +1,7 @@ # Cloudflare-native hosting: cutover, rollback and smoke tests -Tickets: SSC-31 (architecture), SSC-38 (Free-plan budget and cutover gate). +Tickets: SSC-31 (architecture), SSC-37 (precomputed public data), SSC-39 +(background jobs), SSC-38 (Free-plan budget and cutover gate). ## Architecture @@ -16,7 +17,11 @@ returned Error 1102. Production no longer runs a Next.js server at all: | `/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 `*/10 * * * *` | Recomputes the public snapshots 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 @@ -51,6 +56,89 @@ New dynamic pages must export `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 | @@ -61,6 +149,10 @@ placeholder in the export. | `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 @@ -81,8 +173,8 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... Preconditions: 1. The Cloudflare API token in `CLOUDFLARE_API_TOKEN` has **Account → Workers - Scripts: Edit** and **Zone (starsailors.space) → Workers Routes: Edit, DNS: - Edit**. The token used on 2026-09-21 returned 403 for Workers domains and + 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 @@ -96,7 +188,11 @@ Preconditions: Steps: 1. Merge to `staging`, or run "Deploy staging to Cloudflare Workers" manually. - Confirm the run is green. + 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. @@ -127,6 +223,11 @@ browser: 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 @@ -173,6 +274,19 @@ CPU time; that column comes from the deployed workflow run. | 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 `*/10 * * * *` | 4 snapshots published in one KV write | 9 | +| `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. Production and staging CPU / cold-start figures: +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/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/measure-budget.mjs b/scripts/cloudflare/measure-budget.mjs index 7c081d94..7bb261b0 100644 --- a/scripts/cloudflare/measure-budget.mjs +++ b/scripts/cloudflare/measure-budget.mjs @@ -48,12 +48,19 @@ const routes = [ { 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 }, ]; @@ -183,6 +190,30 @@ if (tail) { } } +// 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; @@ -226,12 +257,22 @@ const lines = [ : `| ${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, results }, null, 2)); +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)); +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/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/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 c2354f88..8b6a3c86 100644 --- a/src/app/api/community-activity/route.ts +++ b/src/app/api/community-activity/route.ts @@ -1,31 +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", - 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, - })) - ); - } 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/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/components/scenes/deploy/Telescope/TelescopeActions.ts b/src/components/scenes/deploy/Telescope/TelescopeActions.ts index 9cd4ecb1..18112748 100644 --- a/src/components/scenes/deploy/Telescope/TelescopeActions.ts +++ b/src/components/scenes/deploy/Telescope/TelescopeActions.ts @@ -171,17 +171,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/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/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/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..c2bb37d5 --- /dev/null +++ b/src/server/snapshots/store.ts @@ -0,0 +1,182 @@ +// 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>; + +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 every snapshot 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 } = {}): 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 = 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/app/src/app.test.ts b/workers/app/src/app.test.ts index d903ce70..100c6536 100644 --- a/workers/app/src/app.test.ts +++ b/workers/app/src/app.test.ts @@ -1,4 +1,5 @@ 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. @@ -11,10 +12,19 @@ const upstream = vi.hoisted(() => { // 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 { handle, issuerFromPublishableKey, resolveAuth, type Env } from "./index"; +import { createMemoryKV, type KVLike } from "@/src/server/platform"; +import { resetSnapshotMemo, SNAPSHOT_BUNDLE_KEY } from "@/src/server/snapshots/store"; +import { DISCOVERY_REMINDER_CRON, SNAPSHOT_CRON } from "./background"; +import worker, { handle, issuerFromPublishableKey, resolveAuth, type Env } from "./index"; import { auth } from "./shims/clerk-nextjs-server"; const ISS = "https://clerk.example.test"; @@ -40,6 +50,8 @@ const claims = (sub: string) => ({ sub, sid: `sess_${sub}`, iss: ISS, exp: NOW + let assetRequests: string[]; let fetchMock: ReturnType; +let kv: KVLike; +let queued: Array<{ body: any; delaySeconds?: number }>; const env: Env = { ASSETS: { @@ -54,6 +66,14 @@ const env: Env = { 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 = {}) => @@ -71,6 +91,11 @@ beforeAll(async () => { beforeEach(() => { resetJwksCache(); + resetSnapshotMemo(); + kv = createMemoryKV(); + queued = []; + pocketbase.data = {}; + vi.unstubAllEnvs(); assetRequests = []; fetchMock = vi.fn(async () => new Response(JSON.stringify({ keys: [jwk] }))); }); @@ -221,6 +246,134 @@ describe("budget instrumentation", () => { }); }); +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 the snapshots from the cron trigger", 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(() => {}); + await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: Date.now() }, env, { waitUntil: () => {} }); + const 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 }); + expect(JSON.parse(log.mock.calls[0][0])).toMatchObject({ invocation: `cron ${SNAPSHOT_CRON}`, task: "snapshots" }); + }); +}); + +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..42b734a9 --- /dev/null +++ b/workers/app/src/background.ts @@ -0,0 +1,35 @@ +// 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 } from "@/src/server/snapshots/store"; + +export const SNAPSHOT_CRON = "*/10 * * * *"; +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 report = await refreshSnapshots({ now: scheduledTime }); + 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/generated/api-routes.ts b/workers/app/src/generated/api-routes.ts index dc90329c..1df648ca 100644 --- a/workers/app/src/generated/api-routes.ts +++ b/workers/app/src/generated/api-routes.ts @@ -38,34 +38,38 @@ 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/test/auth/login/route"; -import * as r39 from "../../../../src/app/api/test/staging/playtest/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/webhooks/clerk/route"; -import * as r60 from "../../../../src/app/api/actions/[name]/route"; -import * as r61 from "../../../../src/app/api/auto-notify-discoveries/route"; -import * as r62 from "../../../../src/app/api/community-activity/route"; -import * as r63 from "../../../../src/app/api/notify-my-discoveries/route"; -import * as r64 from "../../../../src/app/api/send-test-notification/route"; -import * as r65 from "../../../../src/app/api/storage/[bucket]/[...path]/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/test/staging/playtest/route"; +import * as r41 from "../../../../src/app/api/zoodex/upload-image/gpt/route"; +import * as r42 from "../../../../src/app/api/auth/complete-guest-conversion/route"; +import * as r43 from "../../../../src/app/api/auth/guest/route"; +import * as r44 from "../../../../src/app/api/auth/session/route"; +import * as r45 from "../../../../src/app/api/gameplay/achievements/route"; +import * as r46 from "../../../../src/app/api/gameplay/active-planet/route"; +import * as r47 from "../../../../src/app/api/gameplay/anomalies/route"; +import * as r48 from "../../../../src/app/api/gameplay/classifications/[id]/route"; +import * as r49 from "../../../../src/app/api/gameplay/classifications/route"; +import * as r50 from "../../../../src/app/api/gameplay/extraction/[id]/route"; +import * as r51 from "../../../../src/app/api/gameplay/inventory/route"; +import * as r52 from "../../../../src/app/api/gameplay/linked-anomalies/route"; +import * as r53 from "../../../../src/app/api/gameplay/locations/route"; +import * as r54 from "../../../../src/app/api/gameplay/milestones/route"; +import * as r55 from "../../../../src/app/api/gameplay/mineral-deposits/route"; +import * as r56 from "../../../../src/app/api/gameplay/nps/route"; +import * as r57 from "../../../../src/app/api/gameplay/page-data/route"; +import * as r58 from "../../../../src/app/api/gameplay/planet-type/route"; +import * as r59 from "../../../../src/app/api/gameplay/solar/route"; +import * as r60 from "../../../../src/app/api/internal/jobs/route"; +import * as r61 from "../../../../src/app/api/public/snapshots/[name]/route"; +import * as r62 from "../../../../src/app/api/public/status/route"; +import * as r63 from "../../../../src/app/api/webhooks/clerk/route"; +import * as r64 from "../../../../src/app/api/actions/[name]/route"; +import * as r65 from "../../../../src/app/api/auto-notify-discoveries/route"; +import * as r66 from "../../../../src/app/api/community-activity/route"; +import * as r67 from "../../../../src/app/api/notify-my-discoveries/route"; +import * as r68 from "../../../../src/app/api/send-test-notification/route"; +import * as r69 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 }, @@ -106,34 +110,38 @@ export const apiRoutes: Array<{ pattern: string; segments: RouteSegment[]; modul { 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/test/auth/login", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"login"}], module: r38 as unknown as RouteModule }, - { pattern: "/api/test/staging/playtest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"staging"},{"kind":"literal","value":"playtest"}], 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/webhooks/clerk", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"webhooks"},{"kind":"literal","value":"clerk"}], module: r59 as unknown as RouteModule }, - { pattern: "/api/actions/[name]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"actions"},{"kind":"param","name":"name"}], module: r60 as unknown as RouteModule }, - { pattern: "/api/auto-notify-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auto-notify-discoveries"}], module: r61 as unknown as RouteModule }, - { pattern: "/api/community-activity", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"community-activity"}], module: r62 as unknown as RouteModule }, - { pattern: "/api/notify-my-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"notify-my-discoveries"}], module: r63 as unknown as RouteModule }, - { pattern: "/api/send-test-notification", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"send-test-notification"}], module: r64 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: r65 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/test/staging/playtest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"test"},{"kind":"literal","value":"staging"},{"kind":"literal","value":"playtest"}], module: r40 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: r41 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: r42 as unknown as RouteModule }, + { pattern: "/api/auth/guest", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"guest"}], module: r43 as unknown as RouteModule }, + { pattern: "/api/auth/session", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auth"},{"kind":"literal","value":"session"}], module: r44 as unknown as RouteModule }, + { pattern: "/api/gameplay/achievements", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"achievements"}], module: r45 as unknown as RouteModule }, + { pattern: "/api/gameplay/active-planet", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"active-planet"}], module: r46 as unknown as RouteModule }, + { pattern: "/api/gameplay/anomalies", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"anomalies"}], module: r47 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: r48 as unknown as RouteModule }, + { pattern: "/api/gameplay/classifications", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"classifications"}], module: r49 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: r50 as unknown as RouteModule }, + { pattern: "/api/gameplay/inventory", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"inventory"}], module: r51 as unknown as RouteModule }, + { pattern: "/api/gameplay/linked-anomalies", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"linked-anomalies"}], module: r52 as unknown as RouteModule }, + { pattern: "/api/gameplay/locations", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"locations"}], module: r53 as unknown as RouteModule }, + { pattern: "/api/gameplay/milestones", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"milestones"}], module: r54 as unknown as RouteModule }, + { pattern: "/api/gameplay/mineral-deposits", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"mineral-deposits"}], module: r55 as unknown as RouteModule }, + { pattern: "/api/gameplay/nps", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"nps"}], module: r56 as unknown as RouteModule }, + { pattern: "/api/gameplay/page-data", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"page-data"}], module: r57 as unknown as RouteModule }, + { pattern: "/api/gameplay/planet-type", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"planet-type"}], module: r58 as unknown as RouteModule }, + { pattern: "/api/gameplay/solar", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"gameplay"},{"kind":"literal","value":"solar"}], module: r59 as unknown as RouteModule }, + { pattern: "/api/internal/jobs", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"internal"},{"kind":"literal","value":"jobs"}], module: r60 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: r61 as unknown as RouteModule }, + { pattern: "/api/public/status", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"public"},{"kind":"literal","value":"status"}], module: r62 as unknown as RouteModule }, + { pattern: "/api/webhooks/clerk", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"webhooks"},{"kind":"literal","value":"clerk"}], module: r63 as unknown as RouteModule }, + { pattern: "/api/actions/[name]", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"actions"},{"kind":"param","name":"name"}], module: r64 as unknown as RouteModule }, + { pattern: "/api/auto-notify-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"auto-notify-discoveries"}], module: r65 as unknown as RouteModule }, + { pattern: "/api/community-activity", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"community-activity"}], module: r66 as unknown as RouteModule }, + { pattern: "/api/notify-my-discoveries", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"notify-my-discoveries"}], module: r67 as unknown as RouteModule }, + { pattern: "/api/send-test-notification", segments: [{"kind":"literal","value":"api"},{"kind":"literal","value":"send-test-notification"}], module: r68 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: r69 as unknown as RouteModule }, ]; export type RouteSegment = diff --git a/workers/app/src/index.ts b/workers/app/src/index.ts index 640d1af6..ee8d7853 100644 --- a/workers/app/src/index.ts +++ b/workers/app/src/index.ts @@ -9,10 +9,16 @@ // 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"; @@ -30,9 +36,39 @@ export type Env = { 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 }; }; -export type Deps = { fetchImpl?: typeof fetch; now?: number }; +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 @@ -201,7 +237,7 @@ async function servePage(request: Request, env: Env, url: URL): Promise { const counter = { subrequests: 0 }; - const response = await metrics.run(counter, () => route(request, env, deps)); + 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; @@ -230,6 +266,23 @@ async function route(request: Request, env: Env, deps: Deps): Promise } } +/** 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) => handle(request, env), + 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/wrangler.jsonc b/wrangler.jsonc index 6762d495..9fd15a2e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -38,6 +38,26 @@ "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 10 minutes, the + // unclassified-discovery reminder daily at 17:00 UTC. + "triggers": { "crons": ["*/10 * * * *", "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 @@ -90,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": ["*/10 * * * *"] } } } } From be150315767380a57d077f7d3723b74cded7fc67 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 16:45:32 +0300 Subject: [PATCH 08/21] =?UTF-8?q?=F0=9F=9A=A6=F0=9F=AA=90=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Build=20the=20staging=20Worker=20with=20the=20edge=20?= =?UTF-8?q?API=20flag=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production builds stay unchanged; only the staging deploy sets NEXT_PUBLIC_EDGE_API. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/open-next-worker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/open-next-worker.yml b/.github/workflows/open-next-worker.yml index 7a77fcd1..640e6228 100644 --- a/.github/workflows/open-next-worker.yml +++ b/.github/workflows/open-next-worker.yml @@ -66,6 +66,8 @@ jobs: NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: ${{ vars.NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL }} NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: ${{ vars.NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL }} NEXT_PUBLIC_POCKETBASE_URL: ${{ secrets.POCKETBASE_URL }} + # Edge API rollout: on for the staging build only until verified there. + NEXT_PUBLIC_EDGE_API: ${{ inputs.wrangler_env_args != '' && 'true' || '' }} NEXT_PUBLIC_POSTHOG_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} NEXT_PUBLIC_POSTHOG_HOST: https://us.posthog.com posthog_api_key: ${{ vars.NEXT_PUBLIC_POSTHOG_KEY }} From 2a05fda80a8a2a88ef3966b08aca8b74ed8e682d Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 16:51:56 +0300 Subject: [PATCH 09/21] =?UTF-8?q?=F0=9F=9A=80=F0=9F=94=AD=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Deploy=20the=20staging=20Worker=20on=20push=20to=20th?= =?UTF-8?q?e=20edge=20API=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index 05c36a3d..b38be4e1 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -15,6 +15,9 @@ on: # 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] concurrency: group: cloudflare-staging From 224620454b03effab4439feea76287667b8a372a Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:29:27 +0300 Subject: [PATCH 10/21] =?UTF-8?q?=F0=9F=94=AC=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Assert=20the=20signed-in=20game=20reads?= =?UTF-8?q?=20its=20research=20summary=20from=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/staging-playtest.yml | 2 +- cypress/e2e/staging-playtest.cy.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index ea048475..628c1549 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -7,7 +7,7 @@ name: Staging playtest lifecycle (SSC-33) # never stay enabled between runs. on: push: - branches: [ssc-33-playtest-ci] + branches: [ssc-33-playtest-ci, ssc-edge-api-staging] workflow_dispatch: {} concurrency: diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 589eae66..84814745 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,6 +17,7 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { + cy.intercept('GET', '/api/v1/research/summary*').as('edgeSummary') cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') @@ -24,6 +25,12 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') + // Edge API rollout: the signed-in game must read its research summary from the Worker. + cy.wait('@edgeSummary', { timeout: 30000 }).then(({ response }) => { + expect(response?.statusCode).to.eq(200) + expect(response?.body.authenticated).to.eq(true) + }) + cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') cy.contains('button', 'Hunt planets').click() cy.contains('button', 'Mark my plots').click() From 9cc5ccf91593271ff9d8b702aba91576c1cb1cfb Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:42:05 +0300 Subject: [PATCH 11/21] =?UTF-8?q?=F0=9F=94=97=F0=9F=AA=90=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Run=20the=20staging=20playtest=20after=20the=20edge-b?= =?UTF-8?q?ranch=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 7 +++++++ .github/workflows/staging-playtest.yml | 9 +++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index b38be4e1..5f678d50 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -30,3 +30,10 @@ jobs: with: wrangler_command: deploy --env staging wrangler_env_args: --env staging + + # 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' + uses: ./.github/workflows/staging-playtest.yml + secrets: inherit diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index 628c1549..4fc026c1 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -5,15 +5,12 @@ name: Staging playtest lifecycle (SSC-33) # throwaway Clerk account, and always removes the endpoint secrets afterwards. # Staging shares the production Clerk/PocketBase instance, so the endpoint must # never stay enabled between runs. +# On the edge branch it is called by the staging deploy workflow once the deploy +# finishes (so a deploy can never cancel it); dispatch it by hand otherwise. on: - push: - branches: [ssc-33-playtest-ci, ssc-edge-api-staging] + workflow_call: {} workflow_dispatch: {} -concurrency: - group: cloudflare-staging - cancel-in-progress: false - jobs: playtest: From f6526ffff525b275aad567036a3ca2e4ff163fa2 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:49:54 +0300 Subject: [PATCH 12/21] =?UTF-8?q?=F0=9F=95=B0=EF=B8=8F=F0=9F=94=AD=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Wait=20for=20Clerk=20before=20routing?= =?UTF-8?q?=20the=20first=20game=20read=20to=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/lib/gameplay/edgeApi.test.ts | 10 ++++++++++ src/lib/gameplay/edgeApi.ts | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/gameplay/edgeApi.test.ts b/src/lib/gameplay/edgeApi.test.ts index 81196b05..145675e1 100644 --- a/src/lib/gameplay/edgeApi.test.ts +++ b/src/lib/gameplay/edgeApi.test.ts @@ -42,6 +42,16 @@ describe("classificationsFetch", () => { expect(urls(fetchMock)).toEqual(["/api/gameplay/classifications"]); }); + it("waits for Clerk to finish loading before choosing a route", async () => { + const clerk = { loaded: false, session: { getToken: async () => "jwt" } }; + const { fetchMock, classificationsFetch } = await load({ edge: true, clerk }); + setTimeout(() => { + clerk.loaded = true; + }, 120); + await classificationsFetch("/api/gameplay/classifications?limit=1"); + expect(urls(fetchMock)).toEqual(["/api/v1/classifications?limit=1"]); + }); + it("falls back to Next for a failed read but never re-sends a write", async () => { const read = await load({ edge: true, clerk: ready }); read.fetchMock.mockImplementationOnce(async () => new Response("x", { status: 502 })); diff --git a/src/lib/gameplay/edgeApi.ts b/src/lib/gameplay/edgeApi.ts index 2e12e8c7..1c611919 100644 --- a/src/lib/gameplay/edgeApi.ts +++ b/src/lib/gameplay/edgeApi.ts @@ -12,10 +12,26 @@ type ClerkGlobal = { loaded?: boolean; session?: { getToken: () => Promise { + const deadline = Date.now() + CLERK_WAIT_MS; + for (;;) { + const clerk = (window as unknown as { Clerk?: ClerkGlobal }).Clerk; + if (clerk?.loaded) return clerk; + if (Date.now() >= deadline) return null; + await new Promise((resolve) => setTimeout(resolve, CLERK_POLL_MS)); + } +} + export async function edgeToken(): Promise { if (!EDGE_API_ENABLED || typeof window === "undefined") return null; - const clerk = (window as unknown as { Clerk?: ClerkGlobal }).Clerk; - if (!clerk?.loaded || !clerk.session) return null; + const clerk = await readyClerk(); + if (!clerk?.session) return null; try { return await clerk.session.getToken(); } catch { From de8dfa2f63a559b490ef83129782fd0348ca49b9 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 17:58:46 +0300 Subject: [PATCH 13/21] =?UTF-8?q?=F0=9F=94=AC=F0=9F=A7=AD=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Assert=20the=20game's=20on-load=20classifications=20r?= =?UTF-8?q?ead=20goes=20through=20the=20edge=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 84814745..871b702e 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,7 +17,7 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { - cy.intercept('GET', '/api/v1/research/summary*').as('edgeSummary') + cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') @@ -25,10 +25,10 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') - // Edge API rollout: the signed-in game must read its research summary from the Worker. - cy.wait('@edgeSummary', { timeout: 30000 }).then(({ response }) => { + // Edge API rollout: the signed-in game must read its classifications from the Worker. + cy.wait('@edgeClassifications', { timeout: 30000 }).then(({ request, response }) => { + expect(request.headers.authorization).to.match(/^Bearer /) expect(response?.statusCode).to.eq(200) - expect(response?.body.authenticated).to.eq(true) }) cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') From a1f08e31a053d55a843a88ef84d33f853bde7de5 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 18:05:02 +0300 Subject: [PATCH 14/21] =?UTF-8?q?=F0=9F=94=81=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Retry=20playtest=20provisioning=20while?= =?UTF-8?q?=20a=20fresh=20Worker=20secret=20propagates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 871b702e..5e1fceb6 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -18,7 +18,15 @@ if (enabled) { it('plays garden onboarding and builds an instrument with a fresh account', () => { cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') - cy.request({ method: 'POST', url: endpoint, headers }).then(({ body }) => { + // A freshly written Worker secret can take a few seconds to reach every edge, and the + // endpoint answers 404 until then. A 404 provisions nothing, so retrying is safe. + const provision = (attempt = 1): Cypress.Chainable> => + cy.request({ method: 'POST', url: endpoint, headers, failOnStatusCode: false }).then((res) => { + if (res.status === 404 && attempt < 8) return cy.wait(5000).then(() => provision(attempt + 1)) + expect(res.status).to.eq(200) + return cy.wrap(res) + }) + provision().then(({ body }) => { userId = body.userId expect(body.ticket).to.be.a('string') cy.visit(`/auth?__clerk_ticket=${encodeURIComponent(body.ticket)}`) From 7c64ae561a9605bb4457d957dacf7434887d988f Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 18:13:32 +0300 Subject: [PATCH 15/21] =?UTF-8?q?=F0=9F=94=91=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-35]:=20Prove=20the=20edge=20Worker=20accepts?= =?UTF-8?q?=20the=20signed-in=20Clerk=20token=20on=20staging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 5e1fceb6..526e92d0 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -17,7 +17,6 @@ if (enabled) { }) it('plays garden onboarding and builds an instrument with a fresh account', () => { - cy.intercept('GET', '/api/v1/classifications*').as('edgeClassifications') // A freshly written Worker secret can take a few seconds to reach every edge, and the // endpoint answers 404 until then. A 404 provisions nothing, so retrying is safe. const provision = (attempt = 1): Cypress.Chainable> => @@ -33,11 +32,21 @@ if (enabled) { }) cy.location('pathname', { timeout: 30000 }).should('eq', '/game') - // Edge API rollout: the signed-in game must read its classifications from the Worker. - cy.wait('@edgeClassifications', { timeout: 30000 }).then(({ request, response }) => { - expect(request.headers.authorization).to.match(/^Bearer /) - expect(response?.statusCode).to.eq(200) - }) + // Edge API rollout: the game reads most data lazily, so prove the Worker directly with the + // signed-in Clerk session's own token (issuer, authorized party and identity all checked). + cy.window({ timeout: 30000 }) + .should((win) => expect((win as any).Clerk?.session, 'Clerk session').to.exist) + .then((win) => cy.wrap((win as any).Clerk.session.getToken(), { timeout: 15000 })) + .then((token) => { + const auth = { authorization: `Bearer ${token}` } + cy.request({ url: '/api/v1/research/summary', headers: auth }).then(({ status, body }) => { + expect(status).to.eq(200) + expect(body.authenticated).to.eq(true) + }) + cy.request({ url: `/api/v1/classifications?author=${encodeURIComponent(userId as string)}&limit=5`, headers: auth }) + .its('status') + .should('eq', 200) + }) cy.get('[role="dialog"]', { timeout: 30000 }).contains('A fresh garden') cy.contains('button', 'Hunt planets').click() From 3fa0e245f608e4f7b0c41fecad701e8830bf3910 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 19:17:46 +0300 Subject: [PATCH 16/21] =?UTF-8?q?=F0=9F=A7=AA=F0=9F=94=91=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Report=20the=20token=20claims=20when=20the=20edge=20W?= =?UTF-8?q?orker=20rejects=20the=20staging=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/staging-playtest.cy.ts b/cypress/e2e/staging-playtest.cy.ts index 526e92d0..e79cdbca 100644 --- a/cypress/e2e/staging-playtest.cy.ts +++ b/cypress/e2e/staging-playtest.cy.ts @@ -39,8 +39,10 @@ if (enabled) { .then((win) => cy.wrap((win as any).Clerk.session.getToken(), { timeout: 15000 })) .then((token) => { const auth = { authorization: `Bearer ${token}` } - cy.request({ url: '/api/v1/research/summary', headers: auth }).then(({ status, body }) => { - expect(status).to.eq(200) + const claims = JSON.parse(atob(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))) + const seen = `azp=${claims.azp} iss=${claims.iss}` + cy.request({ url: '/api/v1/research/summary', headers: auth, failOnStatusCode: false }).then(({ status, body }) => { + expect(status, `research summary (${seen}) ${JSON.stringify(body)}`).to.eq(200) expect(body.authenticated).to.eq(true) }) cy.request({ url: `/api/v1/classifications?author=${encodeURIComponent(userId as string)}&limit=5`, headers: auth }) From 918155d39c3ff016782b18b52fbf36bd8db7d85c Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 19:27:10 +0300 Subject: [PATCH 17/21] =?UTF-8?q?=F0=9F=94=91=F0=9F=A7=A9=20=E2=86=9D=20[S?= =?UTF-8?q?SC-35]:=20Check=20the=20Clerk=20azp=20claim=20only=20when=20pre?= =?UTF-8?q?sent=20so=20ticket-minted=20sessions=20reach=20the=20edge=20Wor?= =?UTF-8?q?ker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token naming a foreign origin is still rejected (403); tokens without azp, such as sessions minted from a sign-in ticket, are accepted once issuer and signature verify, matching Clerk's own verifier. Co-Authored-By: Claude Sonnet 5.5 --- workers/api/src/api.test.ts | 2 ++ workers/api/src/jwt.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/workers/api/src/api.test.ts b/workers/api/src/api.test.ts index f3a06a04..4d7b81f9 100644 --- a/workers/api/src/api.test.ts +++ b/workers/api/src/api.test.ts @@ -91,6 +91,8 @@ describe("worker API auth boundary", () => { it("rejects wrong issuer and unauthorized party", async () => { expect((await call("/api/v1/me", await sign(key, { ...good, iss: "https://evil.test" }))).status).toBe(401); expect((await call("/api/v1/me", await sign(key, { ...good, azp: "https://evil.test" }))).status).toBe(403); + const { azp: _azp, ...noParty } = good; + expect((await call("/api/v1/me", await sign(key, noParty))).status).toBe(200); }); it("rejects cross-user access", async () => { diff --git a/workers/api/src/jwt.ts b/workers/api/src/jwt.ts index ac8809bd..d3f010bd 100644 --- a/workers/api/src/jwt.ts +++ b/workers/api/src/jwt.ts @@ -112,7 +112,9 @@ export async function verifyClerkJwt(token: string | null | undefined, opts: Ver if (claims.exp + skew < nowSec) throw new AuthError("expired"); if (claims.nbf !== undefined && claims.nbf - skew > nowSec) throw new AuthError("not_yet_valid"); if (claims.iss !== opts.issuer) throw new AuthError("bad_issuer"); - if (opts.authorizedParties?.length && (!claims.azp || !opts.authorizedParties.includes(claims.azp))) { + // Like Clerk's own verifier: `azp` is checked when present. Sessions minted from a + // sign-in ticket carry none; a token naming a foreign origin is still rejected. + if (opts.authorizedParties?.length && claims.azp && !opts.authorizedParties.includes(claims.azp)) { throw new AuthError("bad_party"); } return claims; From ce1b5de2c4eb34b6e8eb6a6e952af40b0e7f9b38 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 19:52:35 +0300 Subject: [PATCH 18/21] =?UTF-8?q?=F0=9F=94=A7=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-38=20SSC-31]:=20Fix=20the=20duplicate=20push?= =?UTF-8?q?=20trigger=20so=20staging=20deploys=20to=20Cloudflare=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index 65b837eb..b6e8066d 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -12,7 +12,7 @@ name: Deploy staging to Cloudflare Workers # accounts/data stay one ecosystem; only the frontend Worker differs. on: push: - branches: [staging] + branches: [staging, ssc-edge-api-staging] paths: - "src/**" - "public/**" @@ -29,9 +29,6 @@ on: - ".github/workflows/deploy-cloudflare-staging.yml" - ".github/workflows/cloudflare-app-worker.yml" workflow_dispatch: {} - # Edge API rollout check: deploys only from this one branch, never `staging`. - push: - branches: [ssc-edge-api-staging] concurrency: group: cloudflare-staging @@ -48,6 +45,6 @@ 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 From 2a50eabe615d2a4dc868ad8b67f7d9be72478304 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 20:10:51 +0300 Subject: [PATCH 19/21] =?UTF-8?q?=F0=9F=93=8F=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-38=20SSC-35]:=20Run=20the=20budget=20measureme?= =?UTF-8?q?nt=20after=20each=20staging=20deploy=20and=20point=20the=20play?= =?UTF-8?q?test=20at=20the=20merged=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/deploy-cloudflare-staging.yml | 12 ++++++++++++ .github/workflows/measure-cloudflare-budget.yml | 13 +++++++++++++ .github/workflows/staging-playtest.yml | 9 ++++----- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-staging.yml b/.github/workflows/deploy-cloudflare-staging.yml index b6e8066d..78c1ac57 100644 --- a/.github/workflows/deploy-cloudflare-staging.yml +++ b/.github/workflows/deploy-cloudflare-staging.yml @@ -48,3 +48,15 @@ jobs: 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/measure-cloudflare-budget.yml b/.github/workflows/measure-cloudflare-budget.yml index 3b0d86ae..703d6b32 100644 --- a/.github/workflows/measure-cloudflare-budget.yml +++ b/.github/workflows/measure-cloudflare-budget.yml @@ -10,6 +10,19 @@ name: Measure Cloudflare Free-plan budget (SSC-38) # 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: diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index 4fc026c1..b38f536a 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,7 +62,6 @@ 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 npx wrangler secret delete "$name" --env staging < <(yes) done From e1a7cd7e34f533d2e57adc8b1eb797867ce77211 Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 21:06:55 +0300 Subject: [PATCH 20/21] =?UTF-8?q?=F0=9F=94=84=F0=9F=9B=B0=EF=B8=8F=20?= =?UTF-8?q?=E2=86=9D=20[SSC-38=20SSC-37]:=20Refresh=20one=20snapshot=20per?= =?UTF-8?q?=20cron=20tick=20to=20stay=20inside=20the=2010=20ms=20Free=20CP?= =?UTF-8?q?U=20cap=20and=20keep=20the=20Clerk=20secret=20after=20the=20pla?= =?UTF-8?q?ytest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/staging-playtest.yml | 3 ++- docs/runbooks/cloudflare-cutover.md | 4 ++-- src/server/snapshots/store.ts | 14 +++++++++++--- workers/app/src/app.test.ts | 16 +++++++++++----- workers/app/src/background.ts | 11 ++++++++--- wrangler.jsonc | 6 +++--- 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/.github/workflows/staging-playtest.yml b/.github/workflows/staging-playtest.yml index b38f536a..ebd8cce9 100644 --- a/.github/workflows/staging-playtest.yml +++ b/.github/workflows/staging-playtest.yml @@ -62,7 +62,8 @@ jobs: - name: Disable the playtest endpoint if: always() run: | - 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/docs/runbooks/cloudflare-cutover.md b/docs/runbooks/cloudflare-cutover.md index acd89204..c8db5ca3 100644 --- a/docs/runbooks/cloudflare-cutover.md +++ b/docs/runbooks/cloudflare-cutover.md @@ -19,7 +19,7 @@ returned Error 1102. Production no longer runs a Next.js server at all: | `/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 `*/10 * * * *` | Recomputes the public snapshots into KV | Yes (cron) | +| 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) | @@ -280,7 +280,7 @@ decrypts each payload and verifies its VAPID signature): | Invocation | Result | Subrequests | | --- | --- | --- | -| Cron `*/10 * * * *` | 4 snapshots published in one KV write | 9 | +| 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 | diff --git a/src/server/snapshots/store.ts b/src/server/snapshots/store.ts index c2bb37d5..1df8c080 100644 --- a/src/server/snapshots/store.ts +++ b/src/server/snapshots/store.ts @@ -47,6 +47,12 @@ export const snapshotDefinitions = { 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 = { @@ -124,12 +130,14 @@ export type RefreshReport = { }; /** - * Recompute every snapshot and publish the bundle with one KV write. A failed + * 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 } = {}): Promise { +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; @@ -139,7 +147,7 @@ export async function refreshSnapshots(options: { now?: number; pb?: () => Promi let client: Promise | null = null; const pb = () => (client ??= (options.pb ?? createPocketbaseAdminClient)()); - const names = Object.keys(snapshotDefinitions) as SnapshotName[]; + 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; diff --git a/workers/app/src/app.test.ts b/workers/app/src/app.test.ts index 100c6536..4ae55e64 100644 --- a/workers/app/src/app.test.ts +++ b/workers/app/src/app.test.ts @@ -23,7 +23,7 @@ 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 } from "./background"; +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"; @@ -308,17 +308,23 @@ describe("public snapshots (SSC-37)", () => { expect(body.snapshots).toContainEqual(expect.objectContaining({ name: "sunspot-leaderboard", status: "missing" })); }); - it("publishes the snapshots from the cron trigger", async () => { + 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(() => {}); - await worker.scheduled({ cron: SNAPSHOT_CRON, scheduledTime: Date.now() }, env, { waitUntil: () => {} }); - const bundle = (await kv.get(SNAPSHOT_BUNDLE_KEY, "json")) as any; + 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 }); - expect(JSON.parse(log.mock.calls[0][0])).toMatchObject({ invocation: `cron ${SNAPSHOT_CRON}`, task: "snapshots" }); }); }); diff --git a/workers/app/src/background.ts b/workers/app/src/background.ts index 42b734a9..16ae48f3 100644 --- a/workers/app/src/background.ts +++ b/workers/app/src/background.ts @@ -9,9 +9,13 @@ // 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 } from "@/src/server/snapshots/store"; +import { refreshSnapshots, snapshotForTick } from "@/src/server/snapshots/store"; -export const SNAPSHOT_CRON = "*/10 * * * *"; +// 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> { @@ -21,7 +25,8 @@ export async function runScheduled(cron: string, scheduledTime: number): Promise return { task: "discovery-reminders", day, ...queued }; } // SNAPSHOT_CRON, and any cron added later without a handler of its own. - const report = await refreshSnapshots({ now: scheduledTime }); + const only = [snapshotForTick(scheduledTime, SNAPSHOT_TICK_MS)]; + const report = await refreshSnapshots({ now: scheduledTime, only }); return { task: "snapshots", ...report }; } diff --git a/wrangler.jsonc b/wrangler.jsonc index 9fd15a2e..0b47c6eb 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -55,9 +55,9 @@ { "queue": "starsailors-jobs-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } ] }, - // Must match workers/app/src/background.ts: snapshots every 10 minutes, the + // 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": ["*/10 * * * *", "0 17 * * *"] }, + "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 @@ -121,7 +121,7 @@ { "queue": "starsailors-jobs-staging-dlq", "max_batch_size": 10, "max_batch_timeout": 30, "max_retries": 2 } ] }, - "triggers": { "crons": ["*/10 * * * *"] } + "triggers": { "crons": ["*/5 * * * *"] } } } } From 78145af5835f3c98b03eb13eb0cb0ee4558e3d2c Mon Sep 17 00:00:00 2001 From: Gizmotronn Date: Wed, 30 Sep 2026 21:32:35 +0300 Subject: [PATCH 21/21] =?UTF-8?q?=F0=9F=93=8F=F0=9F=A7=AA=20=E2=86=9D=20[S?= =?UTF-8?q?SC-38]:=20Repeat=20signed-in=20reads=20in=20the=20playtest=20so?= =?UTF-8?q?=20authenticated=20CPU=20has=20warm=20samples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- cypress/e2e/staging-playtest.cy.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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')