From a848edc875c351ae171bce66d243fc81eae2bbaf Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Fri, 29 May 2026 08:34:46 -0500 Subject: [PATCH 1/4] feat(redirect): migrate f3-redirect into the monorepo as apps/redirect Bring the standalone f3-redirect service into the Turborepo monorepo as a grouped multi-tier app under apps/redirect: - apps/redirect/server: Go on-demand-TLS redirect service (Cloud Run/COS), GCS-backed cert + mapping stores, DNS parity tests - apps/redirect/web: Next.js admin dashboard (Better Auth passkey/email), domains API, Drizzle schema, Playwright e2e + vitest - apps/redirect/shared: shared DNS instruction config - apps/redirect/infra/terraform: Terraform IaC + drift-detection CI Repo wiring: - register redirect web/server/shared in pnpm-workspace.yaml - add Go toolchain + a Postgres-backed `test` job to ci.yml - deploy-redirect-{server,web} and redirect-terraform-drift workflows - .npmrc node-linker=hoisted; .dockerignore hardening - normalize packages/db eslint.config.mjs -> .js to match siblings Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 6 + .github/workflows/ci.yml | 44 + .github/workflows/deploy-redirect-server.yml | 77 + .github/workflows/deploy-redirect-web.yml | 100 + .../workflows/redirect-terraform-drift.yml | 105 + .npmrc | 1 + apps/redirect/README.md | 136 + apps/redirect/infra/terraform/.gitignore | 7 + .../infra/terraform/.terraform.lock.hcl | 22 + apps/redirect/infra/terraform/ci.tf | 74 + .../infra/terraform/domain-mapping.tf | 24 + apps/redirect/infra/terraform/main.tf | 144 ++ apps/redirect/infra/terraform/outputs.tf | 37 + .../infra/terraform/startup-script.sh.tftpl | 58 + .../infra/terraform/terraform.tfvars.example | 24 + apps/redirect/infra/terraform/variables.tf | 98 + apps/redirect/infra/terraform/versions.tf | 24 + apps/redirect/server/.dockerignore | 12 + apps/redirect/server/.env.local.example | 31 + apps/redirect/server/Dockerfile | 16 + apps/redirect/server/cmd/f3redirect/main.go | 272 ++ apps/redirect/server/cmd/redirectd/main.go | 205 ++ apps/redirect/server/go.mod | 68 + apps/redirect/server/go.sum | 161 ++ .../redirect/server/internal/certstore/gcs.go | 237 ++ .../server/internal/certstore/gcs_test.go | 217 ++ apps/redirect/server/internal/mappings/dns.go | 84 + .../internal/mappings/dns_parity_test.go | 74 + .../server/internal/mappings/dns_test.go | 47 + .../server/internal/mappings/filestore.go | 60 + .../server/internal/mappings/gcsstore.go | 69 + .../server/internal/mappings/mappings.go | 181 ++ .../server/internal/mappings/mappings_test.go | 153 ++ .../server/internal/mappings/store.go | 51 + .../server/internal/mappings/store_test.go | 67 + .../internal/redirect/integration_test.go | 103 + .../redirect/server/internal/redirect/live.go | 70 + .../server/internal/redirect/proxy.go | 26 + .../server/internal/redirect/redirect.go | 67 + .../server/internal/redirect/redirect_test.go | 172 ++ .../redirect/server/internal/server/server.go | 68 + apps/redirect/server/package.json | 15 + apps/redirect/server/scripts/build.sh | 4 + apps/redirect/server/scripts/coverage.sh | 39 + apps/redirect/server/scripts/format-check.sh | 4 + apps/redirect/server/scripts/format.sh | 4 + apps/redirect/server/scripts/lint.sh | 6 + apps/redirect/server/scripts/test.sh | 6 + apps/redirect/shared/dns-instructions.json | 75 + apps/redirect/shared/package.json | 9 + apps/redirect/web/.dockerignore | 10 + apps/redirect/web/.env.cloud-run.example | 15 + apps/redirect/web/.env.local.example | 39 + apps/redirect/web/.gitignore | 17 + apps/redirect/web/Dockerfile | 45 + apps/redirect/web/drizzle.config.ts | 12 + apps/redirect/web/e2e/admin.spec.ts | 42 + apps/redirect/web/e2e/passkey.spec.ts | 50 + apps/redirect/web/eslint.config.js | 39 + apps/redirect/web/next.config.ts | 8 + apps/redirect/web/package.json | 50 + apps/redirect/web/playwright.config.ts | 28 + apps/redirect/web/public/.gitkeep | 0 apps/redirect/web/scripts/local-e2e.ts | 143 ++ apps/redirect/web/scripts/reset-test-db.mjs | 45 + .../web/src/app/api/auth/[...all]/route.ts | 4 + .../web/src/app/api/domains/[id]/route.ts | 83 + .../web/src/app/api/domains/route.test.ts | 204 ++ .../redirect/web/src/app/api/domains/route.ts | 104 + apps/redirect/web/src/app/dashboard/page.tsx | 28 + apps/redirect/web/src/app/globals.css | 302 +++ apps/redirect/web/src/app/layout.tsx | 37 + apps/redirect/web/src/app/page.tsx | 28 + apps/redirect/web/src/auth.ts | 51 + .../web/src/components/AuthForm.test.tsx | 82 + apps/redirect/web/src/components/AuthForm.tsx | 128 + .../web/src/components/Dashboard.test.tsx | 176 ++ .../redirect/web/src/components/Dashboard.tsx | 340 +++ apps/redirect/web/src/db/index.ts | 42 + apps/redirect/web/src/db/schema.ts | 109 + apps/redirect/web/src/lib/auth-client.ts | 9 + .../web/src/lib/domains.parity.test.ts | 62 + apps/redirect/web/src/lib/domains.test.ts | 107 + apps/redirect/web/src/lib/domains.ts | 138 + apps/redirect/web/src/lib/gcs-export.test.ts | 31 + apps/redirect/web/src/lib/gcs-export.ts | 73 + apps/redirect/web/src/test-setup.ts | 10 + apps/redirect/web/src/vitest.d.ts | 10 + apps/redirect/web/tsconfig.json | 18 + apps/redirect/web/vitest.config.ts | 44 + packages/db/eslint.config.js | 3 + packages/db/eslint.config.mjs | 3 - pnpm-lock.yaml | 2274 ++++++++++++++--- pnpm-workspace.yaml | 5 + 94 files changed, 8311 insertions(+), 391 deletions(-) create mode 100644 .github/workflows/deploy-redirect-server.yml create mode 100644 .github/workflows/deploy-redirect-web.yml create mode 100644 .github/workflows/redirect-terraform-drift.yml create mode 100644 .npmrc create mode 100644 apps/redirect/README.md create mode 100644 apps/redirect/infra/terraform/.gitignore create mode 100644 apps/redirect/infra/terraform/.terraform.lock.hcl create mode 100644 apps/redirect/infra/terraform/ci.tf create mode 100644 apps/redirect/infra/terraform/domain-mapping.tf create mode 100644 apps/redirect/infra/terraform/main.tf create mode 100644 apps/redirect/infra/terraform/outputs.tf create mode 100644 apps/redirect/infra/terraform/startup-script.sh.tftpl create mode 100644 apps/redirect/infra/terraform/terraform.tfvars.example create mode 100644 apps/redirect/infra/terraform/variables.tf create mode 100644 apps/redirect/infra/terraform/versions.tf create mode 100644 apps/redirect/server/.dockerignore create mode 100644 apps/redirect/server/.env.local.example create mode 100644 apps/redirect/server/Dockerfile create mode 100644 apps/redirect/server/cmd/f3redirect/main.go create mode 100644 apps/redirect/server/cmd/redirectd/main.go create mode 100644 apps/redirect/server/go.mod create mode 100644 apps/redirect/server/go.sum create mode 100644 apps/redirect/server/internal/certstore/gcs.go create mode 100644 apps/redirect/server/internal/certstore/gcs_test.go create mode 100644 apps/redirect/server/internal/mappings/dns.go create mode 100644 apps/redirect/server/internal/mappings/dns_parity_test.go create mode 100644 apps/redirect/server/internal/mappings/dns_test.go create mode 100644 apps/redirect/server/internal/mappings/filestore.go create mode 100644 apps/redirect/server/internal/mappings/gcsstore.go create mode 100644 apps/redirect/server/internal/mappings/mappings.go create mode 100644 apps/redirect/server/internal/mappings/mappings_test.go create mode 100644 apps/redirect/server/internal/mappings/store.go create mode 100644 apps/redirect/server/internal/mappings/store_test.go create mode 100644 apps/redirect/server/internal/redirect/integration_test.go create mode 100644 apps/redirect/server/internal/redirect/live.go create mode 100644 apps/redirect/server/internal/redirect/proxy.go create mode 100644 apps/redirect/server/internal/redirect/redirect.go create mode 100644 apps/redirect/server/internal/redirect/redirect_test.go create mode 100644 apps/redirect/server/internal/server/server.go create mode 100644 apps/redirect/server/package.json create mode 100755 apps/redirect/server/scripts/build.sh create mode 100755 apps/redirect/server/scripts/coverage.sh create mode 100755 apps/redirect/server/scripts/format-check.sh create mode 100755 apps/redirect/server/scripts/format.sh create mode 100755 apps/redirect/server/scripts/lint.sh create mode 100755 apps/redirect/server/scripts/test.sh create mode 100644 apps/redirect/shared/dns-instructions.json create mode 100644 apps/redirect/shared/package.json create mode 100644 apps/redirect/web/.dockerignore create mode 100644 apps/redirect/web/.env.cloud-run.example create mode 100644 apps/redirect/web/.env.local.example create mode 100644 apps/redirect/web/.gitignore create mode 100644 apps/redirect/web/Dockerfile create mode 100644 apps/redirect/web/drizzle.config.ts create mode 100644 apps/redirect/web/e2e/admin.spec.ts create mode 100644 apps/redirect/web/e2e/passkey.spec.ts create mode 100644 apps/redirect/web/eslint.config.js create mode 100644 apps/redirect/web/next.config.ts create mode 100644 apps/redirect/web/package.json create mode 100644 apps/redirect/web/playwright.config.ts create mode 100644 apps/redirect/web/public/.gitkeep create mode 100644 apps/redirect/web/scripts/local-e2e.ts create mode 100644 apps/redirect/web/scripts/reset-test-db.mjs create mode 100644 apps/redirect/web/src/app/api/auth/[...all]/route.ts create mode 100644 apps/redirect/web/src/app/api/domains/[id]/route.ts create mode 100644 apps/redirect/web/src/app/api/domains/route.test.ts create mode 100644 apps/redirect/web/src/app/api/domains/route.ts create mode 100644 apps/redirect/web/src/app/dashboard/page.tsx create mode 100644 apps/redirect/web/src/app/globals.css create mode 100644 apps/redirect/web/src/app/layout.tsx create mode 100644 apps/redirect/web/src/app/page.tsx create mode 100644 apps/redirect/web/src/auth.ts create mode 100644 apps/redirect/web/src/components/AuthForm.test.tsx create mode 100644 apps/redirect/web/src/components/AuthForm.tsx create mode 100644 apps/redirect/web/src/components/Dashboard.test.tsx create mode 100644 apps/redirect/web/src/components/Dashboard.tsx create mode 100644 apps/redirect/web/src/db/index.ts create mode 100644 apps/redirect/web/src/db/schema.ts create mode 100644 apps/redirect/web/src/lib/auth-client.ts create mode 100644 apps/redirect/web/src/lib/domains.parity.test.ts create mode 100644 apps/redirect/web/src/lib/domains.test.ts create mode 100644 apps/redirect/web/src/lib/domains.ts create mode 100644 apps/redirect/web/src/lib/gcs-export.test.ts create mode 100644 apps/redirect/web/src/lib/gcs-export.ts create mode 100644 apps/redirect/web/src/test-setup.ts create mode 100644 apps/redirect/web/src/vitest.d.ts create mode 100644 apps/redirect/web/tsconfig.json create mode 100644 apps/redirect/web/vitest.config.ts create mode 100644 packages/db/eslint.config.js delete mode 100644 packages/db/eslint.config.mjs diff --git a/.dockerignore b/.dockerignore index d822131b..40dc1179 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,12 @@ docker-compose* README.md LICENSE +# Local-only dirs that must never enter any image build context. +.worktrees +**/.terraform +**/*.tfstate* +coverage + *.swp /aws diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ae6b948..b330f75e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: with: node-version: 24 cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" - run: pnpm install --frozen-lockfile - run: pnpm format @@ -55,6 +58,9 @@ jobs: with: node-version: 24 cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -67,6 +73,9 @@ jobs: with: node-version: 24 cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -79,9 +88,41 @@ jobs: with: node-version: 24 cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" - run: pnpm install --frozen-lockfile - run: pnpm build + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: f3_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + - run: pnpm install --frozen-lockfile + - run: pnpm test + test-coverage: runs-on: ubuntu-latest services: @@ -105,5 +146,8 @@ jobs: with: node-version: 24 cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version: "1.26" - run: pnpm install --frozen-lockfile - run: pnpm test:coverage diff --git a/.github/workflows/deploy-redirect-server.yml b/.github/workflows/deploy-redirect-server.yml new file mode 100644 index 00000000..994983f2 --- /dev/null +++ b/.github/workflows/deploy-redirect-server.yml @@ -0,0 +1,77 @@ +name: Deploy Redirect Server + +# Redirect tier (Go `redirectd`): build the image, push to Artifact Registry, +# and roll the GCE VM so it pulls and runs the new image on boot. +# +# Deploy model: this app has a SINGLE production environment. Pushing to `dev` +# (the monorepo's integration/default branch) IS the prod deploy — there is no +# separate staging for redirect. Path-filtered so only changes that affect the +# redirect binary/container trigger a roll (not web-only or docs changes). +# +# ⚠️ SANDBOX (v1): targets Patrick's PERSONAL, self-funded GCP project +# `f3-redirects`. Long-term this pivots to an F3 Nation org-owned/funded project +# (provisioned by Tackle); the WIF provider, deployer SA, and project below are +# personal-sandbox identifiers that change at that cutover. +# +# Auth is keyless via Workload Identity Federation — no long-lived SA keys. + +on: + push: + branches: [dev] + paths: + - "apps/redirect/server/**" + - "apps/redirect/shared/**" + - ".github/workflows/deploy-redirect-server.yml" + workflow_dispatch: + +concurrency: + group: deploy-redirect-server + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + PROJECT: f3-redirects + REGION: us-central1 + ZONE: us-central1-a + REPO: redirect + INSTANCE: redirect-vm + WIF_PROVIDER: projects/355149658273/locations/global/workloadIdentityPools/github-pool/providers/github + DEPLOYER_SA: redirect-deployer@f3-redirects.iam.gserviceaccount.com + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ env.WIF_PROVIDER }} + service_account: ${{ env.DEPLOYER_SA }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Configure Docker for Artifact Registry + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Build & push image + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${REPO}/redirectd" + # --provenance=false: emit a plain single-arch manifest. The buildx + # default OCI index (with an attestation manifest) is not reliably + # pullable by the Container-Optimized OS docker on the VM. + docker build --provenance=false --sbom=false \ + --file apps/redirect/server/Dockerfile \ + -t "${IMAGE}:${GITHUB_SHA}" -t "${IMAGE}:latest" \ + apps/redirect/server + docker push "${IMAGE}:${GITHUB_SHA}" + docker push "${IMAGE}:latest" + + - name: Roll the VM (re-runs startup script, pulls :latest) + run: | + gcloud compute instances reset "${INSTANCE}" --zone "${ZONE}" --project "${PROJECT}" + echo "Reset ${INSTANCE}; it will pull the new image on boot." diff --git a/.github/workflows/deploy-redirect-web.yml b/.github/workflows/deploy-redirect-web.yml new file mode 100644 index 00000000..b6272f9f --- /dev/null +++ b/.github/workflows/deploy-redirect-web.yml @@ -0,0 +1,100 @@ +name: Deploy Redirect Web + +# Self-serve admin web (Next.js, `f3-redirect-web`): build the turbo-pruned +# container and deploy to Cloud Run. The Go redirect tier reverse-proxies the +# admin hostname to this service. +# +# Deploy model: SINGLE production environment. Pushing to `dev` (the monorepo's +# integration/default branch) IS the prod deploy — no separate staging for +# redirect. Path-filtered to web changes. +# +# ⚠️ SANDBOX (v1): targets Patrick's PERSONAL, self-funded GCP project +# `f3-redirects`. Long-term pivots to an F3 Nation org-owned/funded project +# (provisioned by Tackle), and the interim Cloud SQL database is replaced by an +# app-specific schema + service principal in the F3PROD data warehouse (the +# Codex/PaxVault pattern). Neon Postgres is off the table. +# +# Keyless auth via Workload Identity Federation. + +on: + push: + branches: [dev] + paths: + - "apps/redirect/web/**" + - ".github/workflows/deploy-redirect-web.yml" + workflow_dispatch: + +concurrency: + group: deploy-redirect-web + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + PROJECT: f3-redirects + REGION: us-central1 + AR_REPO: redirect + # NOTE: the Artifact Registry image and the Cloud Run service are both named + # `f3redirect-web` (no hyphen) — matching the existing deployed resources so + # this workflow updates them in place. (The pnpm package is `f3-redirect-web`; + # the turbo --filter below uses that package name.) + IMAGE_NAME: f3redirect-web + SERVICE_NAME: f3redirect-web + WIF_PROVIDER: projects/355149658273/locations/global/workloadIdentityPools/github-pool/providers/github + DEPLOYER_SA: redirect-deployer@f3-redirects.iam.gserviceaccount.com + +jobs: + build: + runs-on: ubuntu-latest + outputs: + image: ${{ steps.meta.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ env.WIF_PROVIDER }} + service_account: ${{ env.DEPLOYER_SA }} + + - uses: google-github-actions/setup-gcloud@v2 + + - name: Authorize Docker to Artifact Registry + run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet + + - name: Resolve image reference (immutable by commit SHA) + id: meta + run: | + IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${AR_REPO}/${IMAGE_NAME}:${GITHUB_SHA}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + run: | + docker build \ + --file apps/redirect/web/Dockerfile \ + --tag "${{ steps.meta.outputs.image }}" \ + . + docker push "${{ steps.meta.outputs.image }}" + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ env.WIF_PROVIDER }} + service_account: ${{ env.DEPLOYER_SA }} + + - name: Deploy to Cloud Run + uses: google-github-actions/deploy-cloudrun@v2 + with: + service: ${{ env.SERVICE_NAME }} + image: ${{ needs.build.outputs.image }} + region: ${{ env.REGION }} + project_id: ${{ env.PROJECT }} + # Env vars (DATABASE_URL, BETTER_AUTH_SECRET, GCS bucket, etc.) are + # set on the Cloud Run service out-of-band, not in this workflow. diff --git a/.github/workflows/redirect-terraform-drift.yml b/.github/workflows/redirect-terraform-drift.yml new file mode 100644 index 00000000..7706a139 --- /dev/null +++ b/.github/workflows/redirect-terraform-drift.yml @@ -0,0 +1,105 @@ +name: redirect Terraform Drift + +# Enforces the zero-drift rule for the f3-redirects GCP project: the committed +# Terraform under apps/redirect/infra/terraform is the source of truth. This +# workflow runs `terraform plan` and fails if live infrastructure has drifted +# from the code — daily (catch console drift) and on every PR that touches the +# Terraform (catch drift before merge). +# +# Auth is keyless via the f3-redirects project's own Workload Identity pool +# (github-pool/github), whose attribute condition was widened in ci.tf to allow +# this repo (F3-Nation/f3-nation). It impersonates the read-only +# github-actions-ci@f3-redirects service account. +# +# NOTE: f3-redirects is currently Patrick's personal v1 sandbox. When the +# redirect app pivots to an F3 Nation org-owned project, update the project id, +# WIF provider, service account, and the TF_VAR_* values below. + +on: + schedule: + - cron: "17 13 * * *" # daily ~7-8am Central (offset from region-pages' :00) + pull_request: + paths: + - "apps/redirect/infra/terraform/**" + - ".github/workflows/redirect-terraform-drift.yml" + workflow_dispatch: + +concurrency: + group: redirect-tf-drift + cancel-in-progress: false + +permissions: + contents: read + id-token: write + pull-requests: write + +jobs: + drift: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/redirect/infra/terraform + env: + # Non-secret values that must match the live VM startup-script metadata so + # `plan` reports no spurious drift. (The image_tag, project, region, zone, + # machine_type, config_object, and cert_prefix variable defaults already + # match live.) + TF_VAR_acme_email: "patrick@pstaylor.net" + TF_VAR_admin_host: "admin.f3regions.com" + TF_VAR_admin_upstream: "https://f3redirect-web-355149658273.us-central1.run.app" + steps: + - uses: actions/checkout@v4 + + - name: Authenticate to GCP (Workload Identity Federation) + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: "projects/355149658273/locations/global/workloadIdentityPools/github-pool/providers/github" + service_account: "github-actions-ci@f3-redirects.iam.gserviceaccount.com" + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.15.3" + + - name: Terraform init + run: terraform init -input=false + + - name: Terraform plan (detect drift) + id: plan + run: | + set +e + terraform plan -input=false -lock=false -no-color -detailed-exitcode + code=$? + set -e + echo "exitcode=$code" >> "$GITHUB_OUTPUT" + # 0 = in sync, 2 = drift detected, 1 = error + if [ "$code" = "1" ]; then + echo "::error::terraform plan failed." + exit 1 + elif [ "$code" = "2" ]; then + echo "::error::Drift detected — f3-redirects infrastructure no longer matches Terraform. Reconcile by applying the committed config or importing the change." + exit 2 + else + echo "No drift — f3-redirects matches Terraform." + fi + + - name: Comment drift result on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const code = '${{ steps.plan.outputs.exitcode }}'; + const body = code === '0' + ? '✅ **redirect Terraform drift check:** in sync — live infrastructure matches the committed config.' + : code === '2' + ? '⚠️ **redirect Terraform drift check:** drift detected — `terraform plan` shows changes. Reconcile before merge (apply the committed config or import the out-of-band change).' + : '❌ **redirect Terraform drift check:** `terraform plan` errored. See the job logs.'; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..d67f3748 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/apps/redirect/README.md b/apps/redirect/README.md new file mode 100644 index 00000000..652b5c89 --- /dev/null +++ b/apps/redirect/README.md @@ -0,0 +1,136 @@ +# F3 Redirect + +A multi-tenant service that redirects **custom domains** to arbitrary destination +URLs. Each request arrives at our redirect tier (because the tenant pointed their +DNS at us); we read the `Host` header, look up the target, and return a 301/302. +TLS certificates for those custom domains are issued **on demand** and stored in +GCS — no database anywhere. + +## Mappings (current) + +| Source | Destination | DNS shape | DNS owner | +| ---------------------- | --------------------------------------------------- | --------- | ------------------- | +| `f3muletown.com` | `https://regions.f3nation.com/muletown` | apex (A) | Route 53 (us) | +| `www.f3muletown.com` | `https://regions.f3nation.com/muletown` | CNAME | Route 53 (us) | +| `stats.f3muletown.com` | `https://pax-vault.f3nation.com/stats/region/35838` | CNAME | Route 53 (us) | +| `f3marshall.com` | `https://regions.f3nation.com/marshall-tn` | apex (A) | external (hand-off) | +| `www.f3marshall.com` | `https://regions.f3nation.com/marshall-tn` | CNAME | external (hand-off) | + +## Architecture + +- **Redirect tier (Go, `cmd/redirectd`).** Terminates TLS itself and emits the + redirect. Because it owns port 443, it runs on **GCE** (Container-Optimized OS + VM), not Cloud Run. +- **On-demand TLS via [CertMagic](https://github.com/caddyserver/certmagic).** + Certs are obtained from Let's Encrypt the first time a registered host is seen, + and stored in GCS (`internal/certstore`) so they're shared across instances and + survive restarts. Issuance is **gated on the registry**: the decision function + refuses to obtain a cert for a host that isn't in the config (abuse / rate-limit + guard). +- **Config is a flat JSON file in GCS — no database** (`internal/mappings`). The + same file is the registry the TLS gate consults. The server hot-reloads it on + an interval, so new mappings take effect without a redeploy. +- **Admin CLI (Go, `cmd/f3redirect`).** Add/list/remove mappings and print the DNS + records a tenant must create. A TypeScript management UI is deferred. + +```text +cmd/redirectd HTTPS redirect server (on-demand TLS) +cmd/f3redirect admin CLI +internal/mappings config model, resolve, validate, DNS instructions, stores (file + GCS) +internal/redirect HTTP redirect handler + hot-reloading Live view +internal/certstore certmagic.Storage backed by GCS +internal/server CertMagic wiring (gated on-demand issuance) +infra/terraform GCS bucket, static IP, COS VM, firewall, Artifact Registry, IAM +``` + +## Local development + +Run the CLI against a local file (no cloud needed): + +```bash +cp config.example.json /tmp/redirects.json +go run ./cmd/f3redirect list --file /tmp/redirects.json +go run ./cmd/f3redirect dns --file /tmp/redirects.json --static-ip 203.0.113.10 +go run ./cmd/f3redirect add --file /tmp/redirects.json example.com https://example.org +``` + +Run the server locally (cert storage is always GCS — set a bucket): + +```bash +CONFIG_FILE=/tmp/redirects.json CERT_BUCKET= ACME_STAGING=1 \ +HTTP_ADDR=:8080 HTTPS_ADDR=:8443 ACME_EMAIL=you@example.com \ + go run ./cmd/redirectd +``` + +## Tests & coverage + +```bash +go test ./... +bash scripts/coverage.sh # enforces a coverage threshold (default 70%) +``` + +The gate covers the business-logic packages (`internal/mappings`, +`internal/redirect`). Cloud-IO packages (`internal/certstore`, `internal/server`) +and the `cmd/` entrypoints are validated by the deploy-time smoke test instead. +Coverage artifacts (`coverage.out`, `coverage.html`) are gitignored. + +## Deploy (Terraform) + +```bash +cd infra/terraform +cp terraform.tfvars.example terraform.tfvars # set project + acme_email +terraform init +terraform apply +``` + +Provisions: a GCS bucket (config + certs), a reserved **static IP** (apex +A-records point here), an Artifact Registry repo, a COS VM running `redirectd` on +the host network (ports 80/443), a firewall for 80/443, and a runtime service +account with object access + image-pull rights. Key outputs: `static_ip`, +`bucket`, `artifact_registry`. + +Seed the config once (thereafter, manage it with the CLI): + +```bash +gcloud storage cp config.example.json gs:///config/redirects.json +``` + +## CI/CD (GitHub → Google Cloud) + +Now that this app lives in the F3 Nation monorepo, it shares the repo-wide CI +and has its own deploy workflows: + +- **CI** (`.github/workflows/ci.yml`, repo root): the shared Turborepo pipeline + runs `lint`, `typecheck`, `build`, `test`, and `test-coverage` across all + packages. The Go tier participates as a turbo workspace package (its + `package.json` shells to `go` via `scripts/*.sh`); the workflow installs Go + with `actions/setup-go` so those tasks run in CI. +- **CD — redirect tier** (`.github/workflows/deploy-redirect-server.yml`): on + **push to `dev`** (path-filtered to the Go tier + its Dockerfile), builds and + pushes the image to Artifact Registry and rolls the COS VM (which re-pulls + `:latest` on boot). +- **CD — admin web** (`.github/workflows/deploy-redirect-web.yml`): on **push to + `dev`** (path-filtered to `apps/redirect/web/**`), builds the turbo-pruned + image and deploys to Cloud Run. + +There is a single production environment: **merging to `dev` deploys to prod** +(no separate staging for this app). Auth is **keyless via Workload Identity +Federation** — no SA keys. + +> The Go image is built with `--provenance=false` so it's a plain single-arch +> manifest; the COS docker on the VM does not reliably pull buildx OCI indexes +> that carry an attestation manifest. + +## DNS + +Apex domains can't CNAME, so they use an **A record to the static IP**; +subdomains **CNAME** to the apex (which carries that A record). Generate the exact +records with: + +```bash +f3redirect dns --bucket --static-ip +``` + +Muletown's records live in our Route 53 zone and are managed here. Marshall's +domain is controlled by a third party — hand them the `dns` output for +`f3marshall.com` / `www.f3marshall.com`. diff --git a/apps/redirect/infra/terraform/.gitignore b/apps/redirect/infra/terraform/.gitignore new file mode 100644 index 00000000..52d8e8d3 --- /dev/null +++ b/apps/redirect/infra/terraform/.gitignore @@ -0,0 +1,7 @@ +# Terraform working dir + state (never commit; provider binaries are huge). +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!terraform.tfvars.example +crash.log diff --git a/apps/redirect/infra/terraform/.terraform.lock.hcl b/apps/redirect/infra/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..78b8795f --- /dev/null +++ b/apps/redirect/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "6.50.0" + constraints = "~> 6.0" + hashes = [ + "h1:79CwMTsp3Ud1nOl5hFS5mxQHyT0fGVye7pqpU0PPlHI=", + "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c", + "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5", + "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb", + "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d", + "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b", + "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc", + "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071", + "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7", + "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421", + "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6", + ] +} diff --git a/apps/redirect/infra/terraform/ci.tf b/apps/redirect/infra/terraform/ci.tf new file mode 100644 index 00000000..7f243ec8 --- /dev/null +++ b/apps/redirect/infra/terraform/ci.tf @@ -0,0 +1,74 @@ +# CI identity for Terraform plan / drift detection (see +# .github/workflows/redirect-terraform-drift.yml). Keyless: GitHub Actions +# federates into this project's Workload Identity pool and impersonates a +# read-only service account — no long-lived key. +# +# The github-pool / github provider already existed (created out-of-band for +# the standalone F3-Nation/f3-redirect repo's deploy workflows). They are +# imported and codified here, and the provider's attribute condition is widened +# additively so the monorepo (F3-Nation/f3-nation) can federate too — without +# breaking the original repo's deploys. + +data "google_project" "this" { + project_id = var.project +} + +locals { + # GitHub repositories permitted to federate into this pool. + # F3-Nation/f3-redirect = original standalone repo (existing deploys) + # F3-Nation/f3-nation = this monorepo (drift CI + future deploys) + wif_repositories = ["F3-Nation/f3-redirect", "F3-Nation/f3-nation"] + + # Monorepo-scoped principalSet that may impersonate the read-only CI SA. + monorepo_principal = "principalSet://iam.googleapis.com/projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/F3-Nation/f3-nation" +} + +resource "google_iam_workload_identity_pool" "github" { + workload_identity_pool_id = "github-pool" + display_name = "GitHub Actions" +} + +resource "google_iam_workload_identity_pool_provider" "github" { + workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id + workload_identity_pool_provider_id = "github" + display_name = "GitHub OIDC" + + attribute_mapping = { + "google.subject" = "assertion.sub" + "attribute.repository" = "assertion.repository" + } + + # Additive: original repo plus the monorepo. CEL list membership. + attribute_condition = "assertion.repository in ${jsonencode(local.wif_repositories)}" + + oidc { + issuer_uri = "https://token.actions.githubusercontent.com" + } +} + +# Read-only service account the monorepo's drift workflow impersonates. +resource "google_service_account" "ci" { + account_id = "github-actions-ci" + display_name = "GitHub Actions — Terraform plan / drift detection" +} + +resource "google_service_account_iam_member" "ci_wif" { + service_account_id = google_service_account.ci.name + role = "roles/iam.workloadIdentityUser" + member = local.monorepo_principal +} + +# Project-wide read so `terraform plan` can refresh every managed resource. +resource "google_project_iam_member" "ci_viewer" { + project = var.project + role = "roles/viewer" + member = "serviceAccount:${google_service_account.ci.email}" +} + +# getIamPolicy across services so plan can refresh the *_iam_member resources +# (bucket objectAdmin, artifact-registry reader, SA bindings) without write access. +resource "google_project_iam_member" "ci_security_reviewer" { + project = var.project + role = "roles/iam.securityReviewer" + member = "serviceAccount:${google_service_account.ci.email}" +} diff --git a/apps/redirect/infra/terraform/domain-mapping.tf b/apps/redirect/infra/terraform/domain-mapping.tf new file mode 100644 index 00000000..40a6d777 --- /dev/null +++ b/apps/redirect/infra/terraform/domain-mapping.tf @@ -0,0 +1,24 @@ +# Admin web tier (Cloud Run) front door. +# +# The redirect VM can reverse-proxy the admin host (var.admin_host), but routing +# the admin control-plane through the single-instance redirect VM couples admin +# availability to the redirect data-plane: a VM redeploy/restart takes the admin +# dashboard down with it. A Cloud Run custom domain mapping gives admin its own +# front door — free, Google-managed TLS, no load balancer — so it stays up +# independently and offloads admin TLS off the VM's on-demand Let's Encrypt. +# +# DNS handoff: admin.f3regions.com CNAME -> ghs.googlehosted.com +# The domain must be verified once in Search Console for the operating account. +resource "google_cloud_run_domain_mapping" "admin" { + count = var.admin_domain == "" ? 0 : 1 + name = var.admin_domain + location = var.region + + metadata { + namespace = var.project + } + + spec { + route_name = var.admin_service_name + } +} diff --git a/apps/redirect/infra/terraform/main.tf b/apps/redirect/infra/terraform/main.tf new file mode 100644 index 00000000..56a5eb4c --- /dev/null +++ b/apps/redirect/infra/terraform/main.tf @@ -0,0 +1,144 @@ +locals { + apis = [ + "compute.googleapis.com", + "artifactregistry.googleapis.com", + "storage.googleapis.com", + # Required by the Terraform google provider itself (project / IAM / SA / + # service reads). Needed so the drift-detection CI — which quotas its API + # calls against this project — can run `plan`. + "cloudresourcemanager.googleapis.com", + "iam.googleapis.com", + ] + image = "${var.region}-docker.pkg.dev/${var.project}/${var.name}/redirectd:${var.image_tag}" +} + +# --- Enable required APIs --------------------------------------------------- +resource "google_project_service" "apis" { + for_each = toset(local.apis) + service = each.value + disable_on_destroy = false +} + +# --- Shared bucket: flat-file config + on-demand TLS cert storage ----------- +resource "google_storage_bucket" "data" { + name = "${var.project}-${var.name}" + location = var.region + uniform_bucket_level_access = true + # Hard-block any accidental public IAM grant (uniform access alone doesn't). + public_access_prevention = "enforced" + force_destroy = false + + versioning { + enabled = true + } + + depends_on = [google_project_service.apis] +} + +# --- Artifact Registry (Docker images) -------------------------------------- +resource "google_artifact_registry_repository" "repo" { + repository_id = var.name + location = var.region + format = "DOCKER" + description = "Redirect tier container images" + depends_on = [google_project_service.apis] +} + +# --- Service account for the VM --------------------------------------------- +resource "google_service_account" "redirect" { + account_id = "${var.name}-runtime" + display_name = "Redirect tier runtime" +} + +# Read/write the config + cert objects in the bucket. +resource "google_storage_bucket_iam_member" "object_admin" { + bucket = google_storage_bucket.data.name + role = "roles/storage.objectAdmin" + member = "serviceAccount:${google_service_account.redirect.email}" +} + +# Pull images from Artifact Registry. +resource "google_artifact_registry_repository_iam_member" "puller" { + repository = google_artifact_registry_repository.repo.name + location = google_artifact_registry_repository.repo.location + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.redirect.email}" +} + +# --- Reserved static IP (apex A-records point here) ------------------------- +resource "google_compute_address" "redirect" { + name = "${var.name}-ip" + region = var.region + address_type = "EXTERNAL" + depends_on = [google_project_service.apis] +} + +# --- Firewall: allow inbound 80/443 ----------------------------------------- +resource "google_compute_firewall" "web" { + name = "${var.name}-allow-web" + network = "default" + direction = "INGRESS" + + allow { + protocol = "tcp" + ports = ["80", "443"] + } + + source_ranges = ["0.0.0.0/0"] + target_tags = ["${var.name}-web"] + depends_on = [google_project_service.apis] +} + +# --- The redirect VM (Container-Optimized OS) ------------------------------- +resource "google_compute_instance" "redirect" { + name = "${var.name}-vm" + machine_type = var.machine_type + zone = var.zone + tags = ["${var.name}-web"] + + boot_disk { + initialize_params { + image = "cos-cloud/cos-stable" + size = 10 + } + } + + network_interface { + network = "default" + access_config { + nat_ip = google_compute_address.redirect.address + } + } + + metadata = { + startup-script = templatefile("${path.module}/startup-script.sh.tftpl", { + image = local.image + region = var.region + bucket = google_storage_bucket.data.name + config_object = var.config_object + cert_prefix = var.cert_prefix + acme_email = var.acme_email + acme_staging = var.acme_staging ? "true" : "false" + redirect_status = var.redirect_status + admin_host = var.admin_host + admin_upstream = var.admin_upstream + }) + google-logging-enabled = "true" + # Don't inherit project-wide SSH keys on this internet-facing VM; access is + # via the GCP console/IAP only. Flip to OS Login if interactive SSH is ever + # needed. + block-project-ssh-keys = "true" + } + + service_account { + email = google_service_account.redirect.email + scopes = ["cloud-platform"] + } + + allow_stopping_for_update = true + + depends_on = [ + google_storage_bucket_iam_member.object_admin, + google_artifact_registry_repository_iam_member.puller, + ] +} diff --git a/apps/redirect/infra/terraform/outputs.tf b/apps/redirect/infra/terraform/outputs.tf new file mode 100644 index 00000000..a59f5b5f --- /dev/null +++ b/apps/redirect/infra/terraform/outputs.tf @@ -0,0 +1,37 @@ +output "static_ip" { + description = "Reserved public IP. Apex A-records (and the canonical host) point here." + value = google_compute_address.redirect.address +} + +output "bucket" { + description = "GCS bucket holding the flat-file config and TLS certs." + value = google_storage_bucket.data.name +} + +output "config_object" { + description = "Full gs:// path of the flat-file config." + value = "gs://${google_storage_bucket.data.name}/${var.config_object}" +} + +output "image" { + description = "Container image the VM runs." + value = local.image +} + +output "instance" { + description = "Redirect VM name." + value = google_compute_instance.redirect.name +} + +output "artifact_registry" { + description = "Artifact Registry Docker repo URL." + value = "${var.region}-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.repo.repository_id}" +} + +output "admin_domain_dns" { + description = "DNS record to create for the admin Cloud Run domain mapping (CNAME -> rrdata)." + value = var.admin_domain == "" ? null : { + for r in google_cloud_run_domain_mapping.admin[0].status[0].resource_records : + r.name => { type = r.type, rrdata = r.rrdata } + } +} diff --git a/apps/redirect/infra/terraform/startup-script.sh.tftpl b/apps/redirect/infra/terraform/startup-script.sh.tftpl new file mode 100644 index 00000000..b1dcbcd5 --- /dev/null +++ b/apps/redirect/infra/terraform/startup-script.sh.tftpl @@ -0,0 +1,58 @@ +#!/bin/bash +# Container-Optimized OS startup script: run the redirect tier on the host +# network (so it owns ports 80/443) and keep it running. Retries the image +# pull so the VM can boot before the first image is pushed by CI/CD. +set -uo pipefail + +IMAGE="${image}" +REGISTRY="${region}-docker.pkg.dev" + +# Container-Optimized OS ships a locked-down host firewall (INPUT policy DROP, +# only lo/icmp/established/ssh allowed). Open the web ports so external traffic +# reaches the container on the host network. Idempotent across reboots. +for PORT in 80 443; do + iptables -w -C INPUT -p tcp --dport "$PORT" -j ACCEPT 2>/dev/null \ + || iptables -w -A INPUT -p tcp --dport "$PORT" -j ACCEPT +done + +# COS has a read-only root filesystem, so docker can't write its default +# config under /root/.docker. Point DOCKER_CONFIG at a writable path. +export DOCKER_CONFIG=/var/lib/redirectd/docker +mkdir -p "$DOCKER_CONFIG" + +# Authenticate Docker to Artifact Registry using the VM service account's +# access token from the metadata server (reliable on COS, no extra tooling). +docker_login() { + local token + token=$(curl -s -H "Metadata-Flavor: Google" \ + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token" \ + | sed -E 's/.*"access_token":"([^"]+)".*/\1/') + [ -n "$token" ] || return 1 + echo "$token" | docker login -u oauth2accesstoken --password-stdin "https://$REGISTRY" >/dev/null 2>&1 +} + +run_redirectd() { + docker rm -f redirectd >/dev/null 2>&1 || true + docker run -d --name redirectd --restart=always --network host \ + -e CONFIG_BUCKET="${bucket}" \ + -e CONFIG_OBJECT="${config_object}" \ + -e CERT_BUCKET="${bucket}" \ + -e CERT_PREFIX="${cert_prefix}" \ + -e ACME_EMAIL="${acme_email}" \ + -e ACME_STAGING="${acme_staging}" \ + -e REDIRECT_STATUS="${redirect_status}" \ + -e ADMIN_HOST="${admin_host}" \ + -e ADMIN_UPSTREAM="${admin_upstream}" \ + "$IMAGE" +} + +# Retry until the image is available and the container starts. +for i in $(seq 1 60); do + if docker_login && docker pull "$IMAGE" >/dev/null 2>&1; then + run_redirectd && exit 0 + fi + echo "redirectd image not ready yet (attempt $i); retrying in 15s" >&2 + sleep 15 +done +echo "failed to start redirectd after retries" >&2 +exit 1 diff --git a/apps/redirect/infra/terraform/terraform.tfvars.example b/apps/redirect/infra/terraform/terraform.tfvars.example new file mode 100644 index 00000000..fe537133 --- /dev/null +++ b/apps/redirect/infra/terraform/terraform.tfvars.example @@ -0,0 +1,24 @@ +# Copy to terraform.tfvars (gitignored) and fill in. The values must match the +# live VM startup-script metadata or `terraform plan` will report drift (the +# drift-detection CI relies on this). +project = "f3-redirects" +region = "us-central1" +zone = "us-central1-a" +machine_type = "e2-small" + +acme_email = "you@example.com" +acme_staging = false +redirect_status = "302" +# image_tag = "latest" # deploys push :latest; the VM pulls it on boot/restart + +# config_object = "config/redirects.json" +# cert_prefix = "certs" + +# Admin web tier (Cloud Run). admin_host/admin_upstream configure the redirect +# VM's reverse-proxy fallback; admin_domain creates the primary Cloud Run domain +# mapping (free, Google-managed TLS, no load balancer). Set admin_domain = "" +# to disable the mapping and serve admin only through the VM proxy. +admin_host = "admin.f3regions.com" +admin_upstream = "https://f3redirect-web-.us-central1.run.app" +admin_domain = "admin.f3regions.com" +admin_service_name = "f3redirect-web" diff --git a/apps/redirect/infra/terraform/variables.tf b/apps/redirect/infra/terraform/variables.tf new file mode 100644 index 00000000..89e4c9f0 --- /dev/null +++ b/apps/redirect/infra/terraform/variables.tf @@ -0,0 +1,98 @@ +variable "project" { + type = string + description = "GCP project ID." + default = "f3-redirects" +} + +variable "region" { + type = string + description = "GCP region." + default = "us-central1" +} + +variable "zone" { + type = string + description = "GCP zone for the redirect VM." + default = "us-central1-a" +} + +variable "name" { + type = string + description = "Base name for resources." + default = "redirect" +} + +variable "machine_type" { + type = string + description = "GCE machine type for the redirect VM." + default = "e2-small" +} + +variable "acme_email" { + type = string + description = "Let's Encrypt account contact email." +} + +variable "acme_staging" { + type = bool + description = "Use the Let's Encrypt staging CA (untrusted certs, high rate limits)." + default = false +} + +variable "redirect_status" { + type = string + description = "HTTP redirect status code (301 or 302)." + default = "302" + + validation { + condition = contains(["301", "302"], var.redirect_status) + error_message = "redirect_status must be \"301\" or \"302\"." + } +} + +variable "image_tag" { + type = string + description = "Container image tag to run." + default = "latest" +} + +variable "config_object" { + type = string + description = "GCS object path of the flat-file config." + default = "config/redirects.json" +} + +variable "cert_prefix" { + type = string + description = "GCS prefix under the bucket for shared cert storage." + default = "certs" +} + +variable "admin_host" { + type = string + description = "Hostname the redirect tier reverse-proxies to the admin web app (empty disables). Configurable so it can move (e.g. admin.regions.f3nation.com)." + default = "" +} + +variable "admin_upstream" { + type = string + description = "Upstream URL for the admin host (e.g. the Cloud Run service URL)." + default = "" +} + +variable "admin_domain" { + type = string + description = <<-EOT + Custom domain served directly by a Cloud Run domain mapping for the admin + web tier (free, Google-managed TLS, no load balancer; DNS is a CNAME to + ghs.googlehosted.com). Empty disables the mapping (falls back to the VM + reverse-proxy via admin_host). + EOT + default = "admin.f3regions.com" +} + +variable "admin_service_name" { + type = string + description = "Name of the Cloud Run service backing the admin domain mapping." + default = "f3redirect-web" +} diff --git a/apps/redirect/infra/terraform/versions.tf b/apps/redirect/infra/terraform/versions.tf new file mode 100644 index 00000000..bd912aad --- /dev/null +++ b/apps/redirect/infra/terraform/versions.tf @@ -0,0 +1,24 @@ +terraform { + required_version = ">= 1.5" + required_providers { + google = { + source = "hashicorp/google" + version = "~> 6.0" + } + } + + # Remote state so the drift-detection CI (see + # .github/workflows/redirect-terraform-drift.yml) can read the same state a + # local apply writes. Bucket created out-of-band (bootstrap infra is not + # self-managed): gs://f3-redirects-tfstate (versioned, UBLA, PAP enforced). + backend "gcs" { + bucket = "f3-redirects-tfstate" + prefix = "redirect" + } +} + +provider "google" { + project = var.project + region = var.region + zone = var.zone +} diff --git a/apps/redirect/server/.dockerignore b/apps/redirect/server/.dockerignore new file mode 100644 index 00000000..5bf16194 --- /dev/null +++ b/apps/redirect/server/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +infra +docs +scripts +.claude +coverage.out +coverage.html +*.md +.gitignore +.dockerignore +Dockerfile diff --git a/apps/redirect/server/.env.local.example b/apps/redirect/server/.env.local.example new file mode 100644 index 00000000..18e311f7 --- /dev/null +++ b/apps/redirect/server/.env.local.example @@ -0,0 +1,31 @@ +# ============================================================================= +# F3 Redirect — Go redirect tier (redirectd) — env reference +# ============================================================================= +# The Go binary reads os.Getenv directly (no dotenv auto-load). For local runs, +# `set -a; source .env.local; set +a` then `go run ./cmd/redirectd`, or just use +# the CLI against a local file (see apps/redirect/README.md — no cloud needed). +# +# Local TLS/ACME is normally skipped in dev; this is mainly for exercising the +# server against the shared fake-gcs-server (docker-compose.yml, :9023). + +# Flat-file config + cert storage bucket (GCS in prod; emulator locally). +CONFIG_BUCKET=f3-redirects-redirect +CONFIG_OBJECT=config/redirects.json +CERT_PREFIX=certs +# Point the GCS client at the local fake-gcs-server (docker-compose service). +STORAGE_EMULATOR_HOST=http://localhost:9023 + +# Redirect behavior. +REDIRECT_STATUS=302 +REDIRECT_STATIC_IP=34.172.36.60 +RELOAD_INTERVAL=30s +HTTP_ADDR=:8080 +HTTPS_ADDR=:8443 + +# ACME (Let's Encrypt). Use staging while testing to avoid rate limits. +ACME_EMAIL=admin@f3nation.com +ACME_STAGING=true + +# Optional admin reverse-proxy (BOTH or NEITHER): +# ADMIN_HOST=admin.f3regions.com +# ADMIN_UPSTREAM=https://f3-redirect-web-xxxxx.run.app diff --git a/apps/redirect/server/Dockerfile b/apps/redirect/server/Dockerfile new file mode 100644 index 00000000..bc0a0ab1 --- /dev/null +++ b/apps/redirect/server/Dockerfile @@ -0,0 +1,16 @@ +# Multi-stage build for the redirect tier (redirectd). +# Produces a small static binary on a distroless base. +FROM golang:1.26-alpine AS build +WORKDIR /src +# Cache deps first. +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/redirectd ./cmd/redirectd + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /out/redirectd /redirectd +# 80 for ACME HTTP-01 + plain redirects; 443 for TLS we terminate ourselves. +# Runs as root so it can bind the privileged ports 80/443 on the host network. +EXPOSE 80 443 +ENTRYPOINT ["/redirectd"] diff --git a/apps/redirect/server/cmd/f3redirect/main.go b/apps/redirect/server/cmd/f3redirect/main.go new file mode 100644 index 00000000..42561405 --- /dev/null +++ b/apps/redirect/server/cmd/f3redirect/main.go @@ -0,0 +1,272 @@ +// Command f3redirect is the admin CLI for the redirect service. It reads and +// writes the flat-file config (local file or the GCS object — no database) and +// prints the DNS records a tenant must create to activate a redirect. +// +// Storage selection: +// +// --file local JSON file +// --bucket --object GCS object (default object: config/redirects.json) +// +// Falls back to env CONFIG_FILE, or CONFIG_BUCKET/CONFIG_OBJECT, when flags are +// omitted. +// +// Subcommands: +// +// list list all mappings +// add add or replace a mapping +// remove remove a mapping +// dns [host] print DNS instructions (all hosts, or one) +// validate validate the current config +package main + +import ( + "context" + "flag" + "fmt" + "os" + "text/tabwriter" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + usage() + return fmt.Errorf("a subcommand is required") + } + cmd, rest := args[0], args[1:] + + switch cmd { + case "list": + return cmdList(rest) + case "add": + return cmdAdd(rest) + case "remove", "rm": + return cmdRemove(rest) + case "dns": + return cmdDNS(rest) + case "validate": + return cmdValidate(rest) + case "-h", "--help", "help": + usage() + return nil + default: + usage() + return fmt.Errorf("unknown subcommand %q", cmd) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `f3redirect — admin CLI for the redirect service + +Usage: + f3redirect [flags] + +Commands: + list List all mappings + add Add or replace a mapping + remove Remove a mapping + dns [host] Print DNS instructions (all, or one host) + validate Validate the config + +Storage flags (any command): + --file Local JSON config file + --bucket GCS bucket + --object GCS object (default: config/redirects.json) + +DNS flags (dns command): + --static-ip Static IP that apex A-records point to + --canonical-host Hostname that subdomains CNAME to +`) +} + +// storageFlags adds the storage-selection flags to fs and returns a resolver. +func storageFlags(fs *flag.FlagSet) func() (mappings.Store, func(), error) { + file := fs.String("file", os.Getenv("CONFIG_FILE"), "local JSON config file") + bucket := fs.String("bucket", os.Getenv("CONFIG_BUCKET"), "GCS bucket") + object := fs.String("object", envDefault("CONFIG_OBJECT", "config/redirects.json"), "GCS object") + return func() (mappings.Store, func(), error) { + if *file != "" { + return mappings.NewFileStore(*file), func() {}, nil + } + if *bucket == "" { + return nil, nil, fmt.Errorf("no storage configured: pass --file, or --bucket (or set CONFIG_FILE / CONFIG_BUCKET)") + } + gs, err := mappings.NewGCSStore(context.Background(), *bucket, *object) + if err != nil { + return nil, nil, err + } + return gs, func() { _ = gs.Close() }, nil + } +} + +func cmdList(args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + open := storageFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + store, closeFn, err := open() + if err != nil { + return err + } + defer closeFn() + cfg, err := store.Load(context.Background()) + if err != nil { + return err + } + if len(cfg.Mappings) == 0 { + fmt.Println("(no mappings)") + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "HOST\tKIND\tTARGET") + for _, h := range cfg.Hosts() { + target, _ := cfg.Resolve(h) + kind := "subdomain" + if mappings.IsApex(h) { + kind = "apex" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", h, kind, target) + } + return tw.Flush() +} + +func cmdAdd(args []string) error { + fs := flag.NewFlagSet("add", flag.ContinueOnError) + open := storageFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 2 { + return fmt.Errorf("usage: f3redirect add ") + } + host, target := fs.Arg(0), fs.Arg(1) + store, closeFn, err := open() + if err != nil { + return err + } + defer closeFn() + ctx := context.Background() + cfg, err := store.Load(ctx) + if err != nil { + return err + } + cfg = cfg.Upsert(host, target) + if err := store.Save(ctx, cfg); err != nil { + return err + } + fmt.Printf("added %s -> %s\n", mappings.NormalizeHost(host), target) + return nil +} + +func cmdRemove(args []string) error { + fs := flag.NewFlagSet("remove", flag.ContinueOnError) + open := storageFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: f3redirect remove ") + } + store, closeFn, err := open() + if err != nil { + return err + } + defer closeFn() + ctx := context.Background() + cfg, err := store.Load(ctx) + if err != nil { + return err + } + cfg, removed := cfg.Remove(fs.Arg(0)) + if !removed { + return fmt.Errorf("no mapping for %q", fs.Arg(0)) + } + if err := store.Save(ctx, cfg); err != nil { + return err + } + fmt.Printf("removed %s\n", mappings.NormalizeHost(fs.Arg(0))) + return nil +} + +func cmdValidate(args []string) error { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + open := storageFlags(fs) + if err := fs.Parse(args); err != nil { + return err + } + store, closeFn, err := open() + if err != nil { + return err + } + defer closeFn() + cfg, err := store.Load(context.Background()) + if err != nil { + return err + } + if err := cfg.Validate(); err != nil { + return err + } + fmt.Printf("ok: %d mapping(s) valid\n", len(cfg.Mappings)) + return nil +} + +func cmdDNS(args []string) error { + fs := flag.NewFlagSet("dns", flag.ContinueOnError) + open := storageFlags(fs) + staticIP := fs.String("static-ip", os.Getenv("STATIC_IP"), "static IP for apex A-records") + canonical := fs.String("canonical-host", os.Getenv("CANONICAL_HOST"), "hostname subdomains CNAME to") + if err := fs.Parse(args); err != nil { + return err + } + store, closeFn, err := open() + if err != nil { + return err + } + defer closeFn() + cfg, err := store.Load(context.Background()) + if err != nil { + return err + } + + opt := mappings.DNSOptions{StaticIP: *staticIP, CanonicalHost: *canonical} + if opt.StaticIP == "" { + fmt.Fprintln(os.Stderr, "warning: --static-ip not set; apex A-records will show an empty value") + } + + only := "" + if fs.NArg() == 1 { + only = mappings.NormalizeHost(fs.Arg(0)) + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + fmt.Fprintln(tw, "TYPE\tNAME\tVALUE\tNEEDED") + for _, m := range cfg.Mappings { + if only != "" && mappings.NormalizeHost(m.Host) != only { + continue + } + for _, rec := range mappings.DNSInstructions(m, opt) { + needed := "required" + if rec.Optional { + needed = "recommended" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", rec.Type, rec.Name, rec.Value, needed) + } + } + return tw.Flush() +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/apps/redirect/server/cmd/redirectd/main.go b/apps/redirect/server/cmd/redirectd/main.go new file mode 100644 index 00000000..304c8a69 --- /dev/null +++ b/apps/redirect/server/cmd/redirectd/main.go @@ -0,0 +1,205 @@ +// Command redirectd is the Go redirect tier: an HTTPS server that terminates +// TLS itself (on-demand certs via CertMagic, gated on the registry and stored +// in GCS) and emits 301/302 redirects to each tenant's configured target. +// +// Because it owns port 443 to terminate TLS, it runs on GCE/GKE, not Cloud Run. +// +// Configuration (env): +// +// CONFIG_BUCKET / CONFIG_OBJECT GCS flat-file config (object default: config/redirects.json) +// CONFIG_FILE local JSON config (dev; overrides GCS when set) +// CERT_BUCKET / CERT_PREFIX GCS cert storage (default: CONFIG_BUCKET, prefix "certs") +// ACME_EMAIL Let's Encrypt account contact +// ACME_STAGING "1"/"true" to use the LE staging CA +// REDIRECT_STATUS 301 or 302 (default 302) +// HTTP_ADDR / HTTPS_ADDR listen addresses (default :80 / :443) +// RELOAD_INTERVAL config refresh cadence (default 30s) +package main + +import ( + "context" + "crypto/tls" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/certstore" + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/redirect" + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/server" +) + +func main() { + log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + if err := run(log); err != nil { + log.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(log *slog.Logger) error { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + store, certStorage, cleanup, err := openStorage(ctx) + if err != nil { + return err + } + defer cleanup() + + live, err := redirect.NewLive(ctx, store) + if err != nil { + return err + } + go live.Watch(ctx, parseDur(env("RELOAD_INTERVAL", "30s"), 30*time.Second), func(e error) { + log.Warn("config reload failed; keeping last good config", "err", e) + }) + log.Info("loaded config", "hosts", live.Config().Hosts()) + + // Optional: front a configurable admin host (the web app) via reverse proxy + // on this same TLS-terminating tier. The host can change over time (env). + adminHost := mappings.NormalizeHost(os.Getenv("ADMIN_HOST")) + adminUpstream := os.Getenv("ADMIN_UPSTREAM") + + // Both must be set together — a half-configured proxy (only one present) + // would silently disable the admin host, which is a confusing misconfig. + if (adminHost == "") != (adminUpstream == "") { + return errors.New("ADMIN_HOST and ADMIN_UPSTREAM must be set together (or both unset)") + } + + tlsCfg, acme, err := server.Build(server.Options{ + Storage: certStorage, + Email: os.Getenv("ACME_EMAIL"), + Staging: truthy(os.Getenv("ACME_STAGING")), + Decide: func(ctx context.Context, name string) error { + n := mappings.NormalizeHost(name) + if adminHost != "" && n == adminHost { + return nil + } + if live.IsRegistered(n) { + return nil + } + return errors.New("host not registered: " + name) + }, + }) + if err != nil { + return err + } + + status := http.StatusFound + if os.Getenv("REDIRECT_STATUS") == "301" { + status = http.StatusMovedPermanently + } + handler := redirect.NewHandler(live, status) + + if adminHost != "" && adminUpstream != "" { + proxy, perr := redirect.NewAdminProxy(adminUpstream, adminHost) + if perr != nil { + return perr + } + handler.AdminHost = adminHost + handler.AdminProxy = proxy + // Don't log the raw upstream URL — it can carry credentials/query + // secrets. Log only the host being fronted. + log.Info("admin reverse-proxy enabled", "host", adminHost) + } + + httpsAddr := env("HTTPS_ADDR", ":443") + httpAddr := env("HTTP_ADDR", ":80") + + httpsSrv := &http.Server{ + Addr: httpsAddr, + Handler: handler, + TLSConfig: tlsCfg, + ReadHeaderTimeout: 10 * time.Second, + } + httpSrv := &http.Server{ + Addr: httpAddr, + Handler: acme.HTTPChallengeHandler(handler), + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 2) + go func() { + log.Info("serving HTTPS", "addr", httpsAddr) + ln, lerr := tls.Listen("tcp", httpsAddr, tlsCfg) + if lerr != nil { + errCh <- lerr + return + } + errCh <- httpsSrv.Serve(ln) + }() + go func() { + log.Info("serving HTTP (ACME + redirect)", "addr", httpAddr) + errCh <- httpSrv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + log.Info("shutting down") + sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second) + defer scancel() + _ = httpsSrv.Shutdown(sctx) + _ = httpSrv.Shutdown(sctx) + return nil + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +type storageCloser func() + +func openStorage(ctx context.Context) (mappings.Store, *certstore.GCS, storageCloser, error) { + // Cert storage is always GCS (shared, survives restarts). In pure-local dev + // without a bucket this will fail fast — set CERT_BUCKET/CONFIG_BUCKET. + certBucket := env("CERT_BUCKET", os.Getenv("CONFIG_BUCKET")) + certPrefix := env("CERT_PREFIX", "certs") + + if file := os.Getenv("CONFIG_FILE"); file != "" { + cs, err := certstore.New(ctx, certBucket, certPrefix) + if err != nil { + return nil, nil, func() {}, err + } + return mappings.NewFileStore(file), cs, func() { _ = cs.Close() }, nil + } + + bucket := os.Getenv("CONFIG_BUCKET") + object := env("CONFIG_OBJECT", "config/redirects.json") + gs, err := mappings.NewGCSStore(ctx, bucket, object) + if err != nil { + return nil, nil, func() {}, err + } + cs, err := certstore.New(ctx, certBucket, certPrefix) + if err != nil { + _ = gs.Close() + return nil, nil, func() {}, err + } + return gs, cs, func() { _ = gs.Close(); _ = cs.Close() }, nil +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func parseDur(s string, def time.Duration) time.Duration { + if d, err := time.ParseDuration(s); err == nil { + return d + } + return def +} + +func truthy(s string) bool { + b, _ := strconv.ParseBool(s) + return b +} diff --git a/apps/redirect/server/go.mod b/apps/redirect/server/go.mod new file mode 100644 index 00000000..9cd527b5 --- /dev/null +++ b/apps/redirect/server/go.mod @@ -0,0 +1,68 @@ +module github.com/F3-Nation/f3-nation/apps/redirect/server + +go 1.26 + +require ( + cloud.google.com/go/storage v1.62.2 + github.com/caddyserver/certmagic v0.25.3 + golang.org/x/net v0.55.0 + google.golang.org/api v0.280.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect + google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/apps/redirect/server/go.sum b/apps/redirect/server/go.sum new file mode 100644 index 00000000..5b45ade8 --- /dev/null +++ b/apps/redirect/server/go.sum @@ -0,0 +1,161 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= +cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= +cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4= +cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A= +github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= +github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= +github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= +github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= +go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk= +google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/redirect/server/internal/certstore/gcs.go b/apps/redirect/server/internal/certstore/gcs.go new file mode 100644 index 00000000..7a5f1785 --- /dev/null +++ b/apps/redirect/server/internal/certstore/gcs.go @@ -0,0 +1,237 @@ +// Package certstore implements certmagic.Storage backed by Google Cloud +// Storage so that TLS certificates issued on demand are shared across all +// (ephemeral, autoscaled) instances and survive restarts — never the local +// filesystem default. +package certstore + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "strings" + "time" + + "cloud.google.com/go/storage" + "github.com/caddyserver/certmagic" + "google.golang.org/api/googleapi" + "google.golang.org/api/iterator" +) + +// lockTTL is how long a lock may be held before another instance may steal it +// (guards against an instance dying mid-issuance without unlocking). +const lockTTL = 5 * time.Minute + +// GCS is a certmagic.Storage backed by a GCS bucket. Cert/key material lives +// under Prefix; lock objects live under Prefix + "locks/". +type GCS struct { + client *storage.Client + bucket string + prefix string +} + +// compile-time assertion that GCS satisfies the interface. +var _ certmagic.Storage = (*GCS)(nil) + +// New opens a GCS-backed certmagic storage rooted at gs://bucket/prefix using +// Application Default Credentials. +func New(ctx context.Context, bucket, prefix string) (*GCS, error) { + client, err := storage.NewClient(ctx) + if err != nil { + return nil, fmt.Errorf("gcs client: %w", err) + } + prefix = strings.Trim(prefix, "/") + if prefix != "" { + prefix += "/" + } + return &GCS{client: client, bucket: bucket, prefix: prefix}, nil +} + +// Close releases the underlying client. +func (g *GCS) Close() error { return g.client.Close() } + +func (g *GCS) obj(key string) *storage.ObjectHandle { + return g.client.Bucket(g.bucket).Object(g.prefix + strings.TrimPrefix(key, "/")) +} + +// Store writes value at key. +func (g *GCS) Store(ctx context.Context, key string, value []byte) error { + w := g.obj(key).NewWriter(ctx) + if _, err := w.Write(value); err != nil { + _ = w.Close() + return err + } + return w.Close() +} + +// Load reads the value at key, returning fs.ErrNotExist if absent. +func (g *GCS) Load(ctx context.Context, key string) ([]byte, error) { + r, err := g.obj(key).NewReader(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return nil, fs.ErrNotExist + } + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// Delete removes key (and, if key is a directory prefix, everything under it). +func (g *GCS) Delete(ctx context.Context, key string) error { + err := g.obj(key).Delete(ctx) + if err == nil { + return nil + } + if !errors.Is(err, storage.ErrObjectNotExist) { + return err + } + // Not a leaf object — treat as a directory and delete everything under it. + full := g.prefix + strings.TrimPrefix(key, "/") + it := g.client.Bucket(g.bucket).Objects(ctx, &storage.Query{Prefix: full + "/"}) + deleted := false + for { + attrs, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + return err + } + if err := g.client.Bucket(g.bucket).Object(attrs.Name).Delete(ctx); err != nil && + !errors.Is(err, storage.ErrObjectNotExist) { + return err + } + deleted = true + } + if !deleted { + return fs.ErrNotExist + } + return nil +} + +// Exists reports whether key exists as a leaf or as a directory prefix. +func (g *GCS) Exists(ctx context.Context, key string) bool { + if _, err := g.obj(key).Attrs(ctx); err == nil { + return true + } + full := g.prefix + strings.TrimPrefix(key, "/") + it := g.client.Bucket(g.bucket).Objects(ctx, &storage.Query{Prefix: full + "/"}) + if _, err := it.Next(); err == nil { + return true + } + return false +} + +// List returns keys under path. With recursive=false only immediate children +// are returned (directories included, as "prefix" keys). +func (g *GCS) List(ctx context.Context, path string, recursive bool) ([]string, error) { + full := g.prefix + strings.Trim(path, "/") + if full != "" && !strings.HasSuffix(full, "/") { + full += "/" + } + q := &storage.Query{Prefix: full} + if !recursive { + q.Delimiter = "/" + } + it := g.client.Bucket(g.bucket).Objects(ctx, q) + var keys []string + for { + attrs, err := it.Next() + if errors.Is(err, iterator.Done) { + break + } + if err != nil { + return nil, err + } + name := attrs.Name + if name == "" { + name = attrs.Prefix // a synthetic "directory" when delimited + } + name = strings.TrimSuffix(strings.TrimPrefix(name, g.prefix), "/") + if name != "" { + keys = append(keys, name) + } + } + if len(keys) == 0 { + return nil, fs.ErrNotExist + } + return keys, nil +} + +// Stat returns metadata for key. +func (g *GCS) Stat(ctx context.Context, key string) (certmagic.KeyInfo, error) { + attrs, err := g.obj(key).Attrs(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return certmagic.KeyInfo{}, fs.ErrNotExist + } + if err != nil { + return certmagic.KeyInfo{}, err + } + return certmagic.KeyInfo{ + Key: key, + Modified: attrs.Updated, + Size: attrs.Size, + IsTerminal: true, + }, nil +} + +// --- Locker --------------------------------------------------------------- + +func (g *GCS) lockObj(name string) *storage.ObjectHandle { + safe := strings.ReplaceAll(name, "/", "_") + return g.client.Bucket(g.bucket).Object(g.prefix + "locks/" + safe + ".lock") +} + +// Lock acquires a distributed lock named name, blocking until acquired, the +// context is cancelled, or a stale lock is stolen. +func (g *GCS) Lock(ctx context.Context, name string) error { + obj := g.lockObj(name) + for { + // Atomic create-if-absent. + w := obj.If(storage.Conditions{DoesNotExist: true}).NewWriter(ctx) + _, werr := w.Write([]byte(time.Now().UTC().Format(time.RFC3339))) + if werr == nil { + werr = w.Close() + } + if werr == nil { + return nil // acquired + } + if !isPreconditionFailed(werr) { + return werr + } + // Someone holds it. Steal if stale. + if attrs, err := obj.Attrs(ctx); err == nil { + if time.Since(attrs.Created) > lockTTL { + _ = g.client.Bucket(g.bucket). + Object(obj.ObjectName()). + If(storage.Conditions{GenerationMatch: attrs.Generation}). + Delete(ctx) + continue + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// Unlock releases the named lock. +func (g *GCS) Unlock(ctx context.Context, name string) error { + err := g.lockObj(name).Delete(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return nil + } + return err +} + +func isPreconditionFailed(err error) bool { + var apiErr *googleapi.Error + if errors.As(err, &apiErr) { + return apiErr.Code == 412 + } + return false +} diff --git a/apps/redirect/server/internal/certstore/gcs_test.go b/apps/redirect/server/internal/certstore/gcs_test.go new file mode 100644 index 00000000..b54a531e --- /dev/null +++ b/apps/redirect/server/internal/certstore/gcs_test.go @@ -0,0 +1,217 @@ +package certstore + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "os" + "os/exec" + "sort" + "testing" + "time" + + "cloud.google.com/go/storage" +) + +// These tests exercise OUR certmagic.Storage adapter (error mapping, key +// prefixing/trimming, directory-delete, and the distributed lock's +// conditional-create + release) against a real GCS-compatible backend +// (fsouza/fake-gcs-server) — not the GCS client itself. If docker or the +// emulator isn't available the suite skips loudly (CI runs it for real). + +const ( + testBucket = "certstore-test" + emulatorHost = "localhost:9023" + containerNm = "certstore-fakegcs-test" +) + +func TestMain(m *testing.M) { + code, err := withEmulator(m) + if err != nil { + // In CI, a missing emulator is a hard failure — silently exiting 0 + // would turn a broken GCS adapter into a false green. Locally (no CI + // env) we skip so `go test ./...` stays runnable without docker. + if os.Getenv("CI") != "" { + fmt.Println("FAIL certstore (emulator unavailable in CI):", err) + os.Exit(1) + } + fmt.Println("SKIP certstore (emulator unavailable):", err) + os.Exit(0) + } + os.Exit(code) +} + +func withEmulator(m *testing.M) (int, error) { + if _, err := exec.LookPath("docker"); err != nil { + return 0, fmt.Errorf("docker not on PATH") + } + _ = exec.Command("docker", "rm", "-f", containerNm).Run() + // -public-host must match the emulator address or object media downloads + // (NewReader) resolve to the wrong host and 404, even though the JSON API + // (Stat/List) works. + out, err := exec.Command("docker", "run", "-d", "--name", containerNm, + "-p", "9023:4443", "fsouza/fake-gcs-server:latest", + "-scheme", "http", "-port", "4443", "-backend", "memory", + "-public-host", emulatorHost).CombinedOutput() + if err != nil { + return 0, fmt.Errorf("docker run: %v: %s", err, out) + } + defer exec.Command("docker", "rm", "-f", containerNm).Run() + + os.Setenv("STORAGE_EMULATOR_HOST", emulatorHost) + + ready := false + for i := 0; i < 80; i++ { + resp, err := http.Get("http://" + emulatorHost + "/storage/v1/b?project=test") + if err == nil { + resp.Body.Close() + ready = true + break + } + time.Sleep(250 * time.Millisecond) + } + if !ready { + return 0, fmt.Errorf("emulator never became ready") + } + + ctx := context.Background() + client, err := storage.NewClient(ctx) + if err != nil { + return 0, fmt.Errorf("storage client: %v", err) + } + if err := client.Bucket(testBucket).Create(ctx, "test", nil); err != nil { + client.Close() + return 0, fmt.Errorf("create bucket: %v", err) + } + client.Close() + + return m.Run(), nil +} + +func newStore(t *testing.T, prefix string) *GCS { + t.Helper() + g, err := New(context.Background(), testBucket, prefix) + if err != nil { + t.Fatalf("New: %v", err) + } + t.Cleanup(func() { g.Close() }) + return g +} + +func TestLoadMissingReturnsErrNotExist(t *testing.T) { + g := newStore(t, "certs") + _, err := g.Load(context.Background(), "nope/missing.txt") + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("Load(missing) err = %v, want fs.ErrNotExist (our mapping)", err) + } +} + +func TestStoreLoadDelete(t *testing.T) { + g := newStore(t, "certs") + ctx := context.Background() + key := "roundtrip/key.pem" + want := []byte("cert-bytes") + if err := g.Store(ctx, key, want); err != nil { + t.Fatal(err) + } + got, err := g.Load(ctx, key) + if err != nil || string(got) != string(want) { + t.Fatalf("Load = %q,%v", got, err) + } + if !g.Exists(ctx, key) { + t.Error("Exists should be true after Store") + } + if err := g.Delete(ctx, key); err != nil { + t.Fatal(err) + } + if _, err := g.Load(ctx, key); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Load after Delete err = %v, want fs.ErrNotExist", err) + } +} + +func TestListTrimsPrefix(t *testing.T) { + g := newStore(t, "certs") + ctx := context.Background() + for _, k := range []string{"site/a.pem", "site/b.pem"} { + if err := g.Store(ctx, k, []byte("x")); err != nil { + t.Fatal(err) + } + } + got, err := g.List(ctx, "site", true) + if err != nil { + t.Fatal(err) + } + sort.Strings(got) + want := []string{"site/a.pem", "site/b.pem"} + // Crucially: keys are returned WITHOUT our internal "certs/" prefix — that + // trimming is our adapter's responsibility (certmagic relies on it). + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("List = %v, want %v (prefix must be trimmed)", got, want) + } +} + +func TestDeleteDirectoryPrefix(t *testing.T) { + g := newStore(t, "certs") + ctx := context.Background() + for _, k := range []string{"dir/x.pem", "dir/y.pem"} { + if err := g.Store(ctx, k, []byte("x")); err != nil { + t.Fatal(err) + } + } + // Deleting the directory key removes everything under it. + if err := g.Delete(ctx, "dir"); err != nil { + t.Fatalf("Delete(dir): %v", err) + } + if g.Exists(ctx, "dir/x.pem") || g.Exists(ctx, "dir/y.pem") { + t.Error("directory delete should remove all keys under the prefix") + } +} + +func TestStat(t *testing.T) { + g := newStore(t, "certs") + ctx := context.Background() + if err := g.Store(ctx, "s/info.pem", []byte("hello")); err != nil { + t.Fatal(err) + } + ki, err := g.Stat(ctx, "s/info.pem") + if err != nil { + t.Fatal(err) + } + if ki.Key != "s/info.pem" || ki.Size != 5 { + t.Errorf("Stat = %+v, want Key=s/info.pem Size=5", ki) + } + if _, err := g.Stat(ctx, "s/missing.pem"); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Stat(missing) err = %v, want fs.ErrNotExist", err) + } +} + +func TestLockMutualExclusionAndRelease(t *testing.T) { + g := newStore(t, "certs") + ctx := context.Background() + const name = "issue-cert-lock" + + if err := g.Lock(ctx, name); err != nil { + t.Fatalf("first Lock: %v", err) + } + + // A second acquirer must NOT get the lock while it's held — it blocks until + // our short context deadline. + short, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + if err := g.Lock(short, name); err == nil { + t.Fatal("second Lock acquired while held — mutual exclusion broken") + } + + // After Unlock, it can be acquired again. + if err := g.Unlock(ctx, name); err != nil { + t.Fatalf("Unlock: %v", err) + } + reacquire, cancel2 := context.WithTimeout(ctx, 5*time.Second) + defer cancel2() + if err := g.Lock(reacquire, name); err != nil { + t.Fatalf("Lock after Unlock should succeed: %v", err) + } + _ = g.Unlock(ctx, name) +} diff --git a/apps/redirect/server/internal/mappings/dns.go b/apps/redirect/server/internal/mappings/dns.go new file mode 100644 index 00000000..0e23905d --- /dev/null +++ b/apps/redirect/server/internal/mappings/dns.go @@ -0,0 +1,84 @@ +package mappings + +import "fmt" + +// DNSRecord is a single DNS record a tenant must create to activate a redirect. +type DNSRecord struct { + Type string // "A" or "CNAME" + Name string // the record name (the host itself) + Value string // the value: a static IP (A) or our canonical hostname (CNAME) + Note string // human-friendly explanation + // Optional reports whether the record is merely recommended (true) vs. + // required to activate the redirect (false). + Optional bool +} + +// DNSOptions describe our serving endpoint so we can tell tenants what to point +// their domains at. +type DNSOptions struct { + // StaticIP is the reserved public IP of the redirect tier. Apex domains + // point an A record here. + StaticIP string + // CanonicalHost, when set, is the hostname subdomains should CNAME to. When + // empty, subdomains are told to CNAME to their own apex (which must itself + // carry an A record to StaticIP). + CanonicalHost string +} + +// DNSInstructions returns the DNS record(s) required to activate the mapping. +// This mirrors the web app's dnsInstructions() exactly (see the shared +// contract in testdata/dns-instructions.json): +// +// - apex: a required A record to StaticIP, plus a recommended CNAME so the +// www subdomain redirects too (apex can't CNAME itself). +// - subdomain: a single required CNAME to CanonicalHost (or, if unset, to its +// own apex, which must carry the A record). +func DNSInstructions(m Mapping, opt DNSOptions) []DNSRecord { + host := NormalizeHost(m.Host) + + if IsApex(host) { + // Never emit a required A record with an empty value; if the static IP + // isn't configured, surface a placeholder + actionable note instead. + apexValue := opt.StaticIP + apexNote := fmt.Sprintf("Required: %s is an apex domain and cannot use a CNAME, so point an A record at the redirect tier's static IP.", host) + if apexValue == "" { + apexValue = "" + apexNote = fmt.Sprintf("Required: %s is an apex domain and cannot use a CNAME. The redirect tier's static IP is not configured yet — contact the administrator before creating this A record.", host) + } + return []DNSRecord{ + { + Type: "A", + Name: host, + Value: apexValue, + Note: apexNote, + Optional: false, + }, + { + Type: "CNAME", + Name: "www." + host, + Value: host, + Note: fmt.Sprintf("Recommended: so www.%s redirects too. Point it at %s (which carries the A record above).", host, host), + Optional: true, + }, + } + } + + if opt.CanonicalHost != "" { + return []DNSRecord{{ + Type: "CNAME", + Name: host, + Value: opt.CanonicalHost, + Note: fmt.Sprintf("Required: %s is a subdomain; add a single CNAME to %s. No A record is needed.", host, opt.CanonicalHost), + Optional: false, + }} + } + + apex := ApexOf(host) + return []DNSRecord{{ + Type: "CNAME", + Name: host, + Value: apex, + Note: fmt.Sprintf("Required: %s is a subdomain; CNAME it to %s (which must carry an A record to %s).", host, apex, opt.StaticIP), + Optional: false, + }} +} diff --git a/apps/redirect/server/internal/mappings/dns_parity_test.go b/apps/redirect/server/internal/mappings/dns_parity_test.go new file mode 100644 index 00000000..5fc5ff4e --- /dev/null +++ b/apps/redirect/server/internal/mappings/dns_parity_test.go @@ -0,0 +1,74 @@ +package mappings + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +// The DNS-instruction rules are implemented in BOTH Go (here) and TypeScript +// (web/src/lib/domains.ts). This test asserts the Go implementation matches the +// shared contract in testdata/dns-instructions.json; an equivalent test in the +// web suite asserts the TS side. If either drifts, one suite goes red. + +type parityRecord struct { + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + Optional bool `json:"optional"` +} + +type parityCase struct { + Name string `json:"name"` + Host string `json:"host"` + Options struct { + StaticIP string `json:"staticIP"` + CanonicalHost string `json:"canonicalHost"` + } `json:"options"` + Records []parityRecord `json:"records"` +} + +func sortParity(rs []parityRecord) { + sort.Slice(rs, func(i, j int) bool { + if rs[i].Type != rs[j].Type { + return rs[i].Type < rs[j].Type + } + return rs[i].Name < rs[j].Name + }) +} + +func TestDNSInstructionsParity(t *testing.T) { + b, err := os.ReadFile(filepath.Join("..", "..", "..", "shared", "dns-instructions.json")) + if err != nil { + t.Fatalf("read shared fixture: %v", err) + } + var fx struct { + Cases []parityCase `json:"cases"` + } + if err := json.Unmarshal(b, &fx); err != nil { + t.Fatalf("parse fixture: %v", err) + } + if len(fx.Cases) == 0 { + t.Fatal("fixture has no cases") + } + + for _, c := range fx.Cases { + got := DNSInstructions( + Mapping{Host: c.Host}, + DNSOptions{StaticIP: c.Options.StaticIP, CanonicalHost: c.Options.CanonicalHost}, + ) + gotRecs := make([]parityRecord, 0, len(got)) + for _, r := range got { + gotRecs = append(gotRecs, parityRecord{Type: r.Type, Name: r.Name, Value: r.Value, Optional: r.Optional}) + } + want := append([]parityRecord(nil), c.Records...) + sortParity(gotRecs) + sortParity(want) + if !reflect.DeepEqual(gotRecs, want) { + t.Errorf("case %q (host %s): \n got=%+v\nwant=%+v", c.Name, c.Host, gotRecs, want) + } + } +} diff --git a/apps/redirect/server/internal/mappings/dns_test.go b/apps/redirect/server/internal/mappings/dns_test.go new file mode 100644 index 00000000..3b31a96c --- /dev/null +++ b/apps/redirect/server/internal/mappings/dns_test.go @@ -0,0 +1,47 @@ +package mappings + +import "testing" + +func TestDNSInstructionsApex(t *testing.T) { + recs := DNSInstructions( + Mapping{Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + DNSOptions{StaticIP: "203.0.113.10"}, + ) + // Required A record first. + if recs[0].Type != "A" || recs[0].Name != "f3muletown.com" || recs[0].Value != "203.0.113.10" || recs[0].Optional { + t.Errorf("apex required A record = %+v", recs[0]) + } + // Plus a recommended www CNAME pointing at the apex. + var www *DNSRecord + for i := range recs { + if recs[i].Name == "www.f3muletown.com" { + www = &recs[i] + } + } + if www == nil || www.Type != "CNAME" || www.Value != "f3muletown.com" || !www.Optional { + t.Errorf("apex should also recommend a www CNAME, got %+v", recs) + } +} + +func TestDNSInstructionsSubdomainCanonical(t *testing.T) { + recs := DNSInstructions( + Mapping{Host: "stats.f3muletown.com", Target: "https://x"}, + DNSOptions{StaticIP: "203.0.113.10", CanonicalHost: "redirect.f3nation.com"}, + ) + if len(recs) != 1 || recs[0].Type != "CNAME" { + t.Fatalf("subdomain should yield 1 CNAME, got %+v", recs) + } + if recs[0].Value != "redirect.f3nation.com" { + t.Errorf("CNAME value = %q, want canonical host", recs[0].Value) + } +} + +func TestDNSInstructionsSubdomainFallsBackToApex(t *testing.T) { + recs := DNSInstructions( + Mapping{Host: "www.f3marshall.com", Target: "https://x"}, + DNSOptions{StaticIP: "203.0.113.10"}, // no canonical host + ) + if recs[0].Type != "CNAME" || recs[0].Value != "f3marshall.com" { + t.Errorf("subdomain fallback = %+v, want CNAME to apex", recs[0]) + } +} diff --git a/apps/redirect/server/internal/mappings/filestore.go b/apps/redirect/server/internal/mappings/filestore.go new file mode 100644 index 00000000..fa15829e --- /dev/null +++ b/apps/redirect/server/internal/mappings/filestore.go @@ -0,0 +1,60 @@ +package mappings + +import ( + "context" + "errors" + "io/fs" + "os" + "path/filepath" +) + +// FileStore persists the Config to a local JSON file. Used for local +// development and tests. A missing file loads as an empty config. +type FileStore struct { + Path string +} + +// NewFileStore returns a FileStore at path. +func NewFileStore(path string) *FileStore { return &FileStore{Path: path} } + +// Load reads and parses the file; a non-existent file is an empty config. +func (s *FileStore) Load(ctx context.Context) (Config, error) { + b, err := os.ReadFile(s.Path) + if errors.Is(err, fs.ErrNotExist) { + return Config{}, nil + } + if err != nil { + return Config{}, err + } + return Unmarshal(b) +} + +// Save validates then atomically writes the config (temp file + rename). +func (s *FileStore) Save(ctx context.Context, cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + b, err := Marshal(cfg) + if err != nil { + return err + } + if dir := filepath.Dir(s.Path); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + tmp, err := os.CreateTemp(filepath.Dir(s.Path), ".redirects-*.json") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(b); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, s.Path) +} diff --git a/apps/redirect/server/internal/mappings/gcsstore.go b/apps/redirect/server/internal/mappings/gcsstore.go new file mode 100644 index 00000000..d4bad73b --- /dev/null +++ b/apps/redirect/server/internal/mappings/gcsstore.go @@ -0,0 +1,69 @@ +package mappings + +import ( + "context" + "errors" + "fmt" + "io" + + "cloud.google.com/go/storage" +) + +// GCSStore persists the Config as a single JSON object in a GCS bucket. This is +// the production source of truth — a flat file, no database. +type GCSStore struct { + client *storage.Client + bucket string + object string +} + +// NewGCSStore opens a GCS-backed store for gs://bucket/object using Application +// Default Credentials. Call Close when done. +func NewGCSStore(ctx context.Context, bucket, object string) (*GCSStore, error) { + client, err := storage.NewClient(ctx) + if err != nil { + return nil, fmt.Errorf("gcs client: %w", err) + } + return &GCSStore{client: client, bucket: bucket, object: object}, nil +} + +// Close releases the underlying GCS client. +func (s *GCSStore) Close() error { return s.client.Close() } + +// Load reads the config object; a missing object loads as an empty config. +func (s *GCSStore) Load(ctx context.Context) (Config, error) { + r, err := s.client.Bucket(s.bucket).Object(s.object).NewReader(ctx) + if errors.Is(err, storage.ErrObjectNotExist) { + return Config{}, nil + } + if err != nil { + return Config{}, fmt.Errorf("gcs read %s/%s: %w", s.bucket, s.object, err) + } + defer r.Close() + b, err := io.ReadAll(r) + if err != nil { + return Config{}, err + } + return Unmarshal(b) +} + +// Save validates then writes the config object. +func (s *GCSStore) Save(ctx context.Context, cfg Config) error { + if err := cfg.Validate(); err != nil { + return err + } + b, err := Marshal(cfg) + if err != nil { + return err + } + w := s.client.Bucket(s.bucket).Object(s.object).NewWriter(ctx) + w.ContentType = "application/json" + if _, err := w.Write(b); err != nil { + _ = w.Close() + return fmt.Errorf("gcs write %s/%s: %w", s.bucket, s.object, err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("gcs write %s/%s: %w", s.bucket, s.object, err) + } + return nil +} diff --git a/apps/redirect/server/internal/mappings/mappings.go b/apps/redirect/server/internal/mappings/mappings.go new file mode 100644 index 00000000..756298e5 --- /dev/null +++ b/apps/redirect/server/internal/mappings/mappings.go @@ -0,0 +1,181 @@ +// Package mappings is the pure-logic core of the redirect service: the config +// model (host -> target URL), host resolution, validation, and the DNS +// instructions a tenant must apply to point their domain at us. +// +// It has no cloud dependencies so it can be unit-tested in full. Storage +// backends (local file, GCS) live alongside in this package but behind the +// Store interface; cloud wiring lives in other packages. +package mappings + +import ( + "fmt" + "net/url" + "sort" + "strings" + + "golang.org/x/net/publicsuffix" +) + +// Mapping is a single redirect rule: any request whose Host equals Host is +// redirected to Target. +type Mapping struct { + Host string `json:"host"` + Target string `json:"target"` +} + +// Config is the entire redirect configuration — a flat list of mappings. This +// is what lives in the single JSON file in GCS. No database. +type Config struct { + Mappings []Mapping `json:"mappings"` +} + +// NormalizeHost lower-cases a hostname and strips a trailing dot and any port. +func NormalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + host = strings.TrimSuffix(host, ".") + if i := strings.IndexByte(host, ':'); i >= 0 { + host = host[:i] + } + return host +} + +// Validate checks that every mapping has a usable host and an absolute http(s) +// target, and that no host is registered twice. +func (c Config) Validate() error { + seen := make(map[string]struct{}, len(c.Mappings)) + for i, m := range c.Mappings { + host := NormalizeHost(m.Host) + if host == "" { + return fmt.Errorf("mapping %d: empty host", i) + } + if !strings.Contains(host, ".") { + return fmt.Errorf("mapping %d (%q): host must be a fully-qualified domain", i, m.Host) + } + if _, dup := seen[host]; dup { + return fmt.Errorf("mapping %d: duplicate host %q", i, host) + } + seen[host] = struct{}{} + + u, err := url.Parse(m.Target) + if err != nil { + return fmt.Errorf("mapping %d (%q): invalid target: %w", i, m.Host, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("mapping %d (%q): target must be an absolute http(s) URL", i, m.Host) + } + if u.Host == "" { + return fmt.Errorf("mapping %d (%q): target must include a host", i, m.Host) + } + } + return nil +} + +// index builds a normalized host -> target lookup. Callers should treat it as +// read-only. +func (c Config) index() map[string]string { + m := make(map[string]string, len(c.Mappings)) + for _, mp := range c.Mappings { + m[NormalizeHost(mp.Host)] = mp.Target + } + return m +} + +// Resolve returns the target URL for the given request host, or ("", false) if +// the host is not registered. +// +// As a convenience, the "www." variant of a registered host inherits that +// host's target (e.g. registering f3muletown.com also serves +// www.f3muletown.com). An explicit mapping for the www host always wins. This +// keeps the "recommended www CNAME" honest: the redirect tier serves www and, +// because IsRegistered flows through here, the on-demand TLS gate issues its +// certificate too. +func (c Config) Resolve(host string) (string, bool) { + idx := c.index() + h := NormalizeHost(host) + if t, ok := idx[h]; ok { + return t, true + } + if rest, found := strings.CutPrefix(h, "www."); found { + if t, ok := idx[rest]; ok { + return t, true + } + } + return "", false +} + +// IsRegistered reports whether host has a mapping. This is the gate the +// on-demand TLS decision function uses before allowing a certificate to be +// issued for an incoming hostname. +func (c Config) IsRegistered(host string) bool { + _, ok := c.Resolve(host) + return ok +} + +// Hosts returns the registered hostnames, normalized and sorted. +func (c Config) Hosts() []string { + hosts := make([]string, 0, len(c.Mappings)) + for _, m := range c.Mappings { + hosts = append(hosts, NormalizeHost(m.Host)) + } + sort.Strings(hosts) + return hosts +} + +// Upsert adds or replaces the mapping for host. Returns a new Config; the +// receiver is not mutated. +func (c Config) Upsert(host, target string) Config { + host = NormalizeHost(host) + out := Config{Mappings: make([]Mapping, 0, len(c.Mappings)+1)} + replaced := false + for _, m := range c.Mappings { + if NormalizeHost(m.Host) == host { + out.Mappings = append(out.Mappings, Mapping{Host: host, Target: target}) + replaced = true + continue + } + out.Mappings = append(out.Mappings, m) + } + if !replaced { + out.Mappings = append(out.Mappings, Mapping{Host: host, Target: target}) + } + return out +} + +// Remove deletes the mapping for host. Returns the new Config and whether a +// mapping was actually removed. +func (c Config) Remove(host string) (Config, bool) { + host = NormalizeHost(host) + out := Config{Mappings: make([]Mapping, 0, len(c.Mappings))} + removed := false + for _, m := range c.Mappings { + if NormalizeHost(m.Host) == host { + removed = true + continue + } + out.Mappings = append(out.Mappings, m) + } + return out, removed +} + +// IsApex reports whether host is a registrable (apex/root) domain — i.e. it +// equals its own eTLD+1 (e.g. "f3muletown.com") rather than a subdomain +// ("stats.f3muletown.com"). Apex domains cannot use a CNAME. +func IsApex(host string) bool { + host = NormalizeHost(host) + etld1, err := publicsuffix.EffectiveTLDPlusOne(host) + if err != nil { + // Fall back to a label count if the suffix list can't classify it. + return strings.Count(host, ".") == 1 + } + return host == etld1 +} + +// ApexOf returns the registrable (eTLD+1) domain for host, e.g. +// "stats.f3muletown.com" -> "f3muletown.com". +func ApexOf(host string) string { + host = NormalizeHost(host) + if etld1, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil { + return etld1 + } + return host +} diff --git a/apps/redirect/server/internal/mappings/mappings_test.go b/apps/redirect/server/internal/mappings/mappings_test.go new file mode 100644 index 00000000..0972e075 --- /dev/null +++ b/apps/redirect/server/internal/mappings/mappings_test.go @@ -0,0 +1,153 @@ +package mappings + +import "testing" + +func sample() Config { + return Config{Mappings: []Mapping{ + {Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + {Host: "www.f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + {Host: "stats.f3muletown.com", Target: "https://pax-vault.f3nation.com/stats/region/35838"}, + {Host: "f3marshall.com", Target: "https://regions.f3nation.com/marshall-tn"}, + }} +} + +func TestNormalizeHost(t *testing.T) { + cases := map[string]string{ + "F3Muletown.com": "f3muletown.com", + "f3muletown.com.": "f3muletown.com", + " STATS.f3muletown.com": "stats.f3muletown.com", + "f3muletown.com:443": "f3muletown.com", + } + for in, want := range cases { + if got := NormalizeHost(in); got != want { + t.Errorf("NormalizeHost(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveAndRegistered(t *testing.T) { + c := sample() + if tgt, ok := c.Resolve("F3Muletown.com"); !ok || tgt != "https://regions.f3nation.com/muletown" { + t.Errorf("Resolve apex (case-insensitive) = %q,%v", tgt, ok) + } + if tgt, ok := c.Resolve("stats.f3muletown.com."); !ok || tgt != "https://pax-vault.f3nation.com/stats/region/35838" { + t.Errorf("Resolve subdomain = %q,%v", tgt, ok) + } + if _, ok := c.Resolve("evil.example.com"); ok { + t.Error("unregistered host should not resolve") + } + if !c.IsRegistered("www.f3muletown.com") { + t.Error("IsRegistered should be true for registered host") + } + if c.IsRegistered("nope.f3muletown.com") { + t.Error("IsRegistered should be false for unregistered host") + } +} + +func TestResolveWWWInheritsApex(t *testing.T) { + c := Config{Mappings: []Mapping{ + {Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + }} + // www of a registered apex inherits the apex's target... + if tgt, ok := c.Resolve("www.f3muletown.com"); !ok || tgt != "https://regions.f3nation.com/muletown" { + t.Errorf("www should inherit apex target: got %q ok=%v", tgt, ok) + } + // ...and is considered registered (so the on-demand TLS gate issues a cert). + if !c.IsRegistered("www.f3muletown.com") { + t.Error("www of a registered apex should be registered for the TLS gate") + } + // www of an unregistered host stays unregistered. + if c.IsRegistered("www.notregistered.com") { + t.Error("www of an unregistered host must not be registered") + } + // An explicit www registration wins over the apex inheritance. + c2 := Config{Mappings: []Mapping{ + {Host: "f3muletown.com", Target: "https://apex"}, + {Host: "www.f3muletown.com", Target: "https://explicit-www"}, + }} + if tgt, _ := c2.Resolve("www.f3muletown.com"); tgt != "https://explicit-www" { + t.Errorf("explicit www mapping should take precedence: got %q", tgt) + } +} + +func TestUpsertAndRemove(t *testing.T) { + c := sample() + n := len(c.Mappings) + + c2 := c.Upsert("new.example.com", "https://example.org/x") + if len(c2.Mappings) != n+1 { + t.Fatalf("Upsert add: len=%d want %d", len(c2.Mappings), n+1) + } + if tgt, _ := c2.Resolve("new.example.com"); tgt != "https://example.org/x" { + t.Errorf("Upsert add target = %q", tgt) + } + + c3 := c2.Upsert("NEW.example.com", "https://example.org/y") + if len(c3.Mappings) != n+1 { + t.Errorf("Upsert replace should not grow: len=%d want %d", len(c3.Mappings), n+1) + } + if tgt, _ := c3.Resolve("new.example.com"); tgt != "https://example.org/y" { + t.Errorf("Upsert replace target = %q", tgt) + } + + c4, removed := c3.Remove("new.example.com") + if !removed || len(c4.Mappings) != n { + t.Errorf("Remove: removed=%v len=%d want %d", removed, len(c4.Mappings), n) + } + if _, gone := c4.Remove("does-not-exist.com"); gone { + t.Error("Remove of missing host should report false") + } + + // receiver immutability + if len(c.Mappings) != n { + t.Errorf("original mutated: len=%d want %d", len(c.Mappings), n) + } +} + +func TestValidate(t *testing.T) { + if err := sample().Validate(); err != nil { + t.Fatalf("sample should be valid: %v", err) + } + bad := []Config{ + {Mappings: []Mapping{{Host: "", Target: "https://x.com"}}}, + {Mappings: []Mapping{{Host: "nodot", Target: "https://x.com"}}}, + {Mappings: []Mapping{{Host: "a.com", Target: "ftp://x.com"}}}, + {Mappings: []Mapping{{Host: "a.com", Target: "/relative"}}}, + {Mappings: []Mapping{{Host: "a.com", Target: "https://x.com"}, {Host: "A.com", Target: "https://y.com"}}}, + } + for i, c := range bad { + if err := c.Validate(); err == nil { + t.Errorf("bad config %d should fail validation", i) + } + } +} + +func TestApex(t *testing.T) { + cases := map[string]bool{ + "f3muletown.com": true, + "f3marshall.com": true, + "www.f3muletown.com": false, + "stats.f3muletown.com": false, + } + for host, want := range cases { + if got := IsApex(host); got != want { + t.Errorf("IsApex(%q) = %v, want %v", host, got, want) + } + } + if got := ApexOf("stats.f3muletown.com"); got != "f3muletown.com" { + t.Errorf("ApexOf = %q", got) + } +} + +func TestHosts(t *testing.T) { + got := sample().Hosts() + want := []string{"f3marshall.com", "f3muletown.com", "stats.f3muletown.com", "www.f3muletown.com"} + if len(got) != len(want) { + t.Fatalf("Hosts len = %d want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("Hosts[%d] = %q want %q (not sorted?)", i, got[i], want[i]) + } + } +} diff --git a/apps/redirect/server/internal/mappings/store.go b/apps/redirect/server/internal/mappings/store.go new file mode 100644 index 00000000..3adb4df6 --- /dev/null +++ b/apps/redirect/server/internal/mappings/store.go @@ -0,0 +1,51 @@ +package mappings + +import ( + "context" + "encoding/json" + "fmt" +) + +// Store loads and saves the redirect Config. Implementations: FileStore (local +// JSON file, for dev) and GCSStore (a single JSON object in a GCS bucket, the +// production source of truth). +type Store interface { + Load(ctx context.Context) (Config, error) + Save(ctx context.Context, cfg Config) error +} + +// Marshal renders a Config as stable, pretty JSON (sorted by host) suitable for +// the flat file. +func Marshal(cfg Config) ([]byte, error) { + out := Config{Mappings: append([]Mapping(nil), cfg.Mappings...)} + sortMappings(out.Mappings) + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} + +// Unmarshal parses the flat-file bytes into a Config. Empty input yields an +// empty config (a fresh deployment). +func Unmarshal(b []byte) (Config, error) { + var cfg Config + if len(b) == 0 { + return cfg, nil + } + if err := json.Unmarshal(b, &cfg); err != nil { + return Config{}, fmt.Errorf("parse config: %w", err) + } + return cfg, nil +} + +func sortMappings(ms []Mapping) { + // insertion sort by normalized host — tiny n, avoids importing sort twice + for i := 1; i < len(ms); i++ { + j := i + for j > 0 && NormalizeHost(ms[j-1].Host) > NormalizeHost(ms[j].Host) { + ms[j-1], ms[j] = ms[j], ms[j-1] + j-- + } + } +} diff --git a/apps/redirect/server/internal/mappings/store_test.go b/apps/redirect/server/internal/mappings/store_test.go new file mode 100644 index 00000000..4838d36b --- /dev/null +++ b/apps/redirect/server/internal/mappings/store_test.go @@ -0,0 +1,67 @@ +package mappings + +import ( + "context" + "path/filepath" + "testing" +) + +func TestMarshalUnmarshalRoundTrip(t *testing.T) { + in := sample() + b, err := Marshal(in) + if err != nil { + t.Fatal(err) + } + out, err := Unmarshal(b) + if err != nil { + t.Fatal(err) + } + if len(out.Mappings) != len(in.Mappings) { + t.Fatalf("roundtrip len = %d want %d", len(out.Mappings), len(in.Mappings)) + } + // Marshal sorts by host: first should be the alphabetically-first host. + if out.Mappings[0].Host != "f3marshall.com" { + t.Errorf("Marshal not sorted: first host = %q", out.Mappings[0].Host) + } +} + +func TestUnmarshalEmpty(t *testing.T) { + c, err := Unmarshal(nil) + if err != nil || len(c.Mappings) != 0 { + t.Errorf("empty unmarshal = %+v, %v", c, err) + } +} + +func TestFileStoreRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "redirects.json") + store := NewFileStore(path) + ctx := context.Background() + + // Missing file loads empty. + c, err := store.Load(ctx) + if err != nil || len(c.Mappings) != 0 { + t.Fatalf("missing file should load empty: %+v %v", c, err) + } + + if err := store.Save(ctx, sample()); err != nil { + t.Fatalf("save: %v", err) + } + got, err := store.Load(ctx) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(got.Mappings) != len(sample().Mappings) { + t.Errorf("reloaded len = %d want %d", len(got.Mappings), len(sample().Mappings)) + } + if tgt, ok := got.Resolve("f3muletown.com"); !ok || tgt == "" { + t.Error("reloaded config lost a mapping") + } +} + +func TestFileStoreRejectsInvalid(t *testing.T) { + store := NewFileStore(filepath.Join(t.TempDir(), "c.json")) + err := store.Save(context.Background(), Config{Mappings: []Mapping{{Host: "bad", Target: "nope"}}}) + if err == nil { + t.Error("saving invalid config should fail validation") + } +} diff --git a/apps/redirect/server/internal/redirect/integration_test.go b/apps/redirect/server/internal/redirect/integration_test.go new file mode 100644 index 00000000..a846434a --- /dev/null +++ b/apps/redirect/server/internal/redirect/integration_test.go @@ -0,0 +1,103 @@ +package redirect_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/redirect" +) + +// Integration test: a real HTTP server through the handler, backed by a +// file-store-backed Live config, exercising redirect, 404, healthz, and the +// admin reverse-proxy path end-to-end over the wire. +func TestServerIntegration(t *testing.T) { + dir := t.TempDir() + store := mappings.NewFileStore(dir + "/c.json") + cfg := mappings.Config{Mappings: []mappings.Mapping{ + {Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + }} + if err := store.Save(context.Background(), cfg); err != nil { + t.Fatal(err) + } + live, err := redirect.NewLive(context.Background(), store) + if err != nil { + t.Fatal(err) + } + + // Upstream the admin proxy forwards to. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "ADMIN-APP host="+r.Header.Get("X-Forwarded-Host")) + })) + defer upstream.Close() + up, _ := redirect.NewAdminProxy(upstream.URL, "admin.example.com") + + h := redirect.NewHandler(live, http.StatusFound) + h.AdminHost = "admin.example.com" + h.AdminProxy = up + + srv := httptest.NewServer(h) + defer srv.Close() + + // no-redirect client so we can read the 302 Location. + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + + t.Run("redirect", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/whatever", nil) + req.Host = "f3muletown.com" + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("status=%d want 302", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "https://regions.f3nation.com/muletown" { + t.Errorf("Location=%q", loc) + } + }) + + t.Run("unknown host 404", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + req.Host = "nope.example.com" + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("status=%d want 404", resp.StatusCode) + } + }) + + t.Run("healthz", func(t *testing.T) { + resp, err := client.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || string(b) != "ok" { + t.Errorf("healthz=%d %q", resp.StatusCode, b) + } + }) + + t.Run("admin proxied with forwarded host", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + req.Host = "admin.example.com" + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(b), "ADMIN-APP") || !strings.Contains(string(b), "host=admin.example.com") { + t.Errorf("admin proxy body=%q (want ADMIN-APP + forwarded host)", b) + } + }) +} diff --git a/apps/redirect/server/internal/redirect/live.go b/apps/redirect/server/internal/redirect/live.go new file mode 100644 index 00000000..ffaa40e6 --- /dev/null +++ b/apps/redirect/server/internal/redirect/live.go @@ -0,0 +1,70 @@ +package redirect + +import ( + "context" + "sync/atomic" + "time" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" +) + +// Live is a hot-reloading view of the redirect config. It satisfies Resolver +// and supplies the on-demand TLS decision gate, so new mappings take effect +// without a restart (and without a database). +type Live struct { + store mappings.Store + cfg atomic.Pointer[mappings.Config] +} + +// NewLive loads the initial config from store. It errors only if the first +// load fails; subsequent background reload failures keep the last good config. +func NewLive(ctx context.Context, store mappings.Store) (*Live, error) { + l := &Live{store: store} + cfg, err := store.Load(ctx) + if err != nil { + return nil, err + } + l.cfg.Store(&cfg) + return l, nil +} + +// Config returns the current config snapshot. +func (l *Live) Config() mappings.Config { return *l.cfg.Load() } + +// Resolve looks up the target for host in the current config. +func (l *Live) Resolve(host string) (string, bool) { return l.cfg.Load().Resolve(host) } + +// IsRegistered reports whether host is currently registered. +func (l *Live) IsRegistered(host string) bool { return l.cfg.Load().IsRegistered(host) } + +// Reload fetches the latest config once, replacing the snapshot on success. +func (l *Live) Reload(ctx context.Context) error { + cfg, err := l.store.Load(ctx) + if err != nil { + return err + } + l.cfg.Store(&cfg) + return nil +} + +// Watch reloads on an interval until ctx is cancelled. onErr (optional) is +// called with any reload error; the previous config is retained. +func (l *Live) Watch(ctx context.Context, interval time.Duration, onErr func(error)) { + // time.NewTicker panics on a non-positive duration; a misconfigured + // RELOAD_INTERVAL must not crash the watcher goroutine. Fall back to 30s. + if interval <= 0 { + interval = 30 * time.Second + } + t := time.NewTicker(interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := l.Reload(ctx); err != nil && onErr != nil { + onErr(err) + } + } + } +} diff --git a/apps/redirect/server/internal/redirect/proxy.go b/apps/redirect/server/internal/redirect/proxy.go new file mode 100644 index 00000000..f1d78609 --- /dev/null +++ b/apps/redirect/server/internal/redirect/proxy.go @@ -0,0 +1,26 @@ +package redirect + +import ( + "net/http" + "net/http/httputil" + "net/url" +) + +// NewAdminProxy builds a reverse proxy to upstream that presents itself to the +// upstream as the upstream's own host (so e.g. Cloud Run routes correctly) +// while forwarding the public adminHost via X-Forwarded-Host. +func NewAdminProxy(upstream, adminHost string) (http.Handler, error) { + u, err := url.Parse(upstream) + if err != nil { + return nil, err + } + proxy := httputil.NewSingleHostReverseProxy(u) + base := proxy.Director + proxy.Director = func(req *http.Request) { + base(req) + req.Host = u.Host + req.Header.Set("X-Forwarded-Host", adminHost) + req.Header.Set("X-Forwarded-Proto", "https") + } + return proxy, nil +} diff --git a/apps/redirect/server/internal/redirect/redirect.go b/apps/redirect/server/internal/redirect/redirect.go new file mode 100644 index 00000000..71358df2 --- /dev/null +++ b/apps/redirect/server/internal/redirect/redirect.go @@ -0,0 +1,67 @@ +// Package redirect provides the HTTP handler that turns an incoming request +// into a 301/302 to the configured target for its Host. +package redirect + +import ( + "net/http" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" +) + +// Resolver returns the target URL for a request host. +type Resolver interface { + Resolve(host string) (string, bool) +} + +// Handler redirects each request to the target configured for its Host header. +// Unknown hosts get 404. The redirect status is Status (301 or 302). +// +// Optionally, requests for AdminHost are reverse-proxied to AdminProxy instead +// of redirected — this lets the same TLS-terminating tier also front the admin +// web app (its certificate is issued on-demand like any other host). +type Handler struct { + Resolver Resolver + Status int + AdminHost string + AdminProxy http.Handler +} + +// NewHandler builds a redirect handler. status must be 301 or 302; any other +// value falls back to 302 (safer default while DNS/cert setup settles). +func NewHandler(r Resolver, status int) *Handler { + if status != http.StatusMovedPermanently && status != http.StatusFound { + status = http.StatusFound + } + return &Handler{Resolver: r, Status: status} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // A bare health check (no Host match needed) for load balancers / probes. + if r.URL.Path == "/healthz" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + return + } + + host := mappings.NormalizeHost(r.Host) + + // Admin host is reverse-proxied (e.g. to the Cloud Run web app), not redirected. + if h.AdminHost != "" && h.AdminProxy != nil && host == h.AdminHost { + h.AdminProxy.ServeHTTP(w, r) + return + } + + // NewHandler(nil, ...) is permitted (e.g. an admin-proxy-only instance), so + // guard the resolver rather than panicking on a non-health request. + if h.Resolver == nil { + http.Error(w, "no redirect resolver configured", http.StatusServiceUnavailable) + return + } + + target, ok := h.Resolver.Resolve(host) + if !ok { + http.Error(w, "no redirect configured for this host", http.StatusNotFound) + return + } + http.Redirect(w, r, target, h.Status) +} diff --git a/apps/redirect/server/internal/redirect/redirect_test.go b/apps/redirect/server/internal/redirect/redirect_test.go new file mode 100644 index 00000000..747b639b --- /dev/null +++ b/apps/redirect/server/internal/redirect/redirect_test.go @@ -0,0 +1,172 @@ +package redirect + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/F3-Nation/f3-nation/apps/redirect/server/internal/mappings" +) + +func liveFrom(t *testing.T, c mappings.Config) *Live { + t.Helper() + dir := t.TempDir() + store := mappings.NewFileStore(dir + "/c.json") + if err := store.Save(context.Background(), c); err != nil { + t.Fatal(err) + } + l, err := NewLive(context.Background(), store) + if err != nil { + t.Fatal(err) + } + return l +} + +func cfg() mappings.Config { + return mappings.Config{Mappings: []mappings.Mapping{ + {Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, + {Host: "stats.f3muletown.com", Target: "https://pax-vault.f3nation.com/stats/region/35838"}, + }} +} + +func TestHandlerRedirects(t *testing.T) { + h := NewHandler(liveFrom(t, cfg()), http.StatusFound) + + req := httptest.NewRequest(http.MethodGet, "http://f3muletown.com/anything?q=1", nil) + req.Host = "f3muletown.com" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d want 302", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "https://regions.f3nation.com/muletown" { + t.Errorf("Location = %q", loc) + } +} + +func TestHandlerUnknownHost404(t *testing.T) { + h := NewHandler(liveFrom(t, cfg()), http.StatusMovedPermanently) + req := httptest.NewRequest(http.MethodGet, "http://nope.example.com/", nil) + req.Host = "nope.example.com" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Errorf("unknown host status = %d want 404", rec.Code) + } +} + +func TestHandlerHealthz(t *testing.T) { + h := NewHandler(liveFrom(t, cfg()), http.StatusFound) + req := httptest.NewRequest(http.MethodGet, "http://anything/healthz", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || rec.Body.String() != "ok" { + t.Errorf("healthz = %d %q", rec.Code, rec.Body.String()) + } +} + +func TestHandlerAdminProxy(t *testing.T) { + proxied := false + stub := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied = true + w.WriteHeader(http.StatusTeapot) + }) + h := NewHandler(liveFrom(t, cfg()), http.StatusFound) + h.AdminHost = "admin.example.com" + h.AdminProxy = stub + + // admin host → proxied, not redirected + req := httptest.NewRequest(http.MethodGet, "http://admin.example.com/x", nil) + req.Host = "admin.example.com" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !proxied || rec.Code != http.StatusTeapot { + t.Errorf("admin host should be proxied: proxied=%v code=%d", proxied, rec.Code) + } + + // a normal registered host still redirects + proxied = false + req2 := httptest.NewRequest(http.MethodGet, "http://f3muletown.com/", nil) + req2.Host = "f3muletown.com" + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if proxied || rec2.Code != http.StatusFound { + t.Errorf("registered host should redirect, not proxy: proxied=%v code=%d", proxied, rec2.Code) + } +} + +func TestNewHandlerStatusFallback(t *testing.T) { + if h := NewHandler(nil, 307); h.Status != http.StatusFound { + t.Errorf("invalid status should fall back to 302, got %d", h.Status) + } + if h := NewHandler(nil, http.StatusMovedPermanently); h.Status != http.StatusMovedPermanently { + t.Errorf("301 should be honored, got %d", h.Status) + } +} + +func TestLiveConfigAndWatch(t *testing.T) { + dir := t.TempDir() + store := mappings.NewFileStore(dir + "/c.json") + if err := store.Save(context.Background(), cfg()); err != nil { + t.Fatal(err) + } + l, err := NewLive(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if len(l.Config().Mappings) != 2 { + t.Errorf("Config() snapshot len = %d want 2", len(l.Config().Mappings)) + } + + // Watch should reload at least once, then exit promptly on cancel. + updated := cfg().Upsert("watched.f3muletown.com", "https://example.com/w") + if err := store.Save(context.Background(), updated); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { l.Watch(ctx, time.Millisecond, nil); close(done) }() + deadline := time.After(2 * time.Second) + for !l.IsRegistered("watched.f3muletown.com") { + select { + case <-deadline: + cancel() + t.Fatal("Watch did not reload within deadline") + case <-time.After(2 * time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Watch did not return after cancel") + } +} + +func TestLiveReload(t *testing.T) { + dir := t.TempDir() + store := mappings.NewFileStore(dir + "/c.json") + if err := store.Save(context.Background(), cfg()); err != nil { + t.Fatal(err) + } + l, err := NewLive(context.Background(), store) + if err != nil { + t.Fatal(err) + } + if l.IsRegistered("new.f3muletown.com") { + t.Fatal("should not be registered yet") + } + updated := cfg().Upsert("new.f3muletown.com", "https://example.com/new") + if err := store.Save(context.Background(), updated); err != nil { + t.Fatal(err) + } + if err := l.Reload(context.Background()); err != nil { + t.Fatal(err) + } + if !l.IsRegistered("new.f3muletown.com") { + t.Error("reload should pick up the new mapping") + } +} diff --git a/apps/redirect/server/internal/server/server.go b/apps/redirect/server/internal/server/server.go new file mode 100644 index 00000000..18b2e3d8 --- /dev/null +++ b/apps/redirect/server/internal/server/server.go @@ -0,0 +1,68 @@ +// Package server wires CertMagic on-demand TLS to the redirect handler. +// +// On-demand issuance is gated by a DecisionFunc so we only ask Let's Encrypt +// for a certificate when the incoming hostname is actually registered in our +// config — this is the abuse/rate-limit guard required by the design. +package server + +import ( + "context" + "crypto/tls" + "fmt" + + "github.com/caddyserver/certmagic" +) + +// Options configure the TLS/ACME stack. +type Options struct { + // Storage is the shared certificate store (GCS in production). + Storage certmagic.Storage + // Email is the ACME account contact (Let's Encrypt expiry notices). + Email string + // Staging uses the Let's Encrypt staging CA (untrusted certs, high rate + // limits) — use while validating before flipping to production. + Staging bool + // Decide gates on-demand issuance: return nil to allow a cert for name, + // or an error to refuse. Must consult the live registry. + Decide func(ctx context.Context, name string) error +} + +// Build constructs a *tls.Config for on-demand serving and the ACME issuer +// whose HTTPChallengeHandler must wrap the :80 listener. +func Build(o Options) (*tls.Config, *certmagic.ACMEIssuer, error) { + if o.Storage == nil { + return nil, nil, fmt.Errorf("server: Storage is required") + } + if o.Decide == nil { + return nil, nil, fmt.Errorf("server: Decide func is required (issuance must be gated)") + } + + template := certmagic.Config{ + Storage: o.Storage, + OnDemand: &certmagic.OnDemandConfig{DecisionFunc: o.Decide}, + } + + var cache *certmagic.Cache + cache = certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { + return certmagic.New(cache, template), nil + }, + }) + + magic := certmagic.New(cache, template) + + ca := certmagic.LetsEncryptProductionCA + if o.Staging { + ca = certmagic.LetsEncryptStagingCA + } + acme := certmagic.NewACMEIssuer(magic, certmagic.ACMEIssuer{ + CA: ca, + Email: o.Email, + Agreed: true, + }) + magic.Issuers = []certmagic.Issuer{acme} + + tlsCfg := magic.TLSConfig() + tlsCfg.NextProtos = append([]string{"h2", "http/1.1"}, tlsCfg.NextProtos...) + return tlsCfg, acme, nil +} diff --git a/apps/redirect/server/package.json b/apps/redirect/server/package.json new file mode 100644 index 00000000..cb23ff6f --- /dev/null +++ b/apps/redirect/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "f3-redirect-server", + "version": "0.1.0", + "private": true, + "//": "Thin adapter: maps Turborepo's task vocabulary to native Go tooling so the monorepo orchestrates this Go module uniformly. Scripts route through scripts/*.sh wrappers that ignore forwarded args (turbo passes eslint's --cache and vitest's --coverage to every task of that name). No npm deps. Requires the Go toolchain (and Docker for the certstore emulator tests) on the runner.", + "scripts": { + "build": "bash scripts/build.sh", + "test": "bash scripts/test.sh", + "test:coverage": "bash scripts/coverage.sh", + "typecheck": "bash scripts/build.sh", + "lint": "bash scripts/lint.sh", + "format": "bash scripts/format.sh", + "format:check": "bash scripts/format-check.sh" + } +} diff --git a/apps/redirect/server/scripts/build.sh b/apps/redirect/server/scripts/build.sh new file mode 100755 index 00000000..4052cc49 --- /dev/null +++ b/apps/redirect/server/scripts/build.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(cd "$(dirname "$0")/.." && pwd)" +go build ./... diff --git a/apps/redirect/server/scripts/coverage.sh b/apps/redirect/server/scripts/coverage.sh new file mode 100755 index 00000000..aa30727d --- /dev/null +++ b/apps/redirect/server/scripts/coverage.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run unit tests with coverage and enforce a minimum threshold on the +# business-logic packages. +# +# The cloud-IO packages (internal/certstore, internal/server) and the cmd/ +# entrypoints talk to live GCS / ACME / sockets and are validated by the +# deploy-time smoke test, not unit coverage — so they are intentionally +# excluded from the gate to keep the threshold meaningful. +# +# Usage: +# scripts/coverage.sh # enforce default threshold +# COVER_THRESHOLD=80 scripts/coverage.sh +# COVER_PKGS="./internal/mappings/..." scripts/coverage.sh +set -euo pipefail + +# Run from the Go module root (this script lives at /scripts/). +cd "$(cd "$(dirname "$0")/.." && pwd)" + +COVER_THRESHOLD="${COVER_THRESHOLD:-70}" +COVER_PKGS="${COVER_PKGS:-./internal/mappings/... ./internal/redirect/...}" +PROFILE="${COVER_PROFILE:-coverage.out}" + +echo "==> go test (coverage gate ${COVER_THRESHOLD}% on: ${COVER_PKGS})" +# shellcheck disable=SC2086 +go test -covermode=atomic -coverprofile="$PROFILE" $COVER_PKGS + +TOTAL=$(go tool cover -func="$PROFILE" | awk '/^total:/ {gsub("%","",$3); print $3}') +echo "==> total coverage: ${TOTAL}%" + +# Optional HTML report (gitignored). +go tool cover -html="$PROFILE" -o coverage.html 2>/dev/null || true + +awk -v t="$TOTAL" -v min="$COVER_THRESHOLD" 'BEGIN { + if (t+0 < min+0) { + printf "FAIL: coverage %.1f%% is below threshold %s%%\n", t, min + exit 1 + } + printf "PASS: coverage %.1f%% meets threshold %s%%\n", t, min +}' diff --git a/apps/redirect/server/scripts/format-check.sh b/apps/redirect/server/scripts/format-check.sh new file mode 100755 index 00000000..0636056d --- /dev/null +++ b/apps/redirect/server/scripts/format-check.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(cd "$(dirname "$0")/.." && pwd)" +test -z "$(gofmt -l . | tee /dev/stderr)" diff --git a/apps/redirect/server/scripts/format.sh b/apps/redirect/server/scripts/format.sh new file mode 100755 index 00000000..b52c82bb --- /dev/null +++ b/apps/redirect/server/scripts/format.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(cd "$(dirname "$0")/.." && pwd)" +gofmt -w . diff --git a/apps/redirect/server/scripts/lint.sh b/apps/redirect/server/scripts/lint.sh new file mode 100755 index 00000000..152a6d5c --- /dev/null +++ b/apps/redirect/server/scripts/lint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# `pnpm lint` forwards `--cache --cache-location …` (for eslint) to every lint +# task; ignore those args and run the Go linters. +set -euo pipefail +cd "$(cd "$(dirname "$0")/.." && pwd)" +test -z "$(gofmt -l . | tee /dev/stderr)" && go vet ./... diff --git a/apps/redirect/server/scripts/test.sh b/apps/redirect/server/scripts/test.sh new file mode 100755 index 00000000..1dafb690 --- /dev/null +++ b/apps/redirect/server/scripts/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# `pnpm test:coverage` forwards `--coverage` (for vitest) to every test task; +# ignore extra args here and run the Go test suite. +set -euo pipefail +cd "$(cd "$(dirname "$0")/.." && pwd)" +go test ./... -count=1 diff --git a/apps/redirect/shared/dns-instructions.json b/apps/redirect/shared/dns-instructions.json new file mode 100644 index 00000000..a5bade3b --- /dev/null +++ b/apps/redirect/shared/dns-instructions.json @@ -0,0 +1,75 @@ +{ + "_comment": "Shared contract for DNS-instruction generation. Asserted by BOTH the Go (internal/mappings) and TS (web/src/lib/domains.ts) suites so the two implementations cannot drift. Only structural fields (type/name/value/optional) are compared; human-readable notes may differ in wording.", + "cases": [ + { + "name": "apex with canonical host: required A + recommended www CNAME", + "host": "f3muletown.com", + "options": { + "staticIP": "203.0.113.10", + "canonicalHost": "redirect.example.net" + }, + "records": [ + { + "type": "A", + "name": "f3muletown.com", + "value": "203.0.113.10", + "optional": false + }, + { + "type": "CNAME", + "name": "www.f3muletown.com", + "value": "f3muletown.com", + "optional": true + } + ] + }, + { + "name": "subdomain with canonical host: single required CNAME to canonical", + "host": "stats.f3muletown.com", + "options": { + "staticIP": "203.0.113.10", + "canonicalHost": "redirect.example.net" + }, + "records": [ + { + "type": "CNAME", + "name": "stats.f3muletown.com", + "value": "redirect.example.net", + "optional": false + } + ] + }, + { + "name": "subdomain without canonical host: CNAME falls back to apex", + "host": "www.f3marshall.com", + "options": { "staticIP": "203.0.113.10", "canonicalHost": "" }, + "records": [ + { + "type": "CNAME", + "name": "www.f3marshall.com", + "value": "f3marshall.com", + "optional": false + } + ] + }, + { + "name": "apex without canonical host still recommends www", + "host": "example.com", + "options": { "staticIP": "198.51.100.7", "canonicalHost": "" }, + "records": [ + { + "type": "A", + "name": "example.com", + "value": "198.51.100.7", + "optional": false + }, + { + "type": "CNAME", + "name": "www.example.com", + "value": "example.com", + "optional": true + } + ] + } + ] +} diff --git a/apps/redirect/shared/package.json b/apps/redirect/shared/package.json new file mode 100644 index 00000000..db3bc08e --- /dev/null +++ b/apps/redirect/shared/package.json @@ -0,0 +1,9 @@ +{ + "name": "f3-redirect-shared", + "version": "0.1.0", + "private": true, + "//": "Non-code shared asset package: holds dns-instructions.json, the single source-of-truth DNS-rule contract that both the Go tier (server) and TS tier (web) parity-test against. No build/test/lint scripts — exists so the apps/redirect/* workspace glob resolves a valid package here.", + "files": [ + "dns-instructions.json" + ] +} diff --git a/apps/redirect/web/.dockerignore b/apps/redirect/web/.dockerignore new file mode 100644 index 00000000..750ad717 --- /dev/null +++ b/apps/redirect/web/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.env +.env.* +!.env.example +npm-debug.log +Dockerfile +.dockerignore +scripts/local-e2e.ts diff --git a/apps/redirect/web/.env.cloud-run.example b/apps/redirect/web/.env.cloud-run.example new file mode 100644 index 00000000..b8786085 --- /dev/null +++ b/apps/redirect/web/.env.cloud-run.example @@ -0,0 +1,15 @@ +# Cloud Run deployment env for the admin web (f3-redirect-web). +# These are set on the Cloud Run service out-of-band (not committed). v1 runs in +# the personal-sandbox project f3-redirects; long-term these move to the F3 +# Nation org project + F3PROD schema (see apps/redirect/README.md). + +# Cloud SQL via the Cloud Run connector (unix socket form): +DATABASE_URL=postgres://USER:PASS@/f3redirect?host=/cloudsql/f3-redirects:us-central1:f3redirect-pg +# Generate: openssl rand -base64 32 +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://admin.f3regions.com +PASSKEY_RP_ID=admin.f3regions.com +PASSKEY_ORIGIN=https://admin.f3regions.com +# Flat-file config + cert bucket (read by the Go redirect tier): +CONFIG_BUCKET=f3-redirects-redirect +REDIRECT_STATIC_IP=34.172.36.60 diff --git a/apps/redirect/web/.env.local.example b/apps/redirect/web/.env.local.example new file mode 100644 index 00000000..59ed85b1 --- /dev/null +++ b/apps/redirect/web/.env.local.example @@ -0,0 +1,39 @@ +# ============================================================================= +# F3 Redirect — admin web — Local Development Environment (example) +# ============================================================================= +# Copy to .env.local and fill in values. Aligns with the repo-wide local dev +# stack (root docker-compose.yml): Postgres on :5433, fake-gcs-server on :9023. +# Start the shared services first: docker compose up -d +# +# (Full `pnpm env:generate` / GCP-secrets integration is deferred until this +# app moves to an F3 Nation org-owned project — see apps/redirect/README.md.) + +# -- Database (shared local Postgres from docker-compose.yml) -- +DATABASE_URL=postgresql://f3local:f3local@localhost:5433/f3nation +# Integration tests provision an ISOLATED database (f3redirect_test) on this +# same server via scripts/reset-test-db.mjs; point this at the server only. +TEST_DATABASE_URL=postgresql://f3local:f3local@localhost:5433/f3nation_test + +# -- Better Auth (email+password primary, passkey secondary) -- +# Generate: openssl rand -base64 32 +BETTER_AUTH_SECRET=local-dev-secret-change-me-32-chars-min +BETTER_AUTH_URL=http://localhost:3007 + +# -- Passkey (WebAuthn) — defaults are fine for localhost -- +PASSKEY_RP_ID=localhost +PASSKEY_RP_NAME="F3 Redirect" +PASSKEY_ORIGIN=http://localhost:3007 + +# -- Optional Google OAuth (BOTH required to enable; leave unset to skip) -- +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + +# -- Config export / DNS instructions -- +# In local dev, write the flat-file config to a local path instead of GCS. +EXPORT_LOCAL_PATH=/tmp/redirects.local.json +CONFIG_BUCKET=f3-redirects-redirect +REDIRECT_STATIC_IP=34.172.36.60 +# Point the GCS client at the local fake-gcs-server (docker-compose service). +GCS_EMULATOR_HOST=localhost:9023 + +NODE_ENV=development diff --git a/apps/redirect/web/.gitignore b/apps/redirect/web/.gitignore new file mode 100644 index 00000000..28ed477e --- /dev/null +++ b/apps/redirect/web/.gitignore @@ -0,0 +1,17 @@ +node_modules/ +.next/ +out/ +build/ +next-env.d.ts +.env +.env.* +!.env.example +!.env.*.example +*.tsbuildinfo +.DS_Store +# test + coverage artifacts (never commit — thresholds enforced in CI) +coverage/ +test-results/ +playwright-report/ +blob-report/ +.playwright/ diff --git a/apps/redirect/web/Dockerfile b/apps/redirect/web/Dockerfile new file mode 100644 index 00000000..843ac7b7 --- /dev/null +++ b/apps/redirect/web/Dockerfile @@ -0,0 +1,45 @@ +# Monorepo Cloud Run image for f3-redirect-web (mirrors apps/me/Dockerfile): +# turbo prune → install pruned lockfile → turbo build → standalone runner. +# Build with the repo root as context: docker build --file apps/redirect/web/Dockerfile . + +# ---------- Stage 1: Prune monorepo to just this app ---------- +FROM --platform=linux/amd64 node:24-alpine AS builder +ENV TURBO_TELEMETRY_DISABLED=1 +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN npm install -g turbo@1.13.4 +COPY . . +RUN turbo prune f3-redirect-web --docker + +# ---------- Stage 2: Install + build ---------- +FROM --platform=linux/amd64 node:24-alpine AS installer +ENV NEXT_TELEMETRY_DISABLED=1 +ENV TURBO_TELEMETRY_DISABLED=1 +ENV CI=true +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN npm install -g turbo@1.13.4 && corepack enable +# A dummy DATABASE_URL only satisfies module-import guards during `next build` +# (all pages are dynamic — no DB query at build). Real value injected at runtime. +ENV DATABASE_URL=postgres://build:build@localhost:5432/build +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN pnpm install --frozen-lockfile +COPY --from=builder /app/out/full/ . +RUN pnpm turbo build --filter=f3-redirect-web + +# ---------- Stage 3: Production runner ---------- +FROM --platform=linux/amd64 node:24-alpine AS runner +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs +COPY --from=installer --chown=nextjs:nodejs /app/apps/redirect/web/.next/standalone ./ +COPY --from=installer --chown=nextjs:nodejs /app/apps/redirect/web/.next/static ./apps/redirect/web/.next/static +COPY --from=installer --chown=nextjs:nodejs /app/apps/redirect/web/public ./apps/redirect/web/public +USER nextjs +ENV NODE_ENV=production +ENV PORT=8080 +ENV HOSTNAME=0.0.0.0 +EXPOSE 8080 +CMD ["node", "apps/redirect/web/server.js"] diff --git a/apps/redirect/web/drizzle.config.ts b/apps/redirect/web/drizzle.config.ts new file mode 100644 index 00000000..0ff5de45 --- /dev/null +++ b/apps/redirect/web/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: + process.env.DATABASE_URL ?? + "postgresql://f3local:f3local@localhost:5433/f3nation", + }, +}); diff --git a/apps/redirect/web/e2e/admin.spec.ts b/apps/redirect/web/e2e/admin.spec.ts new file mode 100644 index 00000000..451e6fdf --- /dev/null +++ b/apps/redirect/web/e2e/admin.spec.ts @@ -0,0 +1,42 @@ +import { expect, test } from "@playwright/test"; + +test("sign up, register a domain, and see the DNS records", async ({ + page, +}) => { + const ts = Date.now(); + const email = `e2e-${ts}@example.com`; + const host = `e2e${ts}.com`; // apex → expect an A record + + await page.goto("/"); + + // Switch to sign-up and create an account. + await page.getByText("new here? create an account").click(); + await page.locator("#email").fill(email); + await page.locator("#password").fill("e2e-password-123"); + await page.getByRole("button", { name: "Create account" }).click(); + + // Lands on the dashboard. + await expect( + page.getByRole("heading", { name: "Your domains" }), + ).toBeVisible(); + + // Register a domain. + await page.locator("#hostname").fill(host); + await page.locator("#destination").fill("https://example.com/e2e"); + await page.getByRole("button", { name: "Register domain" }).click(); + + // The domain card appears. + await expect(page.getByText(host).first()).toBeVisible(); + + // Edit the destination in place (no delete+recreate). + await page.getByRole("button", { name: "Edit destination" }).click(); + const dest = page.locator('input[id^="dest-"]'); + await dest.fill("https://example.com/edited"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("https://example.com/edited")).toBeVisible(); + + // Open the DNS records bottom-sheet: required apex A record to the static IP. + await page.getByRole("button", { name: "View DNS records" }).click(); + await expect(page.getByText("34.172.36.60")).toBeVisible(); + await expect(page.getByText("required").first()).toBeVisible(); +}); diff --git a/apps/redirect/web/e2e/passkey.spec.ts b/apps/redirect/web/e2e/passkey.spec.ts new file mode 100644 index 00000000..f8f550ad --- /dev/null +++ b/apps/redirect/web/e2e/passkey.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from "@playwright/test"; + +// End-to-end passkey flow using a CDP virtual authenticator (no physical +// device needed): sign up with email+password, add a passkey, sign out, then +// sign in with the passkey. +test("add a passkey, then sign in with it", async ({ page }) => { + const client = await page.context().newCDPSession(page); + await client.send("WebAuthn.enable"); + await client.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + + const ts = Date.now(); + const email = `pk-${ts}@example.com`; + + // Sign up (email + password primary). + await page.goto("/"); + await page.getByText("new here? create an account").click(); + await page.locator("#email").fill(email); + await page.locator("#password").fill("passkey-e2e-123456"); + await page.getByRole("button", { name: "Create account" }).click(); + await expect( + page.getByRole("heading", { name: "Your domains" }), + ).toBeVisible(); + + // Add a passkey (secondary). + await page.getByRole("button", { name: "Add a passkey" }).click(); + await expect(page.getByText(/passkey added/i)).toBeVisible({ + timeout: 15_000, + }); + + // Sign out, then sign in with the passkey. + await page.getByText("sign out").click(); + await expect( + page.getByRole("button", { name: "Sign in", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Sign in with a passkey" }).click(); + + // Back on the dashboard, authenticated via passkey alone. + await expect(page.getByRole("heading", { name: "Your domains" })).toBeVisible( + { timeout: 15_000 }, + ); +}); diff --git a/apps/redirect/web/eslint.config.js b/apps/redirect/web/eslint.config.js new file mode 100644 index 00000000..540b12e8 --- /dev/null +++ b/apps/redirect/web/eslint.config.js @@ -0,0 +1,39 @@ +import baseConfig from "@acme/eslint-config/base"; +import nextConfig from "@acme/eslint-config/nextjs"; +import reactConfig from "@acme/eslint-config/react"; + +export default [ + { + ignores: [ + ".next/**", + "coverage/**", + "next-env.d.ts", + "**/*.config.ts", + "**/*.config.js", + "eslint.config.js", + "e2e/**", + "scripts/**", + ], + }, + ...baseConfig, + ...nextConfig, + ...reactConfig, + { + // Tests use mocked fetch / res.json() which are intentionally loosely + // typed; the type-aware "unsafe" rules add noise without value here. + files: ["**/*.test.ts", "**/*.test.tsx", "src/test-setup.ts"], + rules: { + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-explicit-any": "off", + }, + }, + { + // The jest-dom matcher augmentation is intentionally an empty interface. + files: ["src/vitest.d.ts"], + rules: { "@typescript-eslint/no-empty-object-type": "off" }, + }, +]; diff --git a/apps/redirect/web/next.config.ts b/apps/redirect/web/next.config.ts new file mode 100644 index 00000000..d4c4e2b5 --- /dev/null +++ b/apps/redirect/web/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // Standalone output → small container for Cloud Run. + output: "standalone", +}; + +export default nextConfig; diff --git a/apps/redirect/web/package.json b/apps/redirect/web/package.json new file mode 100644 index 00000000..42fbb68d --- /dev/null +++ b/apps/redirect/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "f3-redirect-web", + "type": "module", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3007", + "build": "next build", + "start": "next start --port 3007", + "lint": "dotenv -v SKIP_ENV_VALIDATION=1 eslint .", + "format:check": "prettier --check .", + "format": "prettier --write .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "db:push": "drizzle-kit push", + "reset-test-db": "node scripts/reset-test-db.mjs" + }, + "dependencies": { + "@better-auth/passkey": "^1.6.11", + "@google-cloud/storage": "^7.14.0", + "better-auth": "^1.6.11", + "drizzle-orm": "^0.45.2", + "next": "^15.3.6", + "postgres": "^3.4.3", + "react": "18.3.1", + "react-dom": "18.3.1", + "tldts": "^6.1.71", + "zod": "^3.25.8" + }, + "devDependencies": { + "@acme/eslint-config": "workspace:^0.2.0", + "@acme/tsconfig": "workspace:^0.1.0", + "@playwright/test": "^1.52.0", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^15.0.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^20.11.13", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitest/coverage-v8": "^1.5.0", + "drizzle-kit": "^0.31.10", + "eslint": "catalog:", + "jsdom": "^24.0.0", + "prettier": "^3.2.5", + "typescript": "catalog:", + "vitest": "^1.5.0" + } +} diff --git a/apps/redirect/web/playwright.config.ts b/apps/redirect/web/playwright.config.ts new file mode 100644 index 00000000..c5996bf4 --- /dev/null +++ b/apps/redirect/web/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test"; + +// End-to-end tests run against a local dev server wired to the local +// dockerized Postgres. The export is written to a temp file (never GCS). +export default defineConfig({ + testDir: "./e2e", + timeout: 60_000, + fullyParallel: false, + reporter: [["list"]], + use: { + baseURL: "http://localhost:3000", + headless: true, + }, + webServer: { + command: "pnpm dev", + url: "http://localhost:3000", + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: "postgres://postgres:devpass@localhost:5433/f3redirect", + EXPORT_LOCAL_PATH: "/tmp/pw-redirects.json", + BETTER_AUTH_SECRET: "e2e-secret-e2e-secret-e2e-secret-e2e", + BETTER_AUTH_URL: "http://localhost:3000", + CONFIG_BUCKET: "test-bucket", + REDIRECT_STATIC_IP: "34.172.36.60", + }, + }, +}); diff --git a/apps/redirect/web/public/.gitkeep b/apps/redirect/web/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/redirect/web/scripts/local-e2e.ts b/apps/redirect/web/scripts/local-e2e.ts new file mode 100644 index 00000000..0ddcea79 --- /dev/null +++ b/apps/redirect/web/scripts/local-e2e.ts @@ -0,0 +1,143 @@ +// Local end-to-end check of the domain-registration mechanics, exercising the +// same modules the API uses (validation, DB insert, unique-claim, DNS, export) +// without needing a Google sign-in. Run with EXPORT_LOCAL_PATH set so the +// exporter writes a local file instead of GCS. +// +// pnpm dlx tsx scripts/local-e2e.ts +import { readFile } from "node:fs/promises"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { domain, user } from "@/db/schema"; +import { dnsInstructions, registerSchema } from "@/lib/domains"; +import { exportConfigToGCS } from "@/lib/gcs-export"; + +function assert(cond: unknown, msg: string) { + if (!cond) throw new Error("FAIL: " + msg); + console.log(" ok:", msg); +} + +// This script truncates the `domain` and `user` tables. Refuse to run unless +// DATABASE_URL clearly points at a local/test database, so a misconfigured env +// can never wipe real data. +function assertLocalTestDb() { + const url = process.env.DATABASE_URL ?? ""; + const isLocal = + /@(localhost|127\.0\.0\.1|::1|host\.docker\.internal)[:/]/.test(url); + const looksTest = /(test|f3redirect)\b/i.test(url); + if (!url || !isLocal || !looksTest) { + throw new Error( + `Refusing to run destructive local-e2e: DATABASE_URL must point at a local test DB (got: ${url || "unset"}).`, + ); + } +} + +async function main() { + assertLocalTestDb(); + const uid1 = "test-user-1"; + const uid2 = "test-user-2"; + // clean slate + await db.delete(domain); + await db.delete(user); + await db.insert(user).values([ + { + id: uid1, + name: "User One", + email: "one@example.com", + emailVerified: true, + }, + { + id: uid2, + name: "User Two", + email: "two@example.com", + emailVerified: true, + }, + ]); + + // 1. validation rejects junk + assert( + !registerSchema.safeParse({ + hostname: "nodot", + destination: "https://x.com", + }).success, + "rejects non-FQDN hostname", + ); + assert( + !registerSchema.safeParse({ hostname: "ok.com", destination: "ftp://x" }) + .success, + "rejects non-http destination", + ); + + // 2. valid registration + const parsed = registerSchema.parse({ + hostname: "F3Muletown.com", + destination: "https://regions.f3nation.com/muletown", + }); + assert( + parsed.hostname === "f3muletown.com", + "normalizes hostname to lowercase", + ); + await db.insert(domain).values({ + userId: uid1, + hostname: parsed.hostname, + destinationUrl: parsed.destination, + }); + + // 3. another user cannot claim the same host (unique index) + let claimed = false; + try { + await db.insert(domain).values({ + userId: uid2, + hostname: "f3muletown.com", + destinationUrl: "https://evil.example.com", + }); + } catch (e) { + // Only a unique-violation (Postgres 23505) counts as "already claimed"; + // any other DB error is a real failure and must not pass silently. + const code = (e as { code?: string } | null)?.code; + if (code !== "23505") throw e; + claimed = true; + } + assert(claimed, "second account cannot claim an already-registered host"); + + // 4. subdomain for a different host is fine + await db.insert(domain).values({ + userId: uid2, + hostname: "stats.f3muletown.com", + destinationUrl: "https://pax-vault.f3nation.com/stats/region/35838", + }); + + // 5. DNS instructions: apex -> A, subdomain -> CNAME + const apex = dnsInstructions("f3muletown.com"); + assert( + apex[0].type === "A" && apex[0].name === "f3muletown.com", + "apex yields an A record", + ); + const sub = dnsInstructions("stats.f3muletown.com"); + assert( + sub[0].type === "CNAME" && sub[0].value === "f3muletown.com", + "subdomain yields a CNAME to its apex", + ); + + // 6. export to local file reflects the DB + const n = await exportConfigToGCS(); + assert(n === 2, "export wrote 2 mappings"); + const written = JSON.parse( + await readFile(process.env.EXPORT_LOCAL_PATH!, "utf8"), + ); + const hosts = written.mappings.map((m: { host: string }) => m.host).sort(); + assert( + hosts[0] === "f3muletown.com" && hosts[1] === "stats.f3muletown.com", + "exported file matches registered domains", + ); + + // cleanup + await db.delete(domain); + await db.delete(user); + console.log("\nLOCAL E2E PASSED"); + process.exit(0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/redirect/web/scripts/reset-test-db.mjs b/apps/redirect/web/scripts/reset-test-db.mjs new file mode 100644 index 00000000..bd492d1b --- /dev/null +++ b/apps/redirect/web/scripts/reset-test-db.mjs @@ -0,0 +1,45 @@ +// Provision an ISOLATED test database for the redirect web integration tests. +// +// In CI the shared Postgres service exposes a single `f3_test` database that +// `@acme/db` owns and resets (dropping/recreating its schema). Our auth/domain +// tables would be clobbered by that reset, so we carve out our own database +// (`f3redirect_test`) on the same server and push our Drizzle schema into it. +// vitest.config.ts derives the same dedicated URL, so tests and this reset +// always agree — and never collide with @acme/db. +import { execSync } from "node:child_process"; +import postgres from "postgres"; + +const DEDICATED_DB = "f3redirect_test"; +const base = + process.env.TEST_DATABASE_URL ?? + process.env.DATABASE_URL ?? + "postgresql://f3local:f3local@localhost:5433/f3nation"; + +function withPath(urlStr, dbName) { + const u = new URL(urlStr); + u.pathname = "/" + dbName; + return u.toString(); +} + +// 1) Create the dedicated database if it doesn't exist (connect via the +// server's default `postgres` database; CREATE DATABASE can't run in a tx). +const admin = postgres(withPath(base, "postgres"), { max: 1 }); +try { + const exists = + await admin`SELECT 1 FROM pg_database WHERE datname = ${DEDICATED_DB}`; + if (exists.length === 0) { + await admin.unsafe(`CREATE DATABASE ${DEDICATED_DB}`); + console.log(`created database ${DEDICATED_DB}`); + } else { + console.log(`database ${DEDICATED_DB} already exists`); + } +} finally { + await admin.end(); +} + +// 2) Push our schema into the dedicated database. +const dedicated = withPath(base, DEDICATED_DB); +execSync("pnpm exec drizzle-kit push --force", { + stdio: "inherit", + env: { ...process.env, DATABASE_URL: dedicated }, +}); diff --git a/apps/redirect/web/src/app/api/auth/[...all]/route.ts b/apps/redirect/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..8c5e03ff --- /dev/null +++ b/apps/redirect/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from "better-auth/next-js"; +import { auth } from "@/auth"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/apps/redirect/web/src/app/api/domains/[id]/route.ts b/apps/redirect/web/src/app/api/domains/[id]/route.ts new file mode 100644 index 00000000..2ea1a11e --- /dev/null +++ b/apps/redirect/web/src/app/api/domains/[id]/route.ts @@ -0,0 +1,83 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { domain } from "@/db/schema"; +import { dnsInstructions, updateSchema } from "@/lib/domains"; +import { deleteCertsForHost, exportConfigToGCS } from "@/lib/gcs-export"; + +async function requireUserId(): Promise { + const sess = await auth.api.getSession({ headers: await headers() }); + return sess?.user?.id ?? null; +} + +// PUT /api/domains/:id — update the redirect destination (owner only). +export async function PUT( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const userId = await requireUserId(); + if (!userId) + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const parsed = updateSchema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "invalid input" }, + { status: 400 }, + ); + } + + const { id } = await params; + const [updated] = await db + .update(domain) + .set({ destinationUrl: parsed.data.destination }) + .where(and(eq(domain.id, id), eq(domain.userId, userId))) + .returning(); + + if (!updated) + return NextResponse.json({ error: "not found" }, { status: 404 }); + + await exportConfigToGCS(); + + return NextResponse.json({ + domain: { + id: updated.id, + hostname: updated.hostname, + destination: updated.destinationUrl, + dns: dnsInstructions(updated.hostname), + }, + }); +} + +// DELETE /api/domains/:id — remove one of the signed-in user's domains, then +// garbage-collect its TLS cert material from GCS. +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const userId = await requireUserId(); + if (!userId) + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const { id } = await params; + const [deleted] = await db + .delete(domain) + .where(and(eq(domain.id, id), eq(domain.userId, userId))) + .returning(); + + if (!deleted) + return NextResponse.json({ error: "not found" }, { status: 404 }); + + await exportConfigToGCS(); + // Best-effort cert cleanup — never fail the request on this. + let certsRemoved = 0; + try { + certsRemoved = await deleteCertsForHost(deleted.hostname); + } catch { + certsRemoved = 0; + } + + return NextResponse.json({ ok: true, certsRemoved }); +} diff --git a/apps/redirect/web/src/app/api/domains/route.test.ts b/apps/redirect/web/src/app/api/domains/route.test.ts new file mode 100644 index 00000000..49d2bd4c --- /dev/null +++ b/apps/redirect/web/src/app/api/domains/route.test.ts @@ -0,0 +1,204 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +// Control the "signed-in" user per test. +const session = vi.hoisted(() => ({ + current: null as null | { user: { id: string; email: string } }, +})); +vi.mock("@/auth", () => ({ + auth: { api: { getSession: async () => session.current } }, +})); +// Route handlers call next/headers; outside a request scope it throws, so stub it. +vi.mock("next/headers", () => ({ headers: async () => new Headers() })); + +import { eq, inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { domain, user } from "@/db/schema"; +import { GET, POST } from "./route"; +import { DELETE, PUT } from "./[id]/route"; + +const UA = "itest-user-a"; +const UB = "itest-user-b"; +const HOSTS = ["itest-apex.example", "itest-sub.itest-apex.example"]; + +function post(body: unknown) { + return new Request("http://t/api/domains", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function cleanup() { + await db.delete(domain).where(inArray(domain.hostname, HOSTS)); + await db.delete(user).where(inArray(user.id, [UA, UB])); +} + +beforeAll(async () => { + await cleanup(); + await db.insert(user).values([ + { id: UA, name: "A", email: "itest-a@example.com", emailVerified: true }, + { id: UB, name: "B", email: "itest-b@example.com", emailVerified: true }, + ]); +}); +afterAll(cleanup); +beforeEach(async () => { + await db.delete(domain).where(inArray(domain.hostname, HOSTS)); + session.current = null; +}); + +describe("POST /api/domains", () => { + it("401 when unauthenticated", async () => { + const res = await POST( + post({ hostname: HOSTS[0], destination: "https://x.com" }), + ); + expect(res.status).toBe(401); + }); + + it("registers a domain and returns DNS records", async () => { + session.current = { user: { id: UA, email: "itest-a@example.com" } }; + const res = await POST( + post({ hostname: HOSTS[0], destination: "https://example.com/a" }), + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.domain.hostname).toBe(HOSTS[0]); + expect(body.domain.dns[0].type).toBe("A"); + const rows = await db + .select() + .from(domain) + .where(eq(domain.hostname, HOSTS[0]!)); + expect(rows).toHaveLength(1); + expect(rows[0]!.userId).toBe(UA); + }); + + it("400 on invalid hostname", async () => { + session.current = { user: { id: UA, email: "a" } }; + const res = await POST( + post({ hostname: "nodot", destination: "https://x.com" }), + ); + expect(res.status).toBe(400); + }); + + it("409 when the same user re-registers", async () => { + session.current = { user: { id: UA, email: "a" } }; + await POST(post({ hostname: HOSTS[0], destination: "https://x.com" })); + const res = await POST( + post({ hostname: HOSTS[0], destination: "https://y.com" }), + ); + expect(res.status).toBe(409); + }); + + it("409 when another account claims the same host", async () => { + session.current = { user: { id: UA, email: "a" } }; + await POST(post({ hostname: HOSTS[0], destination: "https://x.com" })); + session.current = { user: { id: UB, email: "b" } }; + const res = await POST( + post({ hostname: HOSTS[0], destination: "https://hijack.com" }), + ); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/another account/i); + }); +}); + +describe("GET /api/domains", () => { + it("lists only the caller's domains", async () => { + session.current = { user: { id: UA, email: "a" } }; + await POST(post({ hostname: HOSTS[0], destination: "https://a.com" })); + session.current = { user: { id: UB, email: "b" } }; + await POST(post({ hostname: HOSTS[1], destination: "https://b.com" })); + const res = await GET(); + const body = await res.json(); + expect(body.domains).toHaveLength(1); + expect(body.domains[0].hostname).toBe(HOSTS[1]); + }); +}); + +function put(id: string, body: unknown) { + return [ + new Request("http://t", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ] as const; +} + +describe("PUT /api/domains/:id", () => { + it("updates the destination for the owner", async () => { + session.current = { user: { id: UA, email: "a" } }; + const created = await ( + await POST(post({ hostname: HOSTS[0], destination: "https://a.com/old" })) + ).json(); + const id = created.domain.id; + + const res = await PUT(...put(id, { destination: "https://a.com/new" })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.domain.destination).toBe("https://a.com/new"); + + const rows = await db.select().from(domain).where(eq(domain.id, id)); + expect(rows[0]!.destinationUrl).toBe("https://a.com/new"); + }); + + it("400 on an invalid destination", async () => { + session.current = { user: { id: UA, email: "a" } }; + const created = await ( + await POST(post({ hostname: HOSTS[0], destination: "https://a.com" })) + ).json(); + const res = await PUT( + ...put(created.domain.id, { destination: "not-a-url" }), + ); + expect(res.status).toBe(400); + }); + + it("404 when another account tries to edit it", async () => { + session.current = { user: { id: UA, email: "a" } }; + const created = await ( + await POST(post({ hostname: HOSTS[0], destination: "https://a.com" })) + ).json(); + session.current = { user: { id: UB, email: "b" } }; + const res = await PUT( + ...put(created.domain.id, { destination: "https://hijack.com" }), + ); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /api/domains/:id", () => { + it("removes only the caller's own domain", async () => { + session.current = { user: { id: UA, email: "a" } }; + const created = await ( + await POST(post({ hostname: HOSTS[0], destination: "https://a.com" })) + ).json(); + const id = created.domain.id; + + // another user cannot delete it + session.current = { user: { id: UB, email: "b" } }; + const denied = await DELETE(new Request("http://t"), { + params: Promise.resolve({ id }), + }); + expect(denied.status).toBe(404); + + // owner can + session.current = { user: { id: UA, email: "a" } }; + const ok = await DELETE(new Request("http://t"), { + params: Promise.resolve({ id }), + }); + expect(ok.status).toBe(200); + const rows = await db + .select() + .from(domain) + .where(eq(domain.hostname, HOSTS[0]!)); + expect(rows).toHaveLength(0); + }); +}); diff --git a/apps/redirect/web/src/app/api/domains/route.ts b/apps/redirect/web/src/app/api/domains/route.ts new file mode 100644 index 00000000..907724ac --- /dev/null +++ b/apps/redirect/web/src/app/api/domains/route.ts @@ -0,0 +1,104 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { domain } from "@/db/schema"; +import { dnsInstructions, registerSchema } from "@/lib/domains"; +import { exportConfigToGCS } from "@/lib/gcs-export"; + +async function requireUserId(): Promise { + const sess = await auth.api.getSession({ headers: await headers() }); + return sess?.user?.id ?? null; +} + +// GET /api/domains — list the signed-in user's domains. +export async function GET() { + const userId = await requireUserId(); + if (!userId) + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const rows = await db + .select() + .from(domain) + .where(eq(domain.userId, userId)) + .orderBy(domain.hostname); + + return NextResponse.json({ + domains: rows.map((r) => ({ + id: r.id, + hostname: r.hostname, + destination: r.destinationUrl, + createdAt: r.createdAt, + dns: dnsInstructions(r.hostname), + })), + }); +} + +// POST /api/domains — register a custom domain for the signed-in user. +export async function POST(req: Request) { + const userId = await requireUserId(); + if (!userId) + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const parsed = registerSchema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "invalid input" }, + { status: 400 }, + ); + } + const { hostname, destination } = parsed.data; + + // Already claimed (by anyone)? + const existing = await db + .select() + .from(domain) + .where(eq(domain.hostname, hostname)) + .limit(1); + if (existing.length > 0) { + const mine = existing[0]?.userId === userId; + return NextResponse.json( + { + error: mine + ? "you have already registered this domain" + : "this domain is already claimed by another account", + }, + { status: 409 }, + ); + } + + let inserted: typeof domain.$inferSelect | undefined; + try { + [inserted] = await db + .insert(domain) + .values({ userId, hostname, destinationUrl: destination }) + .returning(); + } catch { + // Unique index race → treat as claimed. + return NextResponse.json( + { error: "this domain is already claimed" }, + { status: 409 }, + ); + } + if (!inserted) { + return NextResponse.json( + { error: "failed to register domain" }, + { status: 500 }, + ); + } + + await exportConfigToGCS(); + + return NextResponse.json( + { + domain: { + id: inserted.id, + hostname: inserted.hostname, + destination: inserted.destinationUrl, + dns: dnsInstructions(inserted.hostname), + }, + }, + { status: 201 }, + ); +} diff --git a/apps/redirect/web/src/app/dashboard/page.tsx b/apps/redirect/web/src/app/dashboard/page.tsx new file mode 100644 index 00000000..aec95891 --- /dev/null +++ b/apps/redirect/web/src/app/dashboard/page.tsx @@ -0,0 +1,28 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { domain } from "@/db/schema"; +import { Dashboard } from "@/components/Dashboard"; +import { dnsInstructions } from "@/lib/domains"; + +export default async function DashboardPage() { + const sess = await auth.api.getSession({ headers: await headers() }); + if (!sess?.user) redirect("/"); + + const rows = await db + .select() + .from(domain) + .where(eq(domain.userId, sess.user.id)) + .orderBy(domain.hostname); + + const initial = rows.map((r) => ({ + id: r.id, + hostname: r.hostname, + destination: r.destinationUrl, + dns: dnsInstructions(r.hostname), + })); + + return ; +} diff --git a/apps/redirect/web/src/app/globals.css b/apps/redirect/web/src/app/globals.css new file mode 100644 index 00000000..6dd7f39c --- /dev/null +++ b/apps/redirect/web/src/app/globals.css @@ -0,0 +1,302 @@ +:root { + /* F3 brand: light mode by default, deep-red accent. */ + color-scheme: light; + --bg: #ffffff; + --surface: #ffffff; + --surface-alt: #f6f6f7; + --text: #18181b; + --muted: #6b7280; + --border: #e4e4e7; + --red: #9b1c1c; + --red-hover: #7f1717; + --green: #166534; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + "Segoe UI", + Roboto, + sans-serif; + line-height: 1.5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +.container { + max-width: 760px; + margin: 0 auto; + padding: 1.5rem 1.25rem 4rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.9rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.brand svg { + color: var(--red); +} +.brand-name { + font-weight: 700; + font-size: 1.05rem; +} + +h1 { + font-size: 1.6rem; + margin: 0 0 0.25rem; + font-weight: 800; + letter-spacing: -0.02em; +} + +a { + color: var(--red); +} + +button, +.btn { + font: inherit; + padding: 0.55rem 0.95rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface-alt); + color: var(--text); + cursor: pointer; +} +button:hover { + border-color: var(--red); +} +/* link-styled button (accessible replacement for clickable span/anchor) */ +button.linkbtn { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--red); + cursor: pointer; +} +button.linkbtn:hover { + text-decoration: underline; +} +button.linkbtn.muted { + color: var(--muted); +} +button.primary { + background: var(--red); + border-color: var(--red); + color: #fff; +} +button.primary:hover { + background: var(--red-hover); + border-color: var(--red-hover); +} +button.danger { + background: transparent; + border-color: var(--red); + color: var(--red); +} +button:disabled { + opacity: 0.6; + cursor: default; +} + +input { + font: inherit; + width: 100%; + padding: 0.55rem 0.7rem; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} +input:focus { + outline: none; + border-color: var(--red); +} + +label { + display: block; + font-size: 0.85rem; + color: var(--muted); + margin: 0.75rem 0 0.3rem; +} + +.card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.25rem; + margin: 1rem 0; + background: var(--surface); +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} +/* Domain card header: let the (long) destination wrap instead of overflowing + and pushing the Remove button off the right edge on mobile. */ +.domain-head { + min-width: 0; + flex: 1; +} +.domain-head .dest { + overflow-wrap: anywhere; + word-break: break-word; +} +.row > button { + flex-shrink: 0; +} + +code, +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85rem; +} + +.muted { + color: var(--muted); +} + +.error { + color: var(--red); + font-size: 0.9rem; +} + +/* --- DNS records (stacked) + bottom-sheet drawer --- */ +.badge { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.1rem 0.4rem; + background: var(--surface-alt); +} +.req { + color: var(--green); +} +.dns-record { + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem 0.9rem; + margin: 0.6rem 0; + background: var(--surface-alt); +} +.dns-record-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; +} +.dns-kv { + display: grid; + grid-template-columns: 5rem 1fr; + gap: 0.25rem 0.75rem; + margin: 0; +} +.dns-kv dt { + color: var(--muted); + font-size: 0.8rem; +} +.dns-kv dd { + margin: 0; + word-break: break-all; +} +.dns-note { + font-size: 0.8rem; + margin: 0.5rem 0 0; +} + +dialog.sheet { + border: none; + padding: 0; + background: transparent; + max-width: 100%; + width: 100%; + margin: 0; + position: fixed; + inset: 0; +} +dialog.sheet::backdrop { + background: rgba(0, 0, 0, 0.45); +} +.sheet-body { + position: fixed; + left: 0; + right: 0; + bottom: 0; + background: var(--surface); + border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; + padding: 1rem 1.1rem calc(1.25rem + env(safe-area-inset-bottom)); + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.12); +} + +/* Slide the sheet up from the bottom when it opens (mobile). */ +@keyframes sheet-slide-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} +@keyframes sheet-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +dialog.sheet[open] .sheet-body { + animation: sheet-slide-up 0.3s cubic-bezier(0.32, 0.72, 0, 1); +} +dialog.sheet[open]::backdrop { + animation: sheet-backdrop-in 0.3s ease-out; +} +@media (prefers-reduced-motion: reduce) { + dialog.sheet[open] .sheet-body, + dialog.sheet[open]::backdrop { + animation: none; + } +} + +@media (min-width: 640px) { + .sheet-body { + left: 50%; + right: auto; + bottom: auto; + top: 50%; + transform: translate(-50%, -50%); + width: 520px; + border-radius: 14px; + border: 1px solid var(--border); + } + /* On desktop the sheet is centered; pop instead of slide (keeps centering). */ + @keyframes sheet-pop-in { + from { + opacity: 0; + transform: translate(-50%, -46%); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } + } + dialog.sheet[open] .sheet-body { + animation: sheet-pop-in 0.2s ease-out; + } +} diff --git a/apps/redirect/web/src/app/layout.tsx b/apps/redirect/web/src/app/layout.tsx new file mode 100644 index 00000000..7df7d9d4 --- /dev/null +++ b/apps/redirect/web/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "F3 Redirect — custom domain redirects", + description: "Register a custom domain and redirect it anywhere.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ + F3 Redirect +
+
{children}
+ + + ); +} diff --git a/apps/redirect/web/src/app/page.tsx b/apps/redirect/web/src/app/page.tsx new file mode 100644 index 00000000..6dd45dae --- /dev/null +++ b/apps/redirect/web/src/app/page.tsx @@ -0,0 +1,28 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { AuthForm } from "@/components/AuthForm"; + +export default async function Home() { + const sess = await auth.api.getSession({ headers: await headers() }); + if (sess?.user) redirect("/dashboard"); + + return ( +
+

F3 Redirect

+

+ Register a custom domain and point it anywhere. Sign in, add a domain + plus a destination URL, and we'll give you the exact DNS records to + set up the redirect — with automatic HTTPS. +

+
+

Sign in to manage your domains.

+ +
+

+ Prefer the command line? Admins can still manage redirects via the{" "} + f3redirect CLI. +

+
+ ); +} diff --git a/apps/redirect/web/src/auth.ts b/apps/redirect/web/src/auth.ts new file mode 100644 index 00000000..70764bc6 --- /dev/null +++ b/apps/redirect/web/src/auth.ts @@ -0,0 +1,51 @@ +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { passkey } from "@better-auth/passkey"; +import { db, schema } from "@/db"; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + passkey: schema.passkey, + }, + }), + // TEMPORARY (prototyping): email + password sign-in so we can use the app + // before Google OAuth credentials exist. Swap back to Google (or add the + // passkey plugin) by flipping these — the rest of the app is unchanged. + emailAndPassword: { enabled: true }, + // Only register Google when BOTH credentials are present — enabling it with + // a client ID but empty secret yields a partially configured provider that + // fails at runtime. (Lets us flip to Google later without code changes.) + socialProviders: + process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET + ? { + google: { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + }, + } + : undefined, + secret: process.env.BETTER_AUTH_SECRET, + baseURL: process.env.BETTER_AUTH_URL, + // Cloud Run terminates TLS in front of us; trust the forwarded host. + trustedOrigins: process.env.BETTER_AUTH_URL + ? [process.env.BETTER_AUTH_URL] + : undefined, + plugins: [ + // Secondary, opt-in auth: users add a passkey after signing in with + // email+password. rp/origin are env-configurable per environment. + passkey({ + rpID: process.env.PASSKEY_RP_ID ?? "localhost", + rpName: process.env.PASSKEY_RP_NAME ?? "F3 Redirect", + origin: + process.env.PASSKEY_ORIGIN ?? + process.env.BETTER_AUTH_URL ?? + "http://localhost:3000", + }), + ], +}); diff --git a/apps/redirect/web/src/components/AuthForm.test.tsx b/apps/redirect/web/src/components/AuthForm.test.tsx new file mode 100644 index 00000000..de950406 --- /dev/null +++ b/apps/redirect/web/src/components/AuthForm.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Stub the boundaries (auth-client + router); assert OUR component behavior. +const h = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + signInEmail: vi.fn(), + signUpEmail: vi.fn(), + signInPasskey: vi.fn(), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: h.push, refresh: h.refresh }), +})); +vi.mock("@/lib/auth-client", () => ({ + signIn: { email: h.signInEmail, passkey: h.signInPasskey }, + signUp: { email: h.signUpEmail }, +})); + +import { AuthForm } from "./AuthForm"; + +beforeEach(() => { + Object.values(h).forEach((fn) => fn.mockReset()); +}); + +describe("AuthForm", () => { + it("signs in with email+password and routes to the dashboard", async () => { + h.signInEmail.mockResolvedValue({}); + render(); + await userEvent.type(screen.getByLabelText("Email"), "a@b.com"); + await userEvent.type(screen.getByLabelText("Password"), "pw123456"); + await userEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect(h.signInEmail).toHaveBeenCalledWith({ + email: "a@b.com", + password: "pw123456", + }); + expect(h.push).toHaveBeenCalledWith("/dashboard"); + }); + + it("surfaces the server error and does NOT navigate on failure", async () => { + h.signInEmail.mockResolvedValue({ + error: { message: "invalid credentials" }, + }); + render(); + await userEvent.type(screen.getByLabelText("Email"), "a@b.com"); + await userEvent.type(screen.getByLabelText("Password"), "wrong"); + await userEvent.click(screen.getByRole("button", { name: "Sign in" })); + + expect(await screen.findByText("invalid credentials")).toBeInTheDocument(); + expect(h.push).not.toHaveBeenCalled(); + }); + + it("creates an account in signup mode", async () => { + h.signUpEmail.mockResolvedValue({}); + render(); + await userEvent.click(screen.getByText("new here? create an account")); + await userEvent.type(screen.getByLabelText("Email"), "new@b.com"); + await userEvent.type(screen.getByLabelText("Password"), "pw123456"); + await userEvent.click( + screen.getByRole("button", { name: "Create account" }), + ); + + expect(h.signUpEmail).toHaveBeenCalledWith( + expect.objectContaining({ email: "new@b.com", password: "pw123456" }), + ); + expect(h.signInEmail).not.toHaveBeenCalled(); + }); + + it("signs in with a passkey", async () => { + h.signInPasskey.mockResolvedValue({}); + render(); + await userEvent.click( + screen.getByRole("button", { name: "Sign in with a passkey" }), + ); + + expect(h.signInPasskey).toHaveBeenCalled(); + expect(h.push).toHaveBeenCalledWith("/dashboard"); + }); +}); diff --git a/apps/redirect/web/src/components/AuthForm.tsx b/apps/redirect/web/src/components/AuthForm.tsx new file mode 100644 index 00000000..99099796 --- /dev/null +++ b/apps/redirect/web/src/components/AuthForm.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { signIn, signUp } from "@/lib/auth-client"; + +// TEMPORARY prototyping auth: email + password. (Google / passkeys later.) +export function AuthForm() { + const router = useRouter(); + const [mode, setMode] = useState<"signin" | "signup">("signin"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const res = + mode === "signup" + ? await signUp.email({ + email, + password, + name: email.split("@")[0] ?? email, + }) + : await signIn.email({ email, password }); + if (res.error) { + setError(res.error.message ?? "authentication failed"); + return; + } + router.push("/dashboard"); + router.refresh(); + } catch (e) { + // signIn/signUp can throw (network/parse) — surface it instead of failing + // silently with the form stuck. + setError(e instanceof Error ? e.message : "authentication failed"); + } finally { + setBusy(false); + } + } + + return ( +
+ + setEmail(e.target.value)} + /> + + setPassword(e.target.value)} + /> +
+ + +
+ {error &&

{error}

} + + {mode === "signin" && ( +
+ + + if you've added one + +
+ )} +
+ ); +} diff --git a/apps/redirect/web/src/components/Dashboard.test.tsx b/apps/redirect/web/src/components/Dashboard.test.tsx new file mode 100644 index 00000000..50c11329 --- /dev/null +++ b/apps/redirect/web/src/components/Dashboard.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), + signOut: vi.fn(), + addPasskey: vi.fn(), +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: h.push, refresh: h.refresh }), +})); +vi.mock("@/lib/auth-client", () => ({ + signOut: h.signOut, + passkey: { addPasskey: h.addPasskey }, +})); + +import { Dashboard } from "./Dashboard"; + +const apexDns = [ + { + type: "A", + name: "x.com", + value: "34.172.36.60", + note: "required", + optional: false, + }, +]; + +function mockFetchOnce(status: number, body: unknown) { + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); +} + +beforeEach(() => { + h.push.mockReset(); + h.addPasskey.mockReset(); + global.fetch = vi.fn(); +}); + +describe("Dashboard", () => { + it("registers a domain (POST) and renders the new card", async () => { + render(); + mockFetchOnce(201, { + domain: { + id: "1", + hostname: "x.com", + destination: "https://y.com", + dns: apexDns, + }, + }); + + await userEvent.type(screen.getByLabelText("Custom domain"), "x.com"); + await userEvent.type( + screen.getByLabelText("Redirect destination"), + "https://y.com", + ); + await userEvent.click( + screen.getByRole("button", { name: "Register domain" }), + ); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/domains", + expect.objectContaining({ method: "POST" }), + ); + // Assert on the destination (unique to the card; the hostname also appears + // in the always-rendered DNS-sheet markup). + expect( + await screen.findByText("https://y.com", { exact: false }), + ).toBeInTheDocument(); + }); + + it("surfaces the 'already claimed' error from the API", async () => { + render(); + mockFetchOnce(409, { + error: "this domain is already claimed by another account", + }); + + await userEvent.type(screen.getByLabelText("Custom domain"), "x.com"); + await userEvent.type( + screen.getByLabelText("Redirect destination"), + "https://y.com", + ); + await userEvent.click( + screen.getByRole("button", { name: "Register domain" }), + ); + + expect( + await screen.findByText(/already claimed by another account/i), + ).toBeInTheDocument(); + }); + + it("edits a destination in place (PUT) and shows the new value", async () => { + render( + , + ); + await userEvent.click( + screen.getByRole("button", { name: "Edit destination" }), + ); + const input = screen.getByDisplayValue("https://old.com"); // the card's edit input + await userEvent.clear(input); + await userEvent.type(input, "https://new.com"); + + mockFetchOnce(200, { + domain: { + id: "1", + hostname: "x.com", + destination: "https://new.com", + dns: apexDns, + }, + }); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/domains/1", + expect.objectContaining({ method: "PUT" }), + ); + expect( + await screen.findByText("https://new.com", { exact: false }), + ).toBeInTheDocument(); + }); + + it("removes a domain (DELETE) and the card disappears", async () => { + render( + , + ); + mockFetchOnce(200, { ok: true }); + + await userEvent.click(screen.getByRole("button", { name: "Remove" })); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/domains/1", + expect.objectContaining({ method: "DELETE" }), + ); + await waitFor(() => + expect(screen.queryByText("gone.com")).not.toBeInTheDocument(), + ); + }); + + it("adds a passkey and confirms", async () => { + render(); + h.addPasskey.mockResolvedValue({}); + + await userEvent.click( + screen.getByRole("button", { name: "Add a passkey" }), + ); + + expect(h.addPasskey).toHaveBeenCalled(); + expect(await screen.findByText(/passkey added/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/redirect/web/src/components/Dashboard.tsx b/apps/redirect/web/src/components/Dashboard.tsx new file mode 100644 index 00000000..2df1bc47 --- /dev/null +++ b/apps/redirect/web/src/components/Dashboard.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useRef, useState } from "react"; +import { passkey, signOut } from "@/lib/auth-client"; + +function PasskeySetup() { + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + return ( +
+ + + Optional — sign in faster next time with Touch ID / Face ID / a security + key. + + {msg && ( +

+ {msg} +

+ )} +
+ ); +} + +interface DnsRecord { + type: string; + name: string; + value: string; + note: string; + optional?: boolean; +} +interface DomainItem { + id: string; + hostname: string; + destination: string; + dns: DnsRecord[]; +} +interface ApiResponse { + domain?: DomainItem; + error?: string; +} + +// Stacked, full-width record blocks — no horizontal scroll on mobile. +function DnsRecords({ dns }: { dns: DnsRecord[] }) { + const sorted = [...dns].sort( + (a, b) => Number(a.optional) - Number(b.optional), + ); + return ( +
+ {sorted.map((r, i) => ( +
+
+ {r.type} + {r.optional ? ( + recommended + ) : ( + required + )} +
+
+
Name
+
{r.name}
+
Value
+
{r.value}
+
+

{r.note}

+
+ ))} +
+ ); +} + +// Bottom-sheet drawer (native ) — mobile-friendly, no horizontal scroll. +function DnsSheet({ domain }: { domain: DomainItem }) { + const ref = useRef(null); + return ( + <> + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events -- backdrop-dismiss only; handles Esc/keyboard natively */} + e.target === ref.current && ref.current?.close()} + > +
+
+ {domain.hostname} + +
+

+ Add these DNS records at your registrar to activate the redirect: +

+ +
+
+ + ); +} + +function DomainCard({ + domain, + onChange, + onRemove, +}: { + domain: DomainItem; + onChange: (d: DomainItem) => void; + onRemove: (id: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(domain.destination); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function save() { + setBusy(true); + setError(null); + try { + const res = await fetch(`/api/domains/${domain.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ destination: draft }), + }); + const data = (await res.json()) as ApiResponse; + if (!res.ok) { + setError(data.error ?? "update failed"); + return; + } + if (data.domain) onChange(data.domain); + setEditing(false); + } catch (e) { + // fetch rejection or JSON parse error — surface instead of silently + // leaving the row stuck. + setError(e instanceof Error ? e.message : "update failed"); + } finally { + setBusy(false); + } + } + + async function remove() { + setBusy(true); + try { + const res = await fetch(`/api/domains/${domain.id}`, { + method: "DELETE", + }); + if (res.ok) onRemove(domain.id); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+ {domain.hostname} + {!editing && ( +
→ {domain.destination}
+ )} +
+ {!editing && ( + + )} +
+ + {editing ? ( +
+ + setDraft(e.target.value)} + autoCapitalize="off" + autoCorrect="off" + /> +
+ + +
+ {error &&

{error}

} +
+ ) : ( +
+ + +
+ )} +
+ ); +} + +export function Dashboard({ + initial, + userEmail, +}: { + initial: DomainItem[]; + userEmail: string; +}) { + const router = useRouter(); + const [domains, setDomains] = useState(initial); + const [hostname, setHostname] = useState(""); + const [destination, setDestination] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function add(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + const res = await fetch("/api/domains", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ hostname, destination }), + }); + const data = (await res.json()) as ApiResponse; + if (!res.ok) { + setError(data.error ?? "failed to register domain"); + return; + } + const added = data.domain; + if (added) + setDomains((d) => + [...d, added].sort((a, b) => a.hostname.localeCompare(b.hostname)), + ); + setHostname(""); + setDestination(""); + } catch (e) { + // fetch rejection or JSON parse error — surface instead of silently + // swallowing it. + setError(e instanceof Error ? e.message : "failed to register domain"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

Your domains

+ + {userEmail} ·{" "} + + +
+ +
+ Security + +
+ +
+
+ + setHostname(e.target.value)} + autoCapitalize="off" + autoCorrect="off" + /> + + setDestination(e.target.value)} + autoCapitalize="off" + autoCorrect="off" + /> +
+ +
+ {error &&

{error}

} +
+
+ + {domains.length === 0 && ( +

No domains yet — register one above.

+ )} + + {domains.map((d) => ( + + setDomains((ds) => ds.map((x) => (x.id === u.id ? u : x))) + } + onRemove={(id) => setDomains((ds) => ds.filter((x) => x.id !== id))} + /> + ))} +
+ ); +} diff --git a/apps/redirect/web/src/db/index.ts b/apps/redirect/web/src/db/index.ts new file mode 100644 index 00000000..cfa1e80b --- /dev/null +++ b/apps/redirect/web/src/db/index.ts @@ -0,0 +1,42 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error("DATABASE_URL is not set"); +} + +// Cloud SQL via the Cloud Run connector uses a unix-socket form +// postgres://user:pass@/db?host=/cloudsql/PROJECT:REGION:INSTANCE +// whose empty host is rejected by the WHATWG URL parser, so build the client +// from explicit options in that case. Standard TCP URLs (local Postgres) pass +// straight through. +function makeClient() { + // Match the empty-host socket form. Capture the `host` query param up to the + // next `&` (not `$`), so additional params like `&sslmode=disable` in any + // order don't get swallowed into the socket path. + const socket = + /^postgres(?:ql)?:\/\/([^:@/]+):([^@/]+)@\/([^?]+)\?(.+)$/.exec( + connectionString!, + ); + if (socket) { + const [, user, pass, database, query] = socket; + const host = /(?:^|&)host=([^&]+)/.exec(query ?? "")?.[1]; + if (user && pass && database && host) { + return postgres({ + host: decodeURIComponent(host), + database: decodeURIComponent(database), + username: decodeURIComponent(user), + password: decodeURIComponent(pass), + prepare: false, + }); + } + } + return postgres(connectionString!, { prepare: false }); +} + +const client = makeClient(); + +export const db = drizzle(client, { schema }); +export { schema }; diff --git a/apps/redirect/web/src/db/schema.ts b/apps/redirect/web/src/db/schema.ts new file mode 100644 index 00000000..f73af34c --- /dev/null +++ b/apps/redirect/web/src/db/schema.ts @@ -0,0 +1,109 @@ +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, +} from "drizzle-orm/pg-core"; + +// --- BetterAuth core tables (v1 default schema) --------------------------- + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified") + .$defaultFn(() => false) + .notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), +}); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), +}); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + +// --- Application table: registered redirect domains ----------------------- + +export const domain = pgTable( + "domain", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + // Globally unique: enforces "no one else has claimed this host". + hostname: text("hostname").notNull(), + destinationUrl: text("destination_url").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [uniqueIndex("domain_hostname_unique").on(t.hostname)], +); + +export type Domain = typeof domain.$inferSelect; + +// --- BetterAuth passkey plugin table (generated by @better-auth/cli) ------- + +export const passkey = pgTable( + "passkey", + { + id: text("id").primaryKey(), + name: text("name"), + publicKey: text("public_key").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + credentialID: text("credential_id").notNull(), + counter: integer("counter").notNull(), + deviceType: text("device_type").notNull(), + backedUp: boolean("backed_up").notNull(), + transports: text("transports"), + createdAt: timestamp("created_at"), + aaguid: text("aaguid"), + }, + (table) => [ + index("passkey_userId_idx").on(table.userId), + index("passkey_credentialID_idx").on(table.credentialID), + ], +); diff --git a/apps/redirect/web/src/lib/auth-client.ts b/apps/redirect/web/src/lib/auth-client.ts new file mode 100644 index 00000000..7e6762c8 --- /dev/null +++ b/apps/redirect/web/src/lib/auth-client.ts @@ -0,0 +1,9 @@ +"use client"; + +import { passkeyClient } from "@better-auth/passkey/client"; +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient({ + plugins: [passkeyClient()], +}); +export const { signIn, signUp, signOut, useSession, passkey } = authClient; diff --git a/apps/redirect/web/src/lib/domains.parity.test.ts b/apps/redirect/web/src/lib/domains.parity.test.ts new file mode 100644 index 00000000..7617d77d --- /dev/null +++ b/apps/redirect/web/src/lib/domains.parity.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { dnsInstructions } from "./domains"; + +// The DNS-instruction rules live in BOTH TypeScript (here) and Go +// (internal/mappings). This asserts the TS implementation matches the shared +// contract in ../testdata/dns-instructions.json; the Go suite asserts the same +// file. If either drifts, one suite goes red. + +interface Rec { + type: string; + name: string; + value: string; + optional: boolean; +} +interface Case { + name: string; + host: string; + options: { staticIP: string; canonicalHost: string }; + records: Rec[]; +} + +const fixture = JSON.parse( + readFileSync( + path.join(process.cwd(), "..", "shared", "dns-instructions.json"), + "utf8", + ), +) as { cases: Case[] }; + +function norm( + recs: { type: string; name: string; value: string; optional: boolean }[], +): Rec[] { + return recs + .map((r) => ({ + type: r.type, + name: r.name, + value: r.value, + optional: r.optional, + })) + .sort((a, b) => + a.type === b.type + ? a.name.localeCompare(b.name) + : a.type.localeCompare(b.type), + ); +} + +describe("DNS instruction parity (shared contract with the Go tier)", () => { + it("fixture has cases", () => { + expect(fixture.cases.length).toBeGreaterThan(0); + }); + + for (const c of fixture.cases) { + it(c.name, () => { + const got = dnsInstructions(c.host, { + staticIP: c.options.staticIP, + canonicalHost: c.options.canonicalHost, + }); + expect(norm(got)).toEqual(norm(c.records)); + }); + } +}); diff --git a/apps/redirect/web/src/lib/domains.test.ts b/apps/redirect/web/src/lib/domains.test.ts new file mode 100644 index 00000000..1d104dfc --- /dev/null +++ b/apps/redirect/web/src/lib/domains.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + apexOf, + dnsInstructions, + isApex, + normalizeHost, + registerSchema, +} from "./domains"; + +describe("normalizeHost", () => { + it("lowercases, trims, strips trailing dot and port", () => { + expect(normalizeHost("F3Muletown.com")).toBe("f3muletown.com"); + expect(normalizeHost(" stats.f3muletown.com.")).toBe( + "stats.f3muletown.com", + ); + expect(normalizeHost("f3muletown.com:443")).toBe("f3muletown.com"); + }); +}); + +describe("isApex / apexOf", () => { + it("classifies apex vs subdomain", () => { + expect(isApex("f3muletown.com")).toBe(true); + expect(isApex("f3marshall.com")).toBe(true); + expect(isApex("www.f3muletown.com")).toBe(false); + expect(isApex("stats.f3muletown.com")).toBe(false); + }); + it("returns the registrable apex", () => { + expect(apexOf("stats.f3muletown.com")).toBe("f3muletown.com"); + expect(apexOf("f3muletown.com")).toBe("f3muletown.com"); + }); +}); + +describe("registerSchema", () => { + it("accepts and normalizes valid input", () => { + const r = registerSchema.parse({ + hostname: "F3Muletown.com", + destination: "https://x.com/y", + }); + expect(r.hostname).toBe("f3muletown.com"); + expect(r.destination).toBe("https://x.com/y"); + }); + it("rejects non-FQDN hostnames", () => { + expect( + registerSchema.safeParse({ + hostname: "nodot", + destination: "https://x.com", + }).success, + ).toBe(false); + }); + it("rejects non-http(s) destinations", () => { + expect( + registerSchema.safeParse({ hostname: "ok.com", destination: "ftp://x" }) + .success, + ).toBe(false); + expect( + registerSchema.safeParse({ hostname: "ok.com", destination: "not a url" }) + .success, + ).toBe(false); + }); +}); + +describe("dnsInstructions", () => { + it("apex → required A record FIRST, plus an optional www CNAME suggestion", () => { + const recs = dnsInstructions("f3muletown.com"); + // The A record for the apex is the primary, required record and comes first. + expect(recs[0]).toMatchObject({ + type: "A", + name: "f3muletown.com", + value: "34.172.36.60", + optional: false, + }); + // A secondary, optional suggestion to also redirect the www subdomain. + const www = recs.find((r) => r.name === "www.f3muletown.com"); + expect(www).toMatchObject({ + type: "CNAME", + name: "www.f3muletown.com", + optional: true, + }); + }); + it("subdomain → CNAME to the canonical host when configured (no apex A needed)", () => { + const recs = dnsInstructions("www.f3muletown.com", { + canonicalHost: "redirect.f3regions.com", + }); + expect(recs).toHaveLength(1); + expect(recs[0]).toMatchObject({ + type: "CNAME", + name: "www.f3muletown.com", + value: "redirect.f3regions.com", + }); + }); + + it("subdomain → CNAME to its apex as a fallback when no canonical host is set", () => { + const recs = dnsInstructions("stats.f3muletown.com", { canonicalHost: "" }); + expect(recs[0]).toMatchObject({ + type: "CNAME", + name: "stats.f3muletown.com", + value: "f3muletown.com", + }); + }); + + it("apex still uses an A record regardless of canonical host", () => { + const recs = dnsInstructions("f3muletown.com", { + canonicalHost: "redirect.f3regions.com", + }); + expect(recs[0]).toMatchObject({ type: "A", value: "34.172.36.60" }); + }); +}); diff --git a/apps/redirect/web/src/lib/domains.ts b/apps/redirect/web/src/lib/domains.ts new file mode 100644 index 00000000..260f94cb --- /dev/null +++ b/apps/redirect/web/src/lib/domains.ts @@ -0,0 +1,138 @@ +import { getDomain } from "tldts"; +import { z } from "zod"; + +// Static IP of the redirect tier (apex A-records point here). Overridable so +// the value isn't hardcoded across environments. +export const STATIC_IP = process.env.REDIRECT_STATIC_IP ?? "34.172.36.60"; + +// Canonical host that subdomains CNAME to (it A-records to STATIC_IP). Like +// Vercel's cname.vercel-dns.com — lets a tenant bring just a subdomain with a +// single CNAME and no A record, without touching their apex. Configurable per +// environment. +export const CANONICAL_HOST = process.env.REDIRECT_CANONICAL_HOST ?? ""; + +/** Lower-case, trim, strip a trailing dot and any port. */ +export function normalizeHost(host: string): string { + host = host.trim().toLowerCase().replace(/\.$/, ""); + const colon = host.indexOf(":"); + return colon >= 0 ? host.slice(0, colon) : host; +} + +/** True if host is a registrable apex (equals its own eTLD+1). */ +export function isApex(host: string): boolean { + host = normalizeHost(host); + const reg = getDomain(host); + return reg !== null && reg === host; +} + +/** Registrable (eTLD+1) domain for host, e.g. stats.x.com -> x.com. */ +export function apexOf(host: string): string { + return getDomain(normalizeHost(host)) ?? normalizeHost(host); +} + +const HOSTNAME_RE = + /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; + +const destinationField = z + .string() + .trim() + .min(1, "destination URL is required") + .refine((u) => { + try { + const parsed = new URL(u); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } + }, "must be an absolute http(s) URL"); + +export const registerSchema = z.object({ + hostname: z + .string() + .trim() + .min(1, "hostname is required") + .transform(normalizeHost) + .refine( + (h) => HOSTNAME_RE.test(h), + "must be a valid fully-qualified domain (e.g. example.com)", + ), + destination: destinationField, +}); + +// Editing a redirect only changes the destination; the hostname is immutable +// (changing it is a different registration). +export const updateSchema = z.object({ destination: destinationField }); + +export interface DnsRecord { + type: "A" | "CNAME"; + name: string; + value: string; + note: string; + // false = required to activate the redirect; true = recommended extra. + optional: boolean; +} + +export interface DnsOptions { + staticIP?: string; + canonicalHost?: string; +} + +/** + * DNS record(s) a tenant must create to activate a redirect. + * - apex domains cannot CNAME → A record to the static IP + * - subdomains → a single CNAME to the canonical host (which A-records to the + * static IP). No apex A record is required, and the tenant's apex is left + * untouched. If no canonical host is configured, fall back to CNAME-to-apex. + */ +export function dnsInstructions( + hostname: string, + opts: DnsOptions = {}, +): DnsRecord[] { + const host = normalizeHost(hostname); + const staticIP = opts.staticIP ?? STATIC_IP; + const canonicalHost = opts.canonicalHost ?? CANONICAL_HOST; + + if (isApex(host)) { + // Required: the apex itself via an A record (apex can't CNAME). Recommended + // extra: also redirect the www subdomain, pointed at the apex. + return [ + { + type: "A", + name: host, + value: staticIP, + note: `Required: ${host} is an apex domain and cannot use a CNAME, so point an A record at the redirect tier's static IP.`, + optional: false, + }, + { + type: "CNAME", + name: `www.${host}`, + value: host, + note: `Recommended: so www.${host} redirects too. Point it at ${host} (which carries the A record above).`, + optional: true, + }, + ]; + } + + if (canonicalHost) { + return [ + { + type: "CNAME", + name: host, + value: canonicalHost, + note: `Required: ${host} is a subdomain; add a single CNAME to ${canonicalHost}. No A record is needed.`, + optional: false, + }, + ]; + } + + const apex = apexOf(host); + return [ + { + type: "CNAME", + name: host, + value: apex, + note: `Required: ${host} is a subdomain; CNAME it to ${apex} (which must carry an A record to ${staticIP}).`, + optional: false, + }, + ]; +} diff --git a/apps/redirect/web/src/lib/gcs-export.test.ts b/apps/redirect/web/src/lib/gcs-export.test.ts new file mode 100644 index 00000000..d511dc12 --- /dev/null +++ b/apps/redirect/web/src/lib/gcs-export.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { certObjectsForHost } from "./gcs-export"; + +describe("certObjectsForHost", () => { + const names = [ + "certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.crt", + "certs/certificates/acme-v02.api.letsencrypt.org-directory/f3muletown.com/f3muletown.com.json", + "certs/certificates/acme-v02.api.letsencrypt.org-directory/www.f3muletown.com/www.f3muletown.com.crt", + "certs/certificates/acme-v02.api.letsencrypt.org-directory/stats.f3muletown.com/stats.f3muletown.com.crt", + "certs/acme/acme-v02.api.letsencrypt.org-directory/users/patrick@pstaylor.net/patrick.json", + ]; + + it("matches a host's cert objects by whole path segment", () => { + const got = certObjectsForHost(names, "f3muletown.com"); + expect(got).toHaveLength(2); + expect(got.every((n) => n.includes("/f3muletown.com/"))).toBe(true); + }); + + it("does NOT match www. when cleaning the apex (no over-deletion)", () => { + const got = certObjectsForHost(names, "f3muletown.com"); + expect(got.some((n) => n.includes("www.f3muletown.com"))).toBe(false); + }); + + it("matches a subdomain's own certs", () => { + expect(certObjectsForHost(names, "stats.f3muletown.com")).toHaveLength(1); + }); + + it("returns nothing for an unknown host", () => { + expect(certObjectsForHost(names, "nope.example.com")).toHaveLength(0); + }); +}); diff --git a/apps/redirect/web/src/lib/gcs-export.ts b/apps/redirect/web/src/lib/gcs-export.ts new file mode 100644 index 00000000..89ff843f --- /dev/null +++ b/apps/redirect/web/src/lib/gcs-export.ts @@ -0,0 +1,73 @@ +import { writeFile } from "node:fs/promises"; +import { Storage } from "@google-cloud/storage"; +import { db } from "@/db"; +import { domain } from "@/db/schema"; + +const BUCKET = process.env.CONFIG_BUCKET ?? "f3-redirects-redirect"; +const OBJECT = process.env.CONFIG_OBJECT ?? "config/redirects.json"; +// Dev-only: when set, write the config to a local file instead of GCS (lets +// local development exercise the full export path without GCS credentials). +const LOCAL_PATH = process.env.EXPORT_LOCAL_PATH; + +/** + * Regenerate the flat-file config the Go redirect tier reads, from the current + * set of registered domains in Postgres (the source of truth). Called after + * any add/remove so the live service and the on-demand-TLS gate stay in sync. + * + * Auth is via Application Default Credentials — the Cloud Run runtime service + * account (granted storage.objectAdmin on the bucket). No keys. + */ +export async function exportConfigToGCS(): Promise { + const rows = await db + .select({ host: domain.hostname, target: domain.destinationUrl }) + .from(domain); + + const mappings = rows + .map((r) => ({ host: r.host, target: r.target })) + .sort((a, b) => a.host.localeCompare(b.host)); + + const body = JSON.stringify({ mappings }, null, 2) + "\n"; + + if (LOCAL_PATH) { + await writeFile(LOCAL_PATH, body, "utf8"); + return mappings.length; + } + + const storage = new Storage(); + await storage.bucket(BUCKET).file(OBJECT).save(body, { + contentType: "application/json", + resumable: false, + }); + + return mappings.length; +} + +/** + * Pure: of the given cert-storage object names, which belong to `host`. + * CertMagic stores under `certs/certificates///...`, so we match the + * host as a whole path segment (`//`) — this correctly excludes + * `www.` when cleaning up the apex, and vice-versa. + */ +export function certObjectsForHost(names: string[], host: string): string[] { + const seg = `/${host}/`; + return names.filter((n) => n.includes(seg)); +} + +/** + * Garbage-collect a removed host's TLS cert material from GCS so it doesn't + * linger after the redirect is deleted. Best-effort: individual delete failures + * are swallowed (the mapping removal + export are the critical side effects). + * Returns the number of objects *targeted* for deletion — because failures are + * swallowed, this is not a guaranteed count of successful deletions. No-op in + * local dev (EXPORT_LOCAL_PATH set). + */ +export async function deleteCertsForHost(host: string): Promise { + if (LOCAL_PATH) return 0; + const storage = new Storage(); + const [files] = await storage.bucket(BUCKET).getFiles({ prefix: "certs/" }); + const targets = files.filter( + (f) => certObjectsForHost([f.name], host).length > 0, + ); + await Promise.all(targets.map((f) => f.delete().catch(() => undefined))); + return targets.length; +} diff --git a/apps/redirect/web/src/test-setup.ts b/apps/redirect/web/src/test-setup.ts new file mode 100644 index 00000000..ed3aad2f --- /dev/null +++ b/apps/redirect/web/src/test-setup.ts @@ -0,0 +1,10 @@ +import * as matchers from "@testing-library/jest-dom/matchers"; +import { cleanup } from "@testing-library/react"; +import { afterEach, expect } from "vitest"; + +// Explicitly extend vitest's expect with the jest-dom matchers (more robust +// than the /vitest auto-import under the monorepo's dependency resolution). +expect.extend(matchers); + +// Unmount React trees between tests so the jsdom DOM doesn't leak across cases. +afterEach(() => cleanup()); diff --git a/apps/redirect/web/src/vitest.d.ts b/apps/redirect/web/src/vitest.d.ts new file mode 100644 index 00000000..659ccaa7 --- /dev/null +++ b/apps/redirect/web/src/vitest.d.ts @@ -0,0 +1,10 @@ +import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers"; +import "vitest"; + +// The base tsconfig restricts `types`, so the @testing-library/jest-dom/vitest +// module augmentation isn't auto-applied; declare it explicitly here. +declare module "vitest" { + interface Assertion extends TestingLibraryMatchers {} + interface AsymmetricMatchersContaining + extends TestingLibraryMatchers {} +} diff --git a/apps/redirect/web/tsconfig.json b/apps/redirect/web/tsconfig.json new file mode 100644 index 00000000..67fe48ee --- /dev/null +++ b/apps/redirect/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tooling/typescript/base.json", + "compilerOptions": { + "lib": ["ES2022", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "paths": { "@/*": ["./src/*"] }, + "plugins": [{ "name": "next" }], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules", ".next", "scripts", "e2e", "playwright.config.ts"] +} diff --git a/apps/redirect/web/vitest.config.ts b/apps/redirect/web/vitest.config.ts new file mode 100644 index 00000000..28dd2988 --- /dev/null +++ b/apps/redirect/web/vitest.config.ts @@ -0,0 +1,44 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +// Integration tests run against an ISOLATED database (scripts/reset-test-db.mjs +// creates + pushes `f3redirect_test`), never the shared `f3_test` that +// @acme/db owns and resets. Derive that dedicated URL from whatever server the +// env points at (CI's f3_test service, or the local docker Postgres). +function dedicatedTestDbUrl(): string { + const base = + process.env.TEST_DATABASE_URL ?? + "postgresql://f3local:f3local@localhost:5433/f3nation"; + const u = new URL(base); + u.pathname = "/f3redirect_test"; + return u.toString(); +} + +export default defineConfig({ + // Use the automatic JSX runtime so .tsx (tests + components) don't need a + // React import, matching Next.js. + esbuild: { jsx: "automatic" }, + resolve: { + alias: { "@": path.resolve(__dirname, "src") }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + setupFiles: ["./src/test-setup.ts"], + env: { + // Isolated test DB (see dedicatedTestDbUrl); export writes to a temp file + // (never GCS). + DATABASE_URL: dedicatedTestDbUrl(), + EXPORT_LOCAL_PATH: "/tmp/vitest-redirects.json", + CONFIG_BUCKET: "test-bucket", + REDIRECT_STATIC_IP: "34.172.36.60", + BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret", + }, + coverage: { + provider: "v8", + include: ["src/lib/**", "src/app/api/**", "src/components/**"], + reportsDirectory: "./coverage", + thresholds: { lines: 70, functions: 70, statements: 70, branches: 60 }, + }, + }, +}); diff --git a/packages/db/eslint.config.js b/packages/db/eslint.config.js new file mode 100644 index 00000000..3bfcbbee --- /dev/null +++ b/packages/db/eslint.config.js @@ -0,0 +1,3 @@ +import baseConfig from "@acme/eslint-config/base"; + +export default [...baseConfig]; diff --git a/packages/db/eslint.config.mjs b/packages/db/eslint.config.mjs deleted file mode 100644 index 90a04f52..00000000 --- a/packages/db/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import baseConfig from "@acme/eslint-config/base"; - -export default [{ ignores: ["eslint.config.mjs"] }, ...baseConfig]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 411b7e4a..c2fe3f63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,7 +95,7 @@ catalogs: version: 10.4.0 eslint-config-turbo: specifier: ^2.9.14 - version: 2.9.14 + version: 2.9.15 inquirer: specifier: ^12.11.1 version: 12.11.1 @@ -131,7 +131,7 @@ catalogs: version: 4.22.3 turbo: specifier: ^2.9.14 - version: 2.9.14 + version: 2.9.15 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -184,7 +184,7 @@ importers: version: 4.3.1(@types/node@24.12.4)(typescript@6.0.3) eslint-config-turbo: specifier: 'catalog:' - version: 2.9.14(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.14) + version: 2.9.15(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.15) inquirer: specifier: 'catalog:' version: 12.11.1(@types/node@24.12.4) @@ -196,7 +196,7 @@ importers: version: 3.8.3 turbo: specifier: 'catalog:' - version: 2.9.14 + version: 2.9.15 typescript: specifier: 'catalog:' version: 6.0.3 @@ -235,7 +235,7 @@ importers: version: 1.13.13(@opentelemetry/api@1.9.1) '@orpc/server': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/tanstack-query': specifier: ^1.8.8 version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/client@1.13.13(@opentelemetry/api@1.9.1))(@tanstack/query-core@5.90.12) @@ -271,7 +271,7 @@ importers: version: 7.4.4 geist: specifier: ^1.2.2 - version: 1.7.0(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.7.0(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) jose: specifier: ^6.2.1 version: 6.2.2 @@ -380,13 +380,13 @@ importers: version: 0.8.4 '@orpc/openapi': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/tanstack-query': specifier: ^1.8.8 version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/client@1.13.13(@opentelemetry/api@1.9.1))(@tanstack/query-core@5.90.12) '@orpc/zod': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1))(zod@3.25.76) + version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0))(ws@8.20.0)(zod@3.25.76) '@radix-ui/react-dialog': specifier: ^1.0.4 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -398,7 +398,7 @@ importers: version: 0.9.26(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^9.15.0 - version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) + version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) '@t3-oss/env-nextjs': specifier: ^0.9.2 version: 0.9.2(typescript@6.0.3)(zod@3.25.76) @@ -443,7 +443,7 @@ importers: version: 15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-plausible: specifier: ^3.12.0 - version: 3.12.5(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.12.5(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 18.3.1 version: 18.3.1 @@ -540,7 +540,7 @@ importers: version: 5.2.0(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)) cross-fetch: specifier: 'catalog:' version: 4.1.0 @@ -552,7 +552,7 @@ importers: version: 2.7.0 jsdom: specifier: 'catalog:' - version: 29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) prettier: specifier: 'catalog:' version: 3.8.3 @@ -567,10 +567,10 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1)) vitest: specifier: 'catalog:' - version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)) + version: 1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)) apps/auth: dependencies: @@ -588,7 +588,7 @@ importers: version: 0.9.2(typescript@6.0.3)(zod@3.25.76) drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) jose: specifier: ^6.2.1 version: 6.2.2 @@ -679,7 +679,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) apps/map: dependencies: @@ -715,13 +715,13 @@ importers: version: 0.8.4 '@orpc/openapi': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/tanstack-query': specifier: ^1.8.8 version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/client@1.13.13(@opentelemetry/api@1.9.1))(@tanstack/query-core@5.90.12) '@orpc/zod': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1))(zod@3.25.76) + version: 1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0))(ws@8.20.0)(zod@3.25.76) '@radix-ui/react-dialog': specifier: ^1.0.4 version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -757,7 +757,7 @@ importers: version: 7.4.4 geist: specifier: ^1.2.2 - version: 1.7.0(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.7.0(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) handlebars: specifier: ^4.7.8 version: 4.7.9 @@ -875,7 +875,7 @@ importers: version: 5.2.0(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)) cross-fetch: specifier: 'catalog:' version: 4.1.0 @@ -887,7 +887,7 @@ importers: version: 2.7.0 jsdom: specifier: 'catalog:' - version: 29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) prettier: specifier: 'catalog:' version: 3.8.3 @@ -902,10 +902,10 @@ importers: version: 6.1.1(typescript@6.0.3)(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1)) vitest: specifier: 'catalog:' - version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) vitest-canvas-mock: specifier: 'catalog:' - version: 1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)) + version: 1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)) apps/me: dependencies: @@ -929,7 +929,7 @@ importers: version: 1.13.13(@opentelemetry/api@1.9.1) '@orpc/server': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) clsx: specifier: ^2.1.0 version: 2.1.1 @@ -978,7 +978,7 @@ importers: version: 5.2.0(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1)) '@vitest/coverage-v8': specifier: 'catalog:' - version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)) autoprefixer: specifier: 'catalog:' version: 10.5.0(postcss@8.5.15) @@ -1002,7 +1002,93 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) + + apps/redirect/server: {} + + apps/redirect/shared: {} + + apps/redirect/web: + dependencies: + '@better-auth/passkey': + specifier: ^1.6.11 + version: 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)))(better-call@1.3.5(zod@3.25.76))(nanostores@1.3.0) + '@google-cloud/storage': + specifier: ^7.14.0 + version: 7.19.0 + better-auth: + specifier: ^1.6.11 + version: 1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) + next: + specifier: ^15.3.6 + version: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + postgres: + specifier: ^3.4.3 + version: 3.4.9 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + tldts: + specifier: ^6.1.71 + version: 6.1.86 + zod: + specifier: ^3.25.8 + version: 3.25.76 + devDependencies: + '@acme/eslint-config': + specifier: workspace:^0.2.0 + version: link:../../../tooling/eslint + '@acme/tsconfig': + specifier: workspace:^0.1.0 + version: link:../../../tooling/typescript + '@playwright/test': + specifier: ^1.52.0 + version: 1.59.1 + '@testing-library/jest-dom': + specifier: ^6.4.2 + version: 6.9.1 + '@testing-library/react': + specifier: ^15.0.2 + version: 15.0.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^20.11.13 + version: 20.19.41 + '@types/react': + specifier: ^18.3.1 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.28) + '@vitest/coverage-v8': + specifier: ^1.5.0 + version: 1.6.1(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)) + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 + eslint: + specifier: 'catalog:' + version: 10.4.0(jiti@2.7.0) + jsdom: + specifier: ^24.0.0 + version: 24.1.3 + prettier: + specifier: ^3.2.5 + version: 3.8.3 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: ^1.5.0 + version: 1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1) packages/api: dependencies: @@ -1029,10 +1115,10 @@ importers: version: 1.13.13(@opentelemetry/api@1.9.1) '@orpc/experimental-ratelimit': specifier: ^1.13.2 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/server': specifier: ^1.8.8 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) axios: specifier: '>=1.7.4' version: 1.14.0 @@ -1050,7 +1136,7 @@ importers: version: 4.18.1 next: specifier: '>=14' - version: 15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) superjson: specifier: 2.2.1 version: 2.2.1 @@ -1160,7 +1246,7 @@ importers: version: 7.4.4 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) esbuild-register: specifier: ^3.5.0 version: 3.6.0(esbuild@0.28.0) @@ -1332,7 +1418,7 @@ importers: version: 6.0.3 vitest: specifier: 'catalog:' - version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) packages/ui: dependencies: @@ -1344,67 +1430,67 @@ importers: version: 3.10.0(react-hook-form@7.72.1(react@18.3.1)) '@radix-ui/react-alert-dialog': specifier: ^1.0.4 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-avatar': specifier: ^1.0.4 - version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-checkbox': specifier: ^1.0.4 - version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-context-menu': specifier: ^2.1.4 - version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.0.4 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 - version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-hover-card': specifier: ^1.0.6 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.0 version: 1.3.2(react@18.3.1) '@radix-ui/react-label': specifier: ^2.0.2 - version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-navigation-menu': specifier: ^1.1.4 - version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-popover': specifier: ^1.0.6 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-progress': specifier: ^1.0.3 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-radio-group': specifier: ^1.1.3 - version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^1.2.2 - version: 1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-separator': specifier: ^1.0.3 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slider': specifier: ^1.1.2 - version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.3.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.2.4(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-switch': specifier: ^1.0.3 - version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': specifier: ^1.0.4 - version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-toast': specifier: ^1.1.5 - version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.0.7 - version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@tanstack/react-query': specifier: 5.90.12 version: 5.90.12(react@18.3.1) @@ -1413,10 +1499,10 @@ importers: version: 5.96.2(@tanstack/react-query@5.90.12(react@18.3.1))(react@18.3.1) '@tanstack/react-table': specifier: ^8.20.5 - version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 8.21.3(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@tanstack/react-virtual': specifier: ^3.13.6 - version: 3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.13.23(react-dom@19.2.6(react@18.3.1))(react@18.3.1) class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -1425,7 +1511,7 @@ importers: version: 2.1.1 cmdk: specifier: ^0.2.1 - version: 0.2.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.2.1(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) cmk: specifier: ^0.1.1 version: 0.1.1 @@ -1437,7 +1523,7 @@ importers: version: 0.557.0(react@18.3.1) next-themes: specifier: ^0.4.4 - version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.4.6(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react-day-picker: specifier: ^8.10.0 version: 8.10.1(date-fns@3.6.0)(react@18.3.1) @@ -1446,7 +1532,7 @@ importers: version: 7.72.1(react@18.3.1) sonner: specifier: ^1.4.0 - version: 1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.7.4(react-dom@19.2.6(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^2.2.1 version: 2.6.1 @@ -1455,7 +1541,7 @@ importers: version: 1.0.7(tailwindcss@3.4.19(tsx@4.22.3)) vaul: specifier: ^0.9.0 - version: 0.9.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.9.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) devDependencies: '@acme/eslint-config': specifier: workspace:^0.2.0 @@ -1504,7 +1590,7 @@ importers: version: link:../shared drizzle-zod: specifier: ^0.7.1 - version: 0.7.1(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9))(zod@3.25.76) + version: 0.7.1(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(zod@3.25.76) zod: specifier: ^3.25.8 version: 3.25.76 @@ -1535,7 +1621,7 @@ importers: version: 16.2.6 eslint-config-turbo: specifier: 'catalog:' - version: 2.9.14(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.14) + version: 2.9.15(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.15) eslint-plugin-import-x: specifier: ^4.15.1 version: 4.16.2(@typescript-eslint/utils@8.60.0(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0)) @@ -1692,6 +1778,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1772,26 +1861,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1805,19 +1886,14 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -1842,14 +1918,102 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@better-auth/core@1.6.11': + resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==} + peerDependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.5 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.11': + resolution: {integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.11': + resolution: {integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + kysely: ^0.28.17 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.11': + resolution: {integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + + '@better-auth/mongo-adapter@1.6.11': + resolution: {integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/passkey@1.6.11': + resolution: {integrity: sha512-QjL+OyiKRSHFRhSp2CSe7u5jnRL5G+Eh4bW9eV4WFZQ+2a/S+113kHQxPqxhy3Onb5cQhkT5Bhyz7cxKNDJTPw==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + better-auth: ^1.6.11 + better-call: 1.3.5 + nanostores: ^1.0.1 + + '@better-auth/prisma-adapter@1.6.11': + resolution: {integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.11': + resolution: {integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==} + peerDependencies: + '@better-auth/core': ^1.6.11 + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.4.0': + resolution: {integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -1966,10 +2130,21 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-calc@3.2.1': resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} engines: {node: '>=20.19.0'} @@ -1977,6 +2152,13 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-color-parser@4.1.1': resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} engines: {node: '>=20.19.0'} @@ -1984,6 +2166,12 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + '@csstools/css-parser-algorithms@4.0.0': resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} engines: {node: '>=20.19.0'} @@ -1998,6 +2186,10 @@ packages: css-tree: optional: true + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@csstools/css-tokenizer@4.0.0': resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} @@ -2837,6 +3029,9 @@ packages: resolution: {integrity: sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==} engines: {node: '>=14'} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hookform/resolvers@3.10.0': resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} peerDependencies: @@ -3308,10 +3503,14 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/schema@0.1.6': - resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3334,6 +3533,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -3395,6 +3597,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -3737,6 +3947,46 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@peculiar/asn1-android@2.7.0': + resolution: {integrity: sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==} + + '@peculiar/asn1-cms@2.7.0': + resolution: {integrity: sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==} + + '@peculiar/asn1-csr@2.7.0': + resolution: {integrity: sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==} + + '@peculiar/asn1-ecc@2.7.0': + resolution: {integrity: sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==} + + '@peculiar/asn1-pfx@2.7.0': + resolution: {integrity: sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==} + + '@peculiar/asn1-pkcs8@2.7.0': + resolution: {integrity: sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==} + + '@peculiar/asn1-pkcs9@2.7.0': + resolution: {integrity: sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==} + + '@peculiar/asn1-rsa@2.7.0': + resolution: {integrity: sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==} + + '@peculiar/asn1-schema@2.7.0': + resolution: {integrity: sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==} + + '@peculiar/asn1-x509-attr@2.7.0': + resolution: {integrity: sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==} + + '@peculiar/asn1-x509@2.7.0': + resolution: {integrity: sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@phun-ky/typeof@2.0.3': resolution: {integrity: sha512-oeQJs1aa8Ghke8JIK9yuq/+KjMiaYeDZ38jx7MhkXncXlUKjqQ3wEm2X3qCKyjo+ZZofZj+WsEEiqkTtRuE2xQ==} engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'} @@ -3745,6 +3995,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -4988,6 +5243,16 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} + '@simplewebauthn/browser@13.3.0': + resolution: {integrity: sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==} + + '@simplewebauthn/server@13.3.0': + resolution: {integrity: sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==} + engines: {node: '>=20.0.0'} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5068,6 +5333,12 @@ packages: '@types/react': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tootallnate/once@2.0.1': resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} engines: {node: '>= 10'} @@ -5087,13 +5358,13 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@turbo/darwin-64@2.9.14': - resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} + '@turbo/darwin-64@2.9.15': + resolution: {integrity: sha512-nnDo9R1Df+s2x6jxlERtbg7xRpuicf8p4J2krcnjeaMBt3q9V41pGXa4t9YM2Y4ozozsVJ+CH405CJUrWIQK4Q==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.14': - resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} + '@turbo/darwin-arm64@2.9.15': + resolution: {integrity: sha512-fDSx56oqoFuS+yUQw7hqjQTkjrSLdMcplhuLC8HcSkWC6YrpwEmUUYsPYHPxy4ALvLxnmPQuk6XoSD8tdkjP+g==} cpu: [arm64] os: [darwin] @@ -5101,23 +5372,23 @@ packages: resolution: {integrity: sha512-PK38N1fHhDUyjLi0mUjv0RbX0xXGwDLQeRSGsIlLcVpP1B5fwodSIwIYXc9vJok26Yne94BX5AGjueYsUT3uUw==} hasBin: true - '@turbo/linux-64@2.9.14': - resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} + '@turbo/linux-64@2.9.15': + resolution: {integrity: sha512-/bmxn+x/xE+oh0VzEXt/zf2zsORAYZPrL3db5/VrXzYt0Z4wxcvffwJBGlSfla2smfS1BLGBiyWldJlWDXJVXA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.14': - resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} + '@turbo/linux-arm64@2.9.15': + resolution: {integrity: sha512-cbOaDe1ijz5As+mimOOHgmRMolZZZO7miNBHHp5xdiYMm2Q/Dwu1JVLx/Kw4s7xjocG/oEoHrpHrxpEAIEfNiw==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.14': - resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} + '@turbo/windows-64@2.9.15': + resolution: {integrity: sha512-/Fzm7afui7uK7dFBwrTXKuDhBBTiHk5I+hMVAPMR7cqQyDo2norCNUsN9PdNuYcmzYbhSOxzz498wQYvSAz29w==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.14': - resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} + '@turbo/windows-arm64@2.9.15': + resolution: {integrity: sha512-fOHEsLcqVdFXLw2ApWv4gxwfHzkUnpo9rHGml+9+dyHj148m/Bc+556kEvb5+4u6prI1LMd8zEZE2HcO6Jn2VQ==} cpu: [arm64] os: [win32] @@ -5205,6 +5476,9 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -5453,6 +5727,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/coverage-v8@1.6.1': + resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} + peerDependencies: + vitest: 1.6.1 + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -5462,6 +5741,9 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -5479,15 +5761,27 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + '@vitest/runner@3.2.4': resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + '@vitest/snapshot@3.2.4': resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -5700,6 +5994,13 @@ packages: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5786,6 +6087,76 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + better-auth@1.6.11: + resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.5: + resolution: {integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -5882,6 +6253,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -5911,6 +6286,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -6052,6 +6430,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} @@ -6163,6 +6544,10 @@ packages: cssfontparser@1.2.1: resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -6185,6 +6570,10 @@ packages: resolution: {integrity: sha512-CuRUx0TXGSbbWdEci3VK/XOZGP3n0P4pIKpsqpVtBqaIIuj3GKK8H45oAqA4Rg8FHipc+CzRdUzmD4YQXxv66Q==} engines: {node: '>= 14'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6222,6 +6611,10 @@ packages: dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -6306,6 +6699,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} @@ -6634,8 +7031,8 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-turbo@2.9.14: - resolution: {integrity: sha512-8bAXNDwtmHV7CuSDX+FB9+TslZEP8qJoNWY9FQTEhyO42bRimcExxwOh1+K2H2JV2VFXJrkt1KxyJmy2xm8Ukw==} + eslint-config-turbo@2.9.15: + resolution: {integrity: sha512-YMG1J/fbNCqhUqGf4zQImHiDVhydTSm5noe67uc9ksRo6BJIHucjyGQrflE9n54hCJyPsEKATMdlk30SO9sv3g==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -6680,8 +7077,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.9.14: - resolution: {integrity: sha512-ROTlsO1JBJLATxtDNd7t22vviSb0hD8fKnjOO0WRgtxJW3VBRNO3BLAC129GPZSvEvtMH/f71Y2TzrqGPjLpEw==} + eslint-plugin-turbo@2.9.15: + resolution: {integrity: sha512-IBV3/xMl/TuK7pdW4nHQtbEOq/Si1e4WVOFuEmNsdX0VgzC9CWWtYf+kUH5gqz4h9+uRRYY7nR9SvzCEx2sWlQ==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -6763,6 +7160,10 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + expand-tilde@2.0.2: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} @@ -6972,6 +7373,9 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -6988,6 +7392,10 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -7152,6 +7560,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -7193,6 +7605,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} @@ -7440,6 +7856,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -7562,6 +7982,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@24.1.3: + resolution: {integrity: sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -7628,6 +8057,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} + engines: {node: '>=20.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -7711,6 +8144,10 @@ packages: resolution: {integrity: sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==} engines: {node: '>=4.0.0'} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -7781,6 +8218,9 @@ packages: lottie-web@5.13.0: resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -7793,8 +8233,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.5.0: - resolution: {integrity: sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -7887,6 +8327,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -7928,6 +8372,9 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -7966,6 +8413,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanostores@1.3.0: + resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -8082,9 +8533,16 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + nypm@0.6.6: resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==} engines: {node: '>=18'} @@ -8138,6 +8596,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -8181,6 +8643,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -8266,6 +8732,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -8277,9 +8747,15 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + pathval@2.0.1: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} @@ -8340,16 +8816,29 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + playwright-core@1.60.0: resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -8513,6 +9002,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -8541,13 +9034,26 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qr.js@0.0.0: resolution: {integrity: sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -8581,6 +9087,11 @@ packages: peerDependencies: react: ^18.3.1 + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + react-hook-form@7.72.1: resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} engines: {node: '>=18.0.0'} @@ -8593,6 +9104,9 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-lottie@1.2.10: resolution: {integrity: sha512-x0eWX3Z6zSx1XM5QSjnLupc6D22LlMCB0PH06O/N/epR2hsLaj1Vxd9RtMnbbEHjJ/qlsgHJ6bpN3vnZI92hjw==} peerDependencies: @@ -8673,6 +9187,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -8692,6 +9210,9 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -8723,6 +9244,9 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} engines: {node: '>=0.10.0'} @@ -8785,6 +9309,12 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -8832,6 +9362,9 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@4.3.3: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} @@ -8851,6 +9384,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -9037,6 +9573,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -9049,6 +9589,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -9151,6 +9694,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -9185,6 +9732,10 @@ packages: tinygradient@1.1.5: resolution: {integrity: sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==} + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + tinypool@1.1.1: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -9193,6 +9744,10 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -9200,9 +9755,16 @@ packages: title-case@2.1.1: resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + tldts-core@7.4.0: resolution: {integrity: sha512-/mb9kRld+x1sIMXxWNOAp5m6C+D4GrAORWlJkOJ5dElvxdN1eutz/o7qHLp9gFvDF4Y3/L2xeScoxz6AbEo8rQ==} + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + tldts@7.4.0: resolution: {integrity: sha512-yHBe+zVfzNZ3QfTPW/Z6KK1G2t340gFjMHqI/4KKSt/abzYydzuCnpqdaF5gCCABby+9Yfbj59oR5F2Fd5CBzg==} hasBin: true @@ -9215,6 +9777,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -9222,6 +9788,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tr46@6.0.0: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} @@ -9275,14 +9845,22 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.9.14: - resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + turbo@2.9.15: + resolution: {integrity: sha512-VpKvD9Z0Hu/xrGUAYX1wnhfpqv835wIwGqeKfulvBPTOcDap0E3nFwyzCAVV85fB1sBcBDEfTP+7FSW7GzwWSQ==} hasBin: true type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + type-fest@0.21.3: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} @@ -9327,6 +9905,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -9336,6 +9917,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -9354,6 +9938,10 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -9386,6 +9974,9 @@ packages: resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -9425,6 +10016,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -9440,6 +10032,11 @@ packages: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -9486,6 +10083,31 @@ packages: peerDependencies: vitest: ^3.0.0 || ^4.0.0 + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -9532,6 +10154,10 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -9566,6 +10192,10 @@ packages: resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} engines: {node: '>=20'} + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + whatwg-url@16.0.1: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -9636,6 +10266,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -9678,6 +10320,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} @@ -9689,8 +10335,8 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} @@ -9718,6 +10364,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -9785,10 +10439,10 @@ snapshots: '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -9819,7 +10473,7 @@ snapshots: '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 transitivePeerDependencies: - supports-color @@ -9827,45 +10481,37 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.29.7 + '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@7.28.6': {} '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 '@babel/runtime-corejs3@7.29.2': dependencies: @@ -9896,13 +10542,77 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0)': + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.5(zod@3.25.76) + jose: 6.2.2 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + '@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) + + '@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + optionalDependencies: + kysely: 0.28.17 + + '@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/passkey@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)))(better-call@1.3.5(zod@3.25.76))(nanostores@1.3.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@simplewebauthn/browser': 13.3.0 + '@simplewebauthn/server': 13.3.0 + better-auth: 1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)) + better-call: 1.3.5(zod@3.25.76) + nanostores: 1.3.0 + zod: 4.3.6 + + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + + '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.4.0': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.1.21': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -10082,13 +10792,27 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + '@csstools/color-helpers@6.0.2': {} + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.0.2 @@ -10096,6 +10820,10 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-tokenizer': 4.0.0 @@ -10104,6 +10832,8 @@ snapshots: optionalDependencies: css-tree: 3.2.1 + '@csstools/css-tokenizer@3.0.4': {} + '@csstools/css-tokenizer@4.0.0': {} '@drizzle-team/brocli@0.10.2': {} @@ -10533,7 +11263,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 '@faker-js/faker@10.4.0': {} @@ -10546,11 +11278,11 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/dom': 1.7.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) '@floating-ui/utils@0.2.11': {} @@ -10586,6 +11318,8 @@ snapshots: - encoding - supports-color + '@hexagon/base64@1.1.28': {} + '@hookform/resolvers@3.10.0(react-hook-form@7.72.1(react@18.3.1))': dependencies: react-hook-form: 7.72.1(react@18.3.1) @@ -10968,7 +11702,11 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.6': {} + '@istanbuljs/schema@0.1.3': {} + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -10999,6 +11737,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@levischuck/tiny-cbor@0.2.11': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.10.0 @@ -11036,6 +11776,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.14': optional: true + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + '@nodable/entities@2.1.0': {} '@nodelib/fs.scandir@2.1.5': @@ -11373,10 +12117,10 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/experimental-ratelimit@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/experimental-ratelimit@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0)': dependencies: '@orpc/client': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1) + '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/standard-server': 1.13.13(@opentelemetry/api@1.9.1) transitivePeerDependencies: @@ -11387,12 +12131,12 @@ snapshots: '@orpc/interop@1.13.13': {} - '@orpc/json-schema@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/json-schema@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0)': dependencies: '@orpc/contract': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/interop': 1.13.13 - '@orpc/openapi': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1) + '@orpc/openapi': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) + '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) json-schema-typed: 8.0.2 transitivePeerDependencies: @@ -11410,13 +12154,13 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/openapi@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/openapi@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0)': dependencies: '@orpc/client': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/contract': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/interop': 1.13.13 '@orpc/openapi-client': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1) + '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/standard-server': 1.13.13(@opentelemetry/api@1.9.1) json-schema-typed: 8.0.2 @@ -11427,7 +12171,7 @@ snapshots: - fastify - ws - '@orpc/server@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/server@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0)': dependencies: '@orpc/client': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/contract': 1.13.13(@opentelemetry/api@1.9.1) @@ -11440,6 +12184,8 @@ snapshots: '@orpc/standard-server-node': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/standard-server-peer': 1.13.13(@opentelemetry/api@1.9.1) cookie: 1.1.1 + optionalDependencies: + ws: 8.20.0 transitivePeerDependencies: - '@opentelemetry/api' - fastify @@ -11504,12 +12250,12 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/zod@1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1))(zod@3.25.76)': + '@orpc/zod@1.13.13(@opentelemetry/api@1.9.1)(@orpc/contract@1.13.13(@opentelemetry/api@1.9.1))(@orpc/server@1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0))(ws@8.20.0)(zod@3.25.76)': dependencies: '@orpc/contract': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/json-schema': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/openapi': 1.13.13(@opentelemetry/api@1.9.1) - '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1) + '@orpc/json-schema': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) + '@orpc/openapi': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) + '@orpc/server': 1.13.13(@opentelemetry/api@1.9.1)(ws@8.20.0) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) escape-string-regexp: 5.0.0 wildcard-match: 5.1.4 @@ -11524,11 +12270,115 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@peculiar/asn1-android@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.7.0': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-pfx': 2.7.0 + '@peculiar/asn1-pkcs8': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/asn1-x509-attr': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.7.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.7.0': + dependencies: + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.7.0 + '@peculiar/asn1-csr': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-pkcs9': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@phun-ky/typeof@2.0.3': {} '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -11556,89 +12406,89 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-arrow@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-avatar@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-collection@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.0.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11661,16 +12511,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11699,24 +12549,24 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-dialog@1.0.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dialog@1.0.0(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-context': 1.0.0(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.0.0(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.0.0(react@18.3.1) - '@radix-ui/react-portal': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.0.0(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.0.0(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) react-remove-scroll: 2.5.4(@types/react@18.3.28)(react@18.3.1) transitivePeerDependencies: - '@types/react' @@ -11743,6 +12593,28 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@radix-ui/react-direction@1.0.1(@types/react@18.3.28)(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 @@ -11756,27 +12628,27 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-dismissable-layer@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.0 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.0.0(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) - '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/primitive': 1.0.1 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11794,17 +12666,30 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11827,23 +12712,23 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-focus-scope@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.0(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) - '@radix-ui/react-focus-scope@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11859,19 +12744,30 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -11901,136 +12797,136 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-label@2.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-menu@2.1.16(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-popper@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-rect': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/rect': 1.0.1 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/rect': 1.1.1 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-portal@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 - '@radix-ui/react-primitive': 1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) - '@radix-ui/react-portal@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -12045,13 +12941,23 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-presence@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-presence@1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.0(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -12063,19 +12969,29 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-primitive@1.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@1.0.0(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-slot': 1.0.0(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) - '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/react-slot': 1.0.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -12089,114 +13005,123 @@ snapshots: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-select@1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-select@1.2.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 '@radix-ui/number': 1.0.1 '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.0.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.0.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) aria-hidden: 1.2.6 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) react-remove-scroll: 2.5.5(@types/react@18.3.28)(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.1 '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -12229,73 +13154,73 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-toast@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.3 '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -12436,21 +13361,21 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 - '@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 '@types/react-dom': 18.3.7(@types/react@18.3.28) @@ -12612,7 +13537,7 @@ snapshots: '@scalar/helpers': 0.2.18 nanoid: 5.1.7 type-fest: 5.5.0 - zod: 4.4.3 + zod: 4.3.6 '@sentry-internal/browser-utils@9.47.1': dependencies: @@ -12702,7 +13627,7 @@ snapshots: '@sentry/core@9.47.1': {} - '@sentry/nextjs@9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)': + '@sentry/nextjs@9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 @@ -12728,6 +13653,32 @@ snapshots: - supports-color - webpack + '@sentry/nextjs@9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.60.1) + '@sentry-internal/browser-utils': 9.47.1 + '@sentry/core': 9.47.1 + '@sentry/node': 9.47.1 + '@sentry/opentelemetry': 9.47.1(@opentelemetry/api@1.9.1)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/react': 9.47.1(react@18.3.1) + '@sentry/vercel-edge': 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)) + '@sentry/webpack-plugin': 3.6.1(webpack@5.105.4) + chalk: 3.0.0 + next: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + resolve: 1.22.8 + rollup: 4.60.1 + stacktrace-parser: 0.1.11 + transitivePeerDependencies: + - '@opentelemetry/context-async-hooks' + - '@opentelemetry/core' + - '@opentelemetry/sdk-trace-base' + - encoding + - react + - supports-color + - webpack + '@sentry/node-core@9.47.1(@opentelemetry/api@1.9.1)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)': dependencies: '@opentelemetry/api': 1.9.1 @@ -12825,6 +13776,21 @@ snapshots: '@simple-libs/stream-utils@1.2.0': {} + '@simplewebauthn/browser@13.3.0': {} + + '@simplewebauthn/server@13.3.0': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.7.0 + '@peculiar/asn1-ecc': 2.7.0 + '@peculiar/asn1-rsa': 2.7.0 + '@peculiar/asn1-schema': 2.7.0 + '@peculiar/asn1-x509': 2.7.0 + '@peculiar/x509': 1.14.3 + + '@sinclair/typebox@0.27.10': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -12859,11 +13825,11 @@ snapshots: '@tanstack/query-core': 5.90.12 react: 18.3.1 - '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tanstack/react-table@8.21.3(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/table-core': 8.21.3 react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) '@tanstack/react-virtual@3.13.23(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -12871,6 +13837,12 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-virtual@3.13.23(react-dom@19.2.6(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.23 + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + '@tanstack/table-core@8.21.3': {} '@tanstack/virtual-core@3.13.23': {} @@ -12905,6 +13877,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.28 + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tootallnate/once@2.0.1': {} '@tootallnate/quickjs-emscripten@0.23.0': {} @@ -12917,10 +13893,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@turbo/darwin-64@2.9.14': + '@turbo/darwin-64@2.9.15': optional: true - '@turbo/darwin-arm64@2.9.14': + '@turbo/darwin-arm64@2.9.15': optional: true '@turbo/gen@1.13.4(@types/node@24.12.4)(typescript@6.0.3)': @@ -12943,16 +13919,16 @@ snapshots: - supports-color - typescript - '@turbo/linux-64@2.9.14': + '@turbo/linux-64@2.9.15': optional: true - '@turbo/linux-arm64@2.9.14': + '@turbo/linux-arm64@2.9.15': optional: true - '@turbo/windows-64@2.9.14': + '@turbo/windows-64@2.9.15': optional: true - '@turbo/windows-arm64@2.9.14': + '@turbo/windows-arm64@2.9.15': optional: true '@turbo/workspaces@1.13.4(@types/node@24.12.4)': @@ -12981,28 +13957,28 @@ snapshots: '@types/aws-sdk2-types@0.0.6': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 '@types/caseless@0.12.5': {} @@ -13013,7 +13989,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/cookie@0.6.0': {} @@ -13042,7 +14018,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/google.maps@3.58.1': {} @@ -13063,7 +14039,11 @@ snapshots: '@types/mysql@2.15.26': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 '@types/node@24.12.4': dependencies: @@ -13080,19 +14060,19 @@ snapshots: '@types/nodemailer-smtp-transport@2.7.8': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/nodemailer': 3.1.14 '@types/nodemailer@3.1.14': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/nodemailer-direct-transport': 1.0.35 '@types/nodemailer-ses-transport': 1.5.5 '@types/nodemailer-smtp-transport': 2.7.8 '@types/nodemailer@6.4.23': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/parse-path@7.1.0': dependencies: @@ -13104,13 +14084,13 @@ snapshots: '@types/pg@8.20.0': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 pg-protocol: 1.13.0 pg-types: 2.2.0 '@types/pg@8.6.1': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 pg-protocol: 1.13.0 pg-types: 2.2.0 @@ -13128,7 +14108,7 @@ snapshots: '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/tough-cookie': 4.0.5 form-data: 2.5.5 @@ -13140,7 +14120,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/testing-library__jest-dom@6.0.0': dependencies: @@ -13148,7 +14128,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 '@types/tinycolor2@1.4.6': {} @@ -13319,8 +14299,8 @@ snapshots: '@vitejs/plugin-react@5.2.0(vite@5.4.21(@types/node@24.12.4)(terser@5.46.1))': dependencies: '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -13328,7 +14308,26 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1))': + '@vitest/coverage-v8@1.6.1(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + test-exclude: 6.0.0 + vitest: 1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -13343,10 +14342,16 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + vitest: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) transitivePeerDependencies: - supports-color + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -13367,22 +14372,45 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -13635,6 +14663,14 @@ snapshots: arrify@2.0.1: {} + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + assertion-error@1.1.0: {} + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -13712,6 +14748,55 @@ snapshots: before-after-hook@4.0.0: {} + better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(pg@8.20.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1)): + dependencies: + '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9)) + '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.5(zod@4.3.6) + defu: 6.1.7 + jose: 6.2.2 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.3.6 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) + next: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pg: 8.20.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + vitest: 1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.5(zod@3.25.76): + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 3.25.76 + + better-call@1.3.5(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.4.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.3.6 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -13819,6 +14904,16 @@ snapshots: caniuse-lite@1.0.30001793: {} + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -13870,6 +14965,10 @@ snapshots: chardet@2.1.1: {} + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + check-error@2.1.3: {} cheerio-select@2.1.0: @@ -13959,11 +15058,11 @@ snapshots: clsx@2.1.1: {} - cmdk@0.2.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + cmdk@0.2.1(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1): dependencies: - '@radix-ui/react-dialog': 1.0.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.0.0(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) transitivePeerDependencies: - '@types/react' @@ -14022,6 +15121,8 @@ snapshots: concat-map@0.0.1: {} + confbox@0.1.8: {} + confbox@0.2.4: {} consola@3.4.2: {} @@ -14119,6 +15220,11 @@ snapshots: cssfontparser@1.2.1: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} cz-conventional-changelog@3.3.0(@types/node@24.12.4)(typescript@6.0.3): @@ -14143,10 +15249,15 @@ snapshots: data-uri-to-buffer@7.0.0: {} - data-urls@7.0.0: + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-urls@7.0.0(@noble/hashes@2.2.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -14180,6 +15291,10 @@ snapshots: dedent@0.7.0: {} + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + deep-eql@5.0.2: {} deep-extend@0.6.0: {} @@ -14255,6 +15370,8 @@ snapshots: didyoumean@1.2.2: {} + diff-sequences@29.6.3: {} + diff@4.0.4: {} dir-glob@3.0.1: @@ -14334,16 +15451,17 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.20.0 + kysely: 0.28.17 pg: 8.20.0 postgres: 3.4.9 - drizzle-zod@0.7.1(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9))(zod@3.25.76): + drizzle-zod@0.7.1(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9))(zod@3.25.76): dependencies: - drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.20.0)(postgres@3.4.9) + drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.28.17)(pg@8.20.0)(postgres@3.4.9) zod: 3.25.76 dunder-proto@1.0.1: @@ -14672,11 +15790,11 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-turbo@2.9.14(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.14): + eslint-config-turbo@2.9.15(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.15): dependencies: eslint: 10.4.0(jiti@2.7.0) - eslint-plugin-turbo: 2.9.14(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.14) - turbo: 2.9.14 + eslint-plugin-turbo: 2.9.15(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.15) + turbo: 2.9.15 eslint-import-context@0.1.9(unrs-resolver@1.11.1): dependencies: @@ -14748,11 +15866,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.9.14(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.14): + eslint-plugin-turbo@2.9.15(eslint@10.4.0(jiti@2.7.0))(turbo@2.9.15): dependencies: dotenv: 16.0.3 eslint: 10.4.0(jiti@2.7.0) - turbo: 2.9.14 + turbo: 2.9.15 eslint-scope@5.1.1: dependencies: @@ -14853,6 +15971,18 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -15078,6 +16208,10 @@ snapshots: dependencies: next: 15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + geist@1.7.0(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + dependencies: + next: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -15086,6 +16220,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-func-name@2.0.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -15108,6 +16244,8 @@ snapshots: get-stream@6.0.1: {} + get-stream@8.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -15334,9 +16472,13 @@ snapshots: dependencies: parse-passwd: 1.0.0 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@4.0.0: dependencies: - '@exodus/bytes': 1.15.1 + whatwg-encoding: 3.1.1 + + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) transitivePeerDependencies: - '@noble/hashes' @@ -15396,6 +16538,8 @@ snapshots: human-signals@2.1.0: {} + human-signals@5.0.0: {} + hyphenate-style-name@1.1.0: {} iconv-lite@0.4.24: @@ -15658,6 +16802,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@3.0.0: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -15754,7 +16900,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.12.4 + '@types/node': 20.19.41 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -15778,19 +16924,47 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.1.1: + jsdom@24.1.3: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.20.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsdom@29.1.1(@noble/hashes@2.2.0): dependencies: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.2.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.0 + lru-cache: 11.5.1 parse5: 8.0.1 saxes: 6.0.0 symbol-tree: 3.2.4 @@ -15799,7 +16973,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -15860,6 +17034,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.28.17: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -15928,6 +17104,11 @@ snapshots: emojis-list: 3.0.0 json5: 1.0.2 + local-pkg@0.5.1: + dependencies: + mlly: 1.8.2 + pkg-types: 1.3.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -15985,6 +17166,10 @@ snapshots: lottie-web@5.13.0: {} + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + loupe@3.2.1: {} lower-case-first@1.0.2: @@ -15995,7 +17180,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.5.0: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: dependencies: @@ -16021,8 +17206,8 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@4.0.0: @@ -16068,6 +17253,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} min-indent@1.0.1: {} @@ -16100,6 +17287,13 @@ snapshots: dependencies: minimist: 1.2.8 + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + module-details-from-path@1.0.4: {} moo-color@1.0.3: @@ -16126,6 +17320,8 @@ snapshots: nanoid@5.1.7: {} + nanostores@1.3.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -16141,22 +17337,33 @@ snapshots: next-auth@5.0.0-beta.25(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.10.1)(react@18.3.1): dependencies: '@auth/core': 0.37.2(nodemailer@6.10.1) - next: 15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 optionalDependencies: nodemailer: 6.10.1 - next-plausible@3.12.5(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-plausible@3.12.5(next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: next: 15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + next-plausible@3.12.5(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + next: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + next-themes@0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + next-themes@0.4.6(react-dom@19.2.6(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + next@15.5.14(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.5.14 @@ -16182,6 +17389,81 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 15.5.14 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001786 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.14 + '@next/swc-darwin-x64': 15.5.14 + '@next/swc-linux-arm64-gnu': 15.5.14 + '@next/swc-linux-arm64-musl': 15.5.14 + '@next/swc-linux-x64-gnu': 15.5.14 + '@next/swc-linux-x64-musl': 15.5.14 + '@next/swc-win32-arm64-msvc': 15.5.14 + '@next/swc-win32-x64-msvc': 15.5.14 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.59.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 15.5.14 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001786 + postcss: 8.4.31 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.14 + '@next/swc-darwin-x64': 15.5.14 + '@next/swc-linux-arm64-gnu': 15.5.14 + '@next/swc-linux-arm64-musl': 15.5.14 + '@next/swc-linux-x64-gnu': 15.5.14 + '@next/swc-linux-x64-musl': 15.5.14 + '@next/swc-win32-arm64-msvc': 15.5.14 + '@next/swc-win32-x64-msvc': 15.5.14 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.60.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@next/env': 15.5.14 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001786 + postcss: 8.4.31 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.6(react@19.2.6) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.14 + '@next/swc-darwin-x64': 15.5.14 + '@next/swc-linux-arm64-gnu': 15.5.14 + '@next/swc-linux-arm64-musl': 15.5.14 + '@next/swc-linux-x64-gnu': 15.5.14 + '@next/swc-linux-x64-musl': 15.5.14 + '@next/swc-win32-arm64-msvc': 15.5.14 + '@next/swc-win32-x64-msvc': 15.5.14 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.60.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + no-case@2.3.2: dependencies: lower-case: 1.1.4 @@ -16231,10 +17513,16 @@ snapshots: dependencies: path-key: 3.1.1 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + nwsapi@2.2.23: {} + nypm@0.6.6: dependencies: citty: 0.2.2 @@ -16293,6 +17581,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -16368,6 +17660,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -16475,6 +17771,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -16484,8 +17782,12 @@ snapshots: path-type@4.0.0: {} + pathe@1.1.2: {} + pathe@2.0.3: {} + pathval@1.1.1: {} + pathval@2.0.1: {} perfect-debounce@2.1.0: {} @@ -16535,14 +17837,28 @@ snapshots: pirates@4.0.7: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + pkg-types@2.3.1: dependencies: confbox: 0.2.4 exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.1: {} + playwright-core@1.60.0: {} + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + playwright@1.60.0: dependencies: playwright-core: 1.60.0 @@ -16640,6 +17956,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-format@3.8.0: {} progress@2.0.3: {} @@ -16682,10 +18004,22 @@ snapshots: proxy-from-env@2.1.0: {} + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qr.js@0.0.0: {} + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quickjs-wasi@0.0.1: {} @@ -16719,6 +18053,16 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@19.2.6(react@18.3.1): + dependencies: + react: 18.3.1 + scheduler: 0.27.0 + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + react-hook-form@7.72.1(react@18.3.1): dependencies: react: 18.3.1 @@ -16727,6 +18071,8 @@ snapshots: react-is@17.0.2: {} + react-is@18.3.1: {} + react-lottie@1.2.10(react@18.3.1): dependencies: babel-runtime: 6.26.0 @@ -16813,6 +18159,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + react@19.2.6: {} + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -16834,6 +18182,8 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -16905,6 +18255,8 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + resolve-dir@1.0.1: dependencies: expand-tilde: 2.0.2 @@ -16997,6 +18349,10 @@ snapshots: rou3@0.7.12: {} + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} run-async@2.4.1: {} @@ -17046,6 +18402,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + scheduler@0.27.0: {} + schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 @@ -17064,6 +18422,8 @@ snapshots: server-only@0.0.1: {} + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -17190,10 +18550,10 @@ snapshots: ip-address: 10.1.0 smart-buffer: 4.2.0 - sonner@1.7.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + sonner@1.7.4(react-dom@19.2.6(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react-dom: 19.2.6(react@18.3.1) source-map-js@1.2.1: {} @@ -17318,6 +18678,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@3.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -17326,6 +18688,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -17341,6 +18707,11 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + styled-jsx@5.1.6(react@19.2.6): + dependencies: + client-only: 0.0.1 + react: 19.2.6 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -17444,9 +18815,15 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.5 + test-exclude@7.0.2: dependencies: - '@istanbuljs/schema': 0.1.6 + '@istanbuljs/schema': 0.1.3 glob: 10.5.0 minimatch: 10.2.5 @@ -17478,10 +18855,14 @@ snapshots: '@types/tinycolor2': 1.4.6 tinycolor2: 1.6.0 + tinypool@0.8.4: {} + tinypool@1.1.1: {} tinyrainbow@2.0.0: {} + tinyspy@2.2.1: {} + tinyspy@4.0.4: {} title-case@2.1.1: @@ -17489,8 +18870,14 @@ snapshots: no-case: 2.3.2 upper-case: 1.1.3 + tldts-core@6.1.86: {} + tldts-core@7.4.0: {} + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + tldts@7.4.0: dependencies: tldts-core: 7.4.0 @@ -17503,12 +18890,23 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tough-cookie@6.0.1: dependencies: tldts: 7.4.0 tr46@0.0.3: {} + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tr46@6.0.0: dependencies: punycode: 2.3.1 @@ -17558,19 +18956,25 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.9.14: + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + turbo@2.9.15: optionalDependencies: - '@turbo/darwin-64': 2.9.14 - '@turbo/darwin-arm64': 2.9.14 - '@turbo/linux-64': 2.9.14 - '@turbo/linux-arm64': 2.9.14 - '@turbo/windows-64': 2.9.14 - '@turbo/windows-arm64': 2.9.14 + '@turbo/darwin-64': 2.9.15 + '@turbo/darwin-arm64': 2.9.15 + '@turbo/linux-64': 2.9.15 + '@turbo/linux-arm64': 2.9.15 + '@turbo/windows-64': 2.9.15 + '@turbo/windows-arm64': 2.9.15 type-check@0.4.0: dependencies: prelude-ls: 1.2.1 + type-detect@4.1.0: {} + type-fest@0.21.3: {} type-fest@0.7.1: {} @@ -17627,6 +19031,8 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.3: {} + uglify-js@3.19.3: optional: true @@ -17637,6 +19043,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@7.24.5: {} @@ -17647,6 +19055,8 @@ snapshots: universal-user-agent@7.0.3: {} + universalify@0.2.0: {} + universalify@2.0.1: {} unplugin@1.0.1: @@ -17703,6 +19113,11 @@ snapshots: url-join@5.0.0: {} + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@18.3.28)(react@18.3.1): dependencies: react: 18.3.1 @@ -17743,6 +19158,33 @@ snapshots: - '@types/react' - '@types/react-dom' + vaul@0.9.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.6(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 19.2.6(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + vite-node@1.6.1(@types/node@20.19.41)(terser@5.46.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.41)(terser@5.46.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + vite-node@3.2.4(@types/node@24.12.4)(terser@5.46.1): dependencies: cac: 6.7.14 @@ -17771,23 +19213,68 @@ snapshots: - supports-color - typescript + vite@5.4.21(@types/node@20.19.41)(terser@5.46.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.1 + optionalDependencies: + '@types/node': 20.19.41 + fsevents: 2.3.3 + terser: 5.46.1 + vite@5.4.21(@types/node@24.12.4)(terser@5.46.1): dependencies: esbuild: 0.21.5 - postcss: 8.5.15 + postcss: 8.5.8 rollup: 4.60.1 optionalDependencies: '@types/node': 24.12.4 fsevents: 2.3.3 terser: 5.46.1 - vitest-canvas-mock@1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1)): + vitest-canvas-mock@1.1.4(vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1)): dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1) + vitest: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1) + + vitest@1.6.1(@types/node@20.19.41)(jsdom@24.1.3)(terser@5.46.1): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.41)(terser@5.46.1) + vite-node: 1.6.1(@types/node@20.19.41)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.41 + jsdom: 24.1.3 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser - vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.46.1): + vitest@3.2.4(@types/node@24.12.4)(jsdom@29.1.1(@noble/hashes@2.2.0))(terser@5.46.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -17814,7 +19301,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.4 - jsdom: 29.1.1 + jsdom: 29.1.1(@noble/hashes@2.2.0) transitivePeerDependencies: - less - lightningcss @@ -17843,6 +19330,8 @@ snapshots: webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webidl-conversions@8.0.1: {} webpack-sources@3.3.4: {} @@ -17889,9 +19378,14 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@14.2.0: dependencies: - '@exodus/bytes': 1.15.1 + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -17992,6 +19486,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 @@ -18024,13 +19520,15 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.2.2: {} + yoctocolors-cjs@2.1.3: {} yoctocolors@2.1.2: {} zod@3.25.76: {} - zod@4.4.3: {} + zod@4.3.6: {} zustand@4.5.7(@types/react@18.3.28)(react@18.3.1): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6d42feb..4b6fa523 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,10 @@ packages: - apps/* + # The redirect app is a grouped multi-tier package; list its real packages + # explicitly so non-package dirs (infra/terraform) aren't treated as packages. + - apps/redirect/web + - apps/redirect/server + - apps/redirect/shared - packages/* - tooling/* From 48a1327fe5437c398a87f7044da2ad9763f3dd1f Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Fri, 29 May 2026 08:54:38 -0500 Subject: [PATCH 2/4] fix(redirect): declare web env vars in turbo.json + lazy DB client - turbo.json: add the redirect-web env vars (BETTER_AUTH_*, GOOGLE_CLIENT_*, PASSKEY_*, REDIRECT_*, CONFIG_*, EXPORT_LOCAL_PATH) to globalEnv so the turbo/no-undeclared-env-vars lint rule passes during `next build`. - db/index.ts: instantiate the postgres client lazily via a memoized Proxy instead of at module load. `next build` evaluates the route module graph during page-data collection where DATABASE_URL is intentionally absent; connecting eagerly threw "DATABASE_URL is not set" and failed CI build. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/redirect/web/src/db/index.ts | 37 +++++++++++++++++++++++-------- turbo.json | 14 +++++++++++- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/apps/redirect/web/src/db/index.ts b/apps/redirect/web/src/db/index.ts index cfa1e80b..3a0b612e 100644 --- a/apps/redirect/web/src/db/index.ts +++ b/apps/redirect/web/src/db/index.ts @@ -2,23 +2,20 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "./schema"; -const connectionString = process.env.DATABASE_URL; -if (!connectionString) { - throw new Error("DATABASE_URL is not set"); -} +type Database = ReturnType>; // Cloud SQL via the Cloud Run connector uses a unix-socket form // postgres://user:pass@/db?host=/cloudsql/PROJECT:REGION:INSTANCE // whose empty host is rejected by the WHATWG URL parser, so build the client // from explicit options in that case. Standard TCP URLs (local Postgres) pass // straight through. -function makeClient() { +function makeClient(connectionString: string) { // Match the empty-host socket form. Capture the `host` query param up to the // next `&` (not `$`), so additional params like `&sslmode=disable` in any // order don't get swallowed into the socket path. const socket = /^postgres(?:ql)?:\/\/([^:@/]+):([^@/]+)@\/([^?]+)\?(.+)$/.exec( - connectionString!, + connectionString, ); if (socket) { const [, user, pass, database, query] = socket; @@ -33,10 +30,32 @@ function makeClient() { }); } } - return postgres(connectionString!, { prepare: false }); + return postgres(connectionString, { prepare: false }); +} + +// Lazily instantiate the client on first use rather than at module load. +// Next.js evaluates the route module graph during `next build` (page-data +// collection) where DATABASE_URL is intentionally absent; connecting eagerly +// would throw and fail the build. Memoize so we open exactly one pool. +let instance: Database | undefined; + +function getDb(): Database { + if (!instance) { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error("DATABASE_URL is not set"); + } + instance = drizzle(makeClient(connectionString), { schema }); + } + return instance; } -const client = makeClient(); +// Preserve the `db.select(...)` call-site API while deferring connection until +// the first property access (request time), not import time. +export const db: Database = new Proxy({} as Database, { + get(_target, prop, receiver) { + return Reflect.get(getDb(), prop, receiver) as unknown; + }, +}); -export const db = drizzle(client, { schema }); export { schema }; diff --git a/turbo.json b/turbo.json index ede36a23..a7641e5d 100644 --- a/turbo.json +++ b/turbo.json @@ -41,7 +41,19 @@ "NEXT_PUBLIC_CHANNEL", "GCS_BUCKET", "GCS_CREDENTIALS", - "GCS_EMULATOR_HOST" + "GCS_EMULATOR_HOST", + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "PASSKEY_RP_ID", + "PASSKEY_RP_NAME", + "PASSKEY_ORIGIN", + "REDIRECT_STATIC_IP", + "REDIRECT_CANONICAL_HOST", + "CONFIG_BUCKET", + "CONFIG_OBJECT", + "EXPORT_LOCAL_PATH" ], "tasks": { "topo": { From 53e56a21e4acd798377b9640974a0e6a72163b28 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Fri, 29 May 2026 09:04:31 -0500 Subject: [PATCH 3/4] fix(redirect): address CodeRabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server (Go): - api/domains: roll back the DB claim and return 503 when GCS publish fails, so a failed export can't leave a stale-but-claimed domain (Critical) - mappings: strengthen hostname validation (reject a..com, .x.com, foo/bar.com) - dns: render an actionable target when StaticIP is unset in the subdomain fallback note instead of "A record to " - redirect: normalize AdminHost before comparing to the request host - proxy: reject non-absolute upstream URLs at construction - live: return a defensive copy from Config() so callers can't mutate the live snapshot / race readers - certstore/gcs: propagate unexpected lock-inspection errors (treat only ErrObjectNotExist as benign) - cmd/f3redirect: reject extra positional args in `dns` - tests: fail fast on NewAdminProxy error; guard len(recs) before indexing Web (Next.js): - domains: reject bare public suffixes (require a registrable eTLD+1) - db/schema: make passkey.credentialID a UNIQUE index - AuthForm: announce errors to assistive tech (role=alert/aria-live) - Dashboard: surface delete failures instead of failing silently - globals.css: drop deprecated word-break: break-word - tests: waitFor async navigation; stubGlobal/unstubAllGlobals fetch - scripts/reset-test-db: drop+recreate the dedicated DB (true reset) Infra/CI: - deploy-redirect-{server,web}: guard deploy on github.ref == refs/heads/dev so workflow_dispatch can't ship unmerged code to prod - terraform-drift: skip the PR comment on fork PRs (read-only token → 403) - terraform: drop the live default for `project` (+validation); pass it via TF_VAR in the drift workflow; clear the admin_domain live default - startup-script: use /usr/bin/env bash per repo convention - README: document the new self-serve admin web app Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-redirect-server.yml | 3 +++ .github/workflows/deploy-redirect-web.yml | 4 ++++ .../workflows/redirect-terraform-drift.yml | 12 ++++++---- apps/redirect/README.md | 4 +++- .../infra/terraform/startup-script.sh.tftpl | 2 +- apps/redirect/infra/terraform/variables.tf | 12 ++++++---- apps/redirect/server/cmd/f3redirect/main.go | 3 +++ .../redirect/server/internal/certstore/gcs.go | 22 +++++++++++------- apps/redirect/server/internal/mappings/dns.go | 8 ++++++- .../server/internal/mappings/dns_test.go | 6 +++++ .../server/internal/mappings/mappings.go | 17 +++++++++++++- .../internal/redirect/integration_test.go | 5 +++- .../redirect/server/internal/redirect/live.go | 15 ++++++++++-- .../server/internal/redirect/proxy.go | 7 ++++++ .../server/internal/redirect/redirect.go | 4 +++- apps/redirect/web/scripts/reset-test-db.mjs | 23 +++++++++++++------ .../redirect/web/src/app/api/domains/route.ts | 13 ++++++++++- apps/redirect/web/src/app/globals.css | 1 - .../web/src/components/AuthForm.test.tsx | 18 +++++++++------ apps/redirect/web/src/components/AuthForm.tsx | 6 ++++- .../web/src/components/Dashboard.test.tsx | 10 ++++++-- .../redirect/web/src/components/Dashboard.tsx | 11 ++++++++- apps/redirect/web/src/db/schema.ts | 5 +++- apps/redirect/web/src/lib/domains.ts | 7 ++++++ 24 files changed, 173 insertions(+), 45 deletions(-) diff --git a/.github/workflows/deploy-redirect-server.yml b/.github/workflows/deploy-redirect-server.yml index 994983f2..f047d543 100644 --- a/.github/workflows/deploy-redirect-server.yml +++ b/.github/workflows/deploy-redirect-server.yml @@ -44,6 +44,9 @@ env: jobs: deploy: runs-on: ubuntu-latest + # Production deploys come only from dev. workflow_dispatch can target any + # ref, so guard the job to block a manual deploy of unmerged code to prod. + if: github.ref == 'refs/heads/dev' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/deploy-redirect-web.yml b/.github/workflows/deploy-redirect-web.yml index b6272f9f..908716e0 100644 --- a/.github/workflows/deploy-redirect-web.yml +++ b/.github/workflows/deploy-redirect-web.yml @@ -48,6 +48,10 @@ env: jobs: build: runs-on: ubuntu-latest + # Production deploys come only from dev. workflow_dispatch can target any + # ref, so guard the entry job (deploy needs: build) to block a manual + # deploy of unmerged code to prod. + if: github.ref == 'refs/heads/dev' outputs: image: ${{ steps.meta.outputs.image }} steps: diff --git a/.github/workflows/redirect-terraform-drift.yml b/.github/workflows/redirect-terraform-drift.yml index 7706a139..ee76d1f8 100644 --- a/.github/workflows/redirect-terraform-drift.yml +++ b/.github/workflows/redirect-terraform-drift.yml @@ -41,9 +41,11 @@ jobs: working-directory: apps/redirect/infra/terraform env: # Non-secret values that must match the live VM startup-script metadata so - # `plan` reports no spurious drift. (The image_tag, project, region, zone, - # machine_type, config_object, and cert_prefix variable defaults already - # match live.) + # `plan` reports no spurious drift. project has no variable default + # (removed to prevent unparameterized applies from targeting a live + # environment), so it is supplied explicitly here. image_tag, region, + # zone, machine_type, config_object, and cert_prefix defaults match live. + TF_VAR_project: "f3-redirects" TF_VAR_acme_email: "patrick@pstaylor.net" TF_VAR_admin_host: "admin.f3regions.com" TF_VAR_admin_upstream: "https://f3redirect-web-355149658273.us-central1.run.app" @@ -87,7 +89,9 @@ jobs: fi - name: Comment drift result on PR - if: github.event_name == 'pull_request' && always() + # On fork PRs GitHub forces GITHUB_TOKEN to read-only, so createComment + # would 403 and fail the job. Only comment for same-repo PRs. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && always() uses: actions/github-script@v7 with: script: | diff --git a/apps/redirect/README.md b/apps/redirect/README.md index 652b5c89..9282b94b 100644 --- a/apps/redirect/README.md +++ b/apps/redirect/README.md @@ -31,7 +31,9 @@ GCS — no database anywhere. same file is the registry the TLS gate consults. The server hot-reloads it on an interval, so new mappings take effect without a redeploy. - **Admin CLI (Go, `cmd/f3redirect`).** Add/list/remove mappings and print the DNS - records a tenant must create. A TypeScript management UI is deferred. + records a tenant must create. +- **Self-serve admin web (`apps/redirect/web`).** A Next.js + Better Auth management + UI covering the same add/list/remove flow and DNS-record guidance. ```text cmd/redirectd HTTPS redirect server (on-demand TLS) diff --git a/apps/redirect/infra/terraform/startup-script.sh.tftpl b/apps/redirect/infra/terraform/startup-script.sh.tftpl index b1dcbcd5..f04f3e93 100644 --- a/apps/redirect/infra/terraform/startup-script.sh.tftpl +++ b/apps/redirect/infra/terraform/startup-script.sh.tftpl @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Container-Optimized OS startup script: run the redirect tier on the host # network (so it owns ports 80/443) and keep it running. Retries the image # pull so the VM can boot before the first image is pushed by CI/CD. diff --git a/apps/redirect/infra/terraform/variables.tf b/apps/redirect/infra/terraform/variables.tf index 89e4c9f0..fb5ea3e2 100644 --- a/apps/redirect/infra/terraform/variables.tf +++ b/apps/redirect/infra/terraform/variables.tf @@ -1,7 +1,11 @@ variable "project" { type = string - description = "GCP project ID." - default = "f3-redirects" + description = "GCP project ID. Required — supply per environment via tfvars/CLI so an unparameterized apply can't target a live project." + + validation { + condition = length(trimspace(var.project)) > 0 + error_message = "project must be set explicitly (e.g. via -var or a tfvars file)." + } } variable "region" { @@ -52,7 +56,7 @@ variable "redirect_status" { variable "image_tag" { type = string - description = "Container image tag to run." + description = "Container image tag to run. v1 intentionally uses the mutable :latest tag — the VM pulls it on boot/restart and deploys push :latest (see terraform.tfvars.example). Immutable tags/digests are deferred to the org-owned-project migration." default = "latest" } @@ -88,7 +92,7 @@ variable "admin_domain" { ghs.googlehosted.com). Empty disables the mapping (falls back to the VM reverse-proxy via admin_host). EOT - default = "admin.f3regions.com" + default = "" } variable "admin_service_name" { diff --git a/apps/redirect/server/cmd/f3redirect/main.go b/apps/redirect/server/cmd/f3redirect/main.go index 42561405..887b998c 100644 --- a/apps/redirect/server/cmd/f3redirect/main.go +++ b/apps/redirect/server/cmd/f3redirect/main.go @@ -243,6 +243,9 @@ func cmdDNS(args []string) error { } only := "" + if fs.NArg() > 1 { + return fmt.Errorf("usage: f3redirect dns [host]") + } if fs.NArg() == 1 { only = mappings.NormalizeHost(fs.Arg(0)) } diff --git a/apps/redirect/server/internal/certstore/gcs.go b/apps/redirect/server/internal/certstore/gcs.go index 7a5f1785..cfa36331 100644 --- a/apps/redirect/server/internal/certstore/gcs.go +++ b/apps/redirect/server/internal/certstore/gcs.go @@ -201,15 +201,21 @@ func (g *GCS) Lock(ctx context.Context, name string) error { if !isPreconditionFailed(werr) { return werr } - // Someone holds it. Steal if stale. - if attrs, err := obj.Attrs(ctx); err == nil { - if time.Since(attrs.Created) > lockTTL { - _ = g.client.Bucket(g.bucket). - Object(obj.ObjectName()). - If(storage.Conditions{GenerationMatch: attrs.Generation}). - Delete(ctx) - continue + // Someone holds it. Steal if stale. Treat a missing object as benign + // (the holder released it between the create attempt and this read), + // but propagate any other inspection error instead of silently + // sleeping/retrying until the context expires. + attrs, err := obj.Attrs(ctx) + if err != nil { + if !errors.Is(err, storage.ErrObjectNotExist) { + return err } + } else if time.Since(attrs.Created) > lockTTL { + _ = g.client.Bucket(g.bucket). + Object(obj.ObjectName()). + If(storage.Conditions{GenerationMatch: attrs.Generation}). + Delete(ctx) + continue } select { case <-ctx.Done(): diff --git a/apps/redirect/server/internal/mappings/dns.go b/apps/redirect/server/internal/mappings/dns.go index 0e23905d..2a369c46 100644 --- a/apps/redirect/server/internal/mappings/dns.go +++ b/apps/redirect/server/internal/mappings/dns.go @@ -74,11 +74,17 @@ func DNSInstructions(m Mapping, opt DNSOptions) []DNSRecord { } apex := ApexOf(host) + // Mirror the apex placeholder behavior: if the static IP isn't configured, + // the fallback note would otherwise read "...A record to ." with no target. + staticIPTarget := opt.StaticIP + if staticIPTarget == "" { + staticIPTarget = "the redirect tier's static IP (not yet configured — contact the administrator)" + } return []DNSRecord{{ Type: "CNAME", Name: host, Value: apex, - Note: fmt.Sprintf("Required: %s is a subdomain; CNAME it to %s (which must carry an A record to %s).", host, apex, opt.StaticIP), + Note: fmt.Sprintf("Required: %s is a subdomain; CNAME it to %s (which must carry an A record to %s).", host, apex, staticIPTarget), Optional: false, }} } diff --git a/apps/redirect/server/internal/mappings/dns_test.go b/apps/redirect/server/internal/mappings/dns_test.go index 3b31a96c..fb142685 100644 --- a/apps/redirect/server/internal/mappings/dns_test.go +++ b/apps/redirect/server/internal/mappings/dns_test.go @@ -7,6 +7,9 @@ func TestDNSInstructionsApex(t *testing.T) { Mapping{Host: "f3muletown.com", Target: "https://regions.f3nation.com/muletown"}, DNSOptions{StaticIP: "203.0.113.10"}, ) + if len(recs) == 0 { + t.Fatalf("expected at least one DNS record, got none") + } // Required A record first. if recs[0].Type != "A" || recs[0].Name != "f3muletown.com" || recs[0].Value != "203.0.113.10" || recs[0].Optional { t.Errorf("apex required A record = %+v", recs[0]) @@ -41,6 +44,9 @@ func TestDNSInstructionsSubdomainFallsBackToApex(t *testing.T) { Mapping{Host: "www.f3marshall.com", Target: "https://x"}, DNSOptions{StaticIP: "203.0.113.10"}, // no canonical host ) + if len(recs) == 0 { + t.Fatalf("expected at least one DNS record, got none") + } if recs[0].Type != "CNAME" || recs[0].Value != "f3marshall.com" { t.Errorf("subdomain fallback = %+v, want CNAME to apex", recs[0]) } diff --git a/apps/redirect/server/internal/mappings/mappings.go b/apps/redirect/server/internal/mappings/mappings.go index 756298e5..a8c24d71 100644 --- a/apps/redirect/server/internal/mappings/mappings.go +++ b/apps/redirect/server/internal/mappings/mappings.go @@ -10,12 +10,17 @@ package mappings import ( "fmt" "net/url" + "regexp" "sort" "strings" "golang.org/x/net/publicsuffix" ) +// hostnameLabel matches a single DNS label: 1–63 chars, alphanumeric, with +// interior hyphens allowed but no leading/trailing hyphen. +var hostnameLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + // Mapping is a single redirect rule: any request whose Host equals Host is // redirected to Target. type Mapping struct { @@ -48,9 +53,19 @@ func (c Config) Validate() error { if host == "" { return fmt.Errorf("mapping %d: empty host", i) } - if !strings.Contains(host, ".") { + // strings.Contains(host, ".") accepts malformed hosts like "a..com", + // ".example.com", or "foo/bar.com". Require at least two non-empty, + // well-formed DNS labels instead so bad values never reach DNS + // generation or certificate gating. + labels := strings.Split(host, ".") + if len(labels) < 2 { return fmt.Errorf("mapping %d (%q): host must be a fully-qualified domain", i, m.Host) } + for _, label := range labels { + if !hostnameLabel.MatchString(label) { + return fmt.Errorf("mapping %d (%q): invalid host", i, m.Host) + } + } if _, dup := seen[host]; dup { return fmt.Errorf("mapping %d: duplicate host %q", i, host) } diff --git a/apps/redirect/server/internal/redirect/integration_test.go b/apps/redirect/server/internal/redirect/integration_test.go index a846434a..94adeb7b 100644 --- a/apps/redirect/server/internal/redirect/integration_test.go +++ b/apps/redirect/server/internal/redirect/integration_test.go @@ -34,7 +34,10 @@ func TestServerIntegration(t *testing.T) { _, _ = io.WriteString(w, "ADMIN-APP host="+r.Header.Get("X-Forwarded-Host")) })) defer upstream.Close() - up, _ := redirect.NewAdminProxy(upstream.URL, "admin.example.com") + up, err := redirect.NewAdminProxy(upstream.URL, "admin.example.com") + if err != nil { + t.Fatal(err) + } h := redirect.NewHandler(live, http.StatusFound) h.AdminHost = "admin.example.com" diff --git a/apps/redirect/server/internal/redirect/live.go b/apps/redirect/server/internal/redirect/live.go index ffaa40e6..4a89fb3f 100644 --- a/apps/redirect/server/internal/redirect/live.go +++ b/apps/redirect/server/internal/redirect/live.go @@ -28,8 +28,19 @@ func NewLive(ctx context.Context, store mappings.Store) (*Live, error) { return l, nil } -// Config returns the current config snapshot. -func (l *Live) Config() mappings.Config { return *l.cfg.Load() } +// Config returns a defensive copy of the current config snapshot. +// mappings.Config holds a slice, so returning *l.cfg.Load() directly would +// expose the live backing array; a caller mutating cfg.Mappings could corrupt +// the snapshot and race with concurrent readers. +func (l *Live) Config() mappings.Config { + cfg := l.cfg.Load() + if cfg == nil { + return mappings.Config{} + } + return mappings.Config{ + Mappings: append([]mappings.Mapping(nil), cfg.Mappings...), + } +} // Resolve looks up the target for host in the current config. func (l *Live) Resolve(host string) (string, bool) { return l.cfg.Load().Resolve(host) } diff --git a/apps/redirect/server/internal/redirect/proxy.go b/apps/redirect/server/internal/redirect/proxy.go index f1d78609..e8fd11c8 100644 --- a/apps/redirect/server/internal/redirect/proxy.go +++ b/apps/redirect/server/internal/redirect/proxy.go @@ -1,6 +1,7 @@ package redirect import ( + "fmt" "net/http" "net/http/httputil" "net/url" @@ -14,6 +15,12 @@ func NewAdminProxy(upstream, adminHost string) (http.Handler, error) { if err != nil { return nil, err } + // url.Parse also accepts relative URLs and values without a scheme/host; + // reject those up front so a bad admin_upstream fails at construction + // rather than becoming a runtime proxy outage. + if u.Scheme == "" || u.Host == "" { + return nil, fmt.Errorf("upstream must be an absolute URL: %q", upstream) + } proxy := httputil.NewSingleHostReverseProxy(u) base := proxy.Director proxy.Director = func(req *http.Request) { diff --git a/apps/redirect/server/internal/redirect/redirect.go b/apps/redirect/server/internal/redirect/redirect.go index 71358df2..24a2cca6 100644 --- a/apps/redirect/server/internal/redirect/redirect.go +++ b/apps/redirect/server/internal/redirect/redirect.go @@ -46,7 +46,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { host := mappings.NormalizeHost(r.Host) // Admin host is reverse-proxied (e.g. to the Cloud Run web app), not redirected. - if h.AdminHost != "" && h.AdminProxy != nil && host == h.AdminHost { + // Normalize the configured AdminHost the same way r.Host is normalized so a + // value with uppercase letters or a trailing dot still matches. + if h.AdminHost != "" && h.AdminProxy != nil && host == mappings.NormalizeHost(h.AdminHost) { h.AdminProxy.ServeHTTP(w, r) return } diff --git a/apps/redirect/web/scripts/reset-test-db.mjs b/apps/redirect/web/scripts/reset-test-db.mjs index bd492d1b..b798330b 100644 --- a/apps/redirect/web/scripts/reset-test-db.mjs +++ b/apps/redirect/web/scripts/reset-test-db.mjs @@ -21,18 +21,27 @@ function withPath(urlStr, dbName) { return u.toString(); } -// 1) Create the dedicated database if it doesn't exist (connect via the -// server's default `postgres` database; CREATE DATABASE can't run in a tx). +// 1) Reset the dedicated database (connect via the server's default `postgres` +// database; CREATE/DROP DATABASE can't run in a tx). This is the advertised +// reset entrypoint, so drop-and-recreate when it exists rather than leaving +// rows behind — `drizzle-kit push --force` only reconciles schema, not data, +// so a create-if-missing would make the integration suite stateful across +// runs. const admin = postgres(withPath(base, "postgres"), { max: 1 }); try { const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${DEDICATED_DB}`; - if (exists.length === 0) { - await admin.unsafe(`CREATE DATABASE ${DEDICATED_DB}`); - console.log(`created database ${DEDICATED_DB}`); - } else { - console.log(`database ${DEDICATED_DB} already exists`); + if (exists.length !== 0) { + // Terminate other connections so DROP DATABASE doesn't block. + await admin.unsafe(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '${DEDICATED_DB}' AND pid <> pg_backend_pid() + `); + await admin.unsafe(`DROP DATABASE ${DEDICATED_DB}`); } + await admin.unsafe(`CREATE DATABASE ${DEDICATED_DB}`); + console.log(`reset database ${DEDICATED_DB}`); } finally { await admin.end(); } diff --git a/apps/redirect/web/src/app/api/domains/route.ts b/apps/redirect/web/src/app/api/domains/route.ts index 907724ac..2f9c5707 100644 --- a/apps/redirect/web/src/app/api/domains/route.ts +++ b/apps/redirect/web/src/app/api/domains/route.ts @@ -88,7 +88,18 @@ export async function POST(req: Request) { ); } - await exportConfigToGCS(); + // Publish the live redirect config to GCS. If this fails the claim must NOT + // persist — otherwise retries hit 409 while the published config stays stale. + // Roll back the row and surface a 503 so the caller can retry cleanly. + try { + await exportConfigToGCS(); + } catch { + await db.delete(domain).where(eq(domain.id, inserted.id)); + return NextResponse.json( + { error: "failed to publish redirect config" }, + { status: 503 }, + ); + } return NextResponse.json( { diff --git a/apps/redirect/web/src/app/globals.css b/apps/redirect/web/src/app/globals.css index 6dd7f39c..c7f7692b 100644 --- a/apps/redirect/web/src/app/globals.css +++ b/apps/redirect/web/src/app/globals.css @@ -152,7 +152,6 @@ label { } .domain-head .dest { overflow-wrap: anywhere; - word-break: break-word; } .row > button { flex-shrink: 0; diff --git a/apps/redirect/web/src/components/AuthForm.test.tsx b/apps/redirect/web/src/components/AuthForm.test.tsx index de950406..a628e542 100644 --- a/apps/redirect/web/src/components/AuthForm.test.tsx +++ b/apps/redirect/web/src/components/AuthForm.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -33,11 +33,13 @@ describe("AuthForm", () => { await userEvent.type(screen.getByLabelText("Password"), "pw123456"); await userEvent.click(screen.getByRole("button", { name: "Sign in" })); - expect(h.signInEmail).toHaveBeenCalledWith({ - email: "a@b.com", - password: "pw123456", + await waitFor(() => { + expect(h.signInEmail).toHaveBeenCalledWith({ + email: "a@b.com", + password: "pw123456", + }); + expect(h.push).toHaveBeenCalledWith("/dashboard"); }); - expect(h.push).toHaveBeenCalledWith("/dashboard"); }); it("surfaces the server error and does NOT navigate on failure", async () => { @@ -76,7 +78,9 @@ describe("AuthForm", () => { screen.getByRole("button", { name: "Sign in with a passkey" }), ); - expect(h.signInPasskey).toHaveBeenCalled(); - expect(h.push).toHaveBeenCalledWith("/dashboard"); + await waitFor(() => { + expect(h.signInPasskey).toHaveBeenCalled(); + expect(h.push).toHaveBeenCalledWith("/dashboard"); + }); }); }); diff --git a/apps/redirect/web/src/components/AuthForm.tsx b/apps/redirect/web/src/components/AuthForm.tsx index 99099796..25683437 100644 --- a/apps/redirect/web/src/components/AuthForm.tsx +++ b/apps/redirect/web/src/components/AuthForm.tsx @@ -83,7 +83,11 @@ export function AuthForm() { : "new here? create an account"} - {error &&

{error}

} + {error && ( +

+ {error} +

+ )} {mode === "signin" && (
({ push: vi.fn(), @@ -40,7 +40,13 @@ function mockFetchOnce(status: number, body: unknown) { beforeEach(() => { h.push.mockReset(); h.addPasskey.mockReset(); - global.fetch = vi.fn(); + // Stub rather than assign so the mocked fetch is torn down per suite and + // doesn't leak into later Vitest files/workers (avoids order-dependence). + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); }); describe("Dashboard", () => { diff --git a/apps/redirect/web/src/components/Dashboard.tsx b/apps/redirect/web/src/components/Dashboard.tsx index 2df1bc47..f8361632 100644 --- a/apps/redirect/web/src/components/Dashboard.tsx +++ b/apps/redirect/web/src/components/Dashboard.tsx @@ -159,11 +159,19 @@ function DomainCard({ async function remove() { setBusy(true); + setError(null); try { const res = await fetch(`/api/domains/${domain.id}`, { method: "DELETE", }); - if (res.ok) onRemove(domain.id); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as ApiResponse; + setError(data.error ?? "remove failed"); + return; + } + onRemove(domain.id); + } catch (e) { + setError(e instanceof Error ? e.message : "remove failed"); } finally { setBusy(false); } @@ -218,6 +226,7 @@ function DomainCard({
)} + {!editing && error &&

{error}

} ); } diff --git a/apps/redirect/web/src/db/schema.ts b/apps/redirect/web/src/db/schema.ts index f73af34c..6637691e 100644 --- a/apps/redirect/web/src/db/schema.ts +++ b/apps/redirect/web/src/db/schema.ts @@ -104,6 +104,9 @@ export const passkey = pgTable( }, (table) => [ index("passkey_userId_idx").on(table.userId), - index("passkey_credentialID_idx").on(table.credentialID), + // WebAuthn credential IDs must resolve to exactly one registered + // credential; enforce uniqueness at the DB level rather than relying on + // the authenticator's probabilistic uniqueness. + uniqueIndex("passkey_credentialID_unique").on(table.credentialID), ], ); diff --git a/apps/redirect/web/src/lib/domains.ts b/apps/redirect/web/src/lib/domains.ts index 260f94cb..2ef4113a 100644 --- a/apps/redirect/web/src/lib/domains.ts +++ b/apps/redirect/web/src/lib/domains.ts @@ -55,6 +55,13 @@ export const registerSchema = z.object({ .refine( (h) => HOSTNAME_RE.test(h), "must be a valid fully-qualified domain (e.g. example.com)", + ) + // HOSTNAME_RE accepts bare public suffixes like "co.uk", which are not + // registrable and would later yield impossible DNS instructions. Require a + // registrable (eTLD+1) domain. + .refine( + (h) => getDomain(h) !== null, + "must be a registrable domain, not a public suffix", ), destination: destinationField, }); From 4fa7f625ce1427632a89f8d3287dd021393b0af2 Mon Sep 17 00:00:00 2001 From: Patrick Taylor <1963845+pstaylor-patrick@users.noreply.github.com> Date: Fri, 29 May 2026 09:09:24 -0500 Subject: [PATCH 4/4] fix(redirect): surface PUT publish failures like POST PUT /api/domains/:id now returns 503 if exportConfigToGCS() throws after the destination update, mirroring the POST rollback fix. Without this the DB shows the new destination while the redirect tier keeps serving the old one with no signal to the caller; a retry of the same PUT re-exports cleanly. DELETE is intentionally left as-is: a failed export there is self-healing on the next successful write, and a post-delete 503 would only yield a confusing 404 on retry (the row is already gone). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/redirect/web/src/app/api/domains/[id]/route.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/redirect/web/src/app/api/domains/[id]/route.ts b/apps/redirect/web/src/app/api/domains/[id]/route.ts index 2ea1a11e..d05e0b9f 100644 --- a/apps/redirect/web/src/app/api/domains/[id]/route.ts +++ b/apps/redirect/web/src/app/api/domains/[id]/route.ts @@ -39,7 +39,17 @@ export async function PUT( if (!updated) return NextResponse.json({ error: "not found" }, { status: 404 }); - await exportConfigToGCS(); + // Surface a publish failure so the caller knows the live config is stale and + // can retry (the same PUT re-exports). Without this the DB shows the new + // destination while the redirect tier keeps serving the old one silently. + try { + await exportConfigToGCS(); + } catch { + return NextResponse.json( + { error: "destination updated but failed to publish redirect config; retry" }, + { status: 503 }, + ); + } return NextResponse.json({ domain: {