diff --git a/.agents/types/agent-definition.ts b/.agents/types/agent-definition.ts index d89843404e..74fc1be81f 100644 --- a/.agents/types/agent-definition.ts +++ b/.agents/types/agent-definition.ts @@ -433,6 +433,7 @@ export type ModelName = | 'z-ai/glm-4.7-flash:nitro' | 'minimax/minimax-m2.5' | 'minimax/minimax-m2.7' + | 'minimax/minimax-m3' | (string & {}) import type { ToolName, GetToolParams } from './tools' diff --git a/.bin/bun b/.bin/bun index 253ecd840e..7c6b1488dc 100755 --- a/.bin/bun +++ b/.bin/bun @@ -55,6 +55,7 @@ PROJECT_ROOT="$(find_project_root)" ENV_LOCAL_FILE="$PROJECT_ROOT/.env.local" ENV_DEVELOPMENT_LOCAL_FILE="$PROJECT_ROOT/.env.development.local" CACHE_TTL_SECONDS=${INFISICAL_CACHE_TTL:-900} # Default 15 minutes +INFISICAL_ENV_NAME=${INFISICAL_ENV:-dev} CALLING_DIR="$(pwd)" # Clean up any leftover temporary files @@ -103,8 +104,9 @@ sync_from_infisical() { local temp_file=$(mktemp) - # Run infisical export in background from project root - (cd "$PROJECT_ROOT" && infisical export > "$temp_file" 2>/dev/null; echo $? > "$temp_file.exit") & + # Run infisical export in background from project root. Pass an explicit + # environment so the CLI never waits for interactive environment selection. + (cd "$PROJECT_ROOT" && infisical export --env="$INFISICAL_ENV_NAME" --silent > "$temp_file" 2>/dev/null; echo $? > "$temp_file.exit") & local pid=$! # Wait up to 10 seconds diff --git a/.bin/cloudflared b/.bin/cloudflared new file mode 100755 index 0000000000..1c433a5cca Binary files /dev/null and b/.bin/cloudflared differ diff --git a/.bun-version b/.bun-version index 17e63e7aff..085c0f2666 100644 --- a/.bun-version +++ b/.bun-version @@ -1 +1 @@ -1.3.11 +1.3.14 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000000..b88f65f83e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,57 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "freebuff-web", + "runtimeExecutable": "bun", + "runtimeArgs": [ + "--cwd", + "freebuff/web", + "dev" + ], + "port": 3002, + "autoPort": false + }, + { + "name": "codebuff-web", + "runtimeExecutable": "bun", + "runtimeArgs": [ + "--cwd", + "web", + "dev" + ], + "port": 3000, + "autoPort": false + }, + { + "name": "freebuff-desktop", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "PORT=8787 exec bun freebuff-desktop/src/app/server.ts" + ], + "port": 8787, + "autoPort": false + }, + { + "name": "freebuff-desktop-web", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "cd freebuff-desktop && exec bun run dev:web" + ], + "port": 5174, + "autoPort": false + }, + { + "name": "freebuff-desktop-ui-only", + "runtimeExecutable": "bash", + "runtimeArgs": [ + "-c", + "cd freebuff-desktop && exec bun run ui:dev" + ], + "port": 5174, + "autoPort": false + } + ] +} \ No newline at end of file diff --git a/.cursor/rules/end-to-end-pr-development.mdc b/.cursor/rules/end-to-end-pr-development.mdc new file mode 100644 index 0000000000..f38c434d5b --- /dev/null +++ b/.cursor/rules/end-to-end-pr-development.mdc @@ -0,0 +1,56 @@ +--- +description: End-to-end PR development, deployment, and production verification workflow +alwaysApply: true +--- + +# End-To-End PR Development + +Use this workflow for complex UI changes, backend changes, deployment-sensitive fixes, and features that need real product verification. For simple questions, small bug explanations, or low-risk local edits, do not run the full production loop. + +## Environment Checklist + +- Ensure `direnv` is active and `bun` resolves to the repo wrapper: `command -v bun` should point at `.bin/bun`. +- Infisical manages local secrets sync. If env sync fails or `.env.local` appears stale, ask the user to run `infisical login` / `direnv allow`, then retry with `INFISICAL_CACHE_TTL=0 bun ...`. +- Axiom manages production logs for the Next.js server. Use the repo log scripts and the Axiom query token from env when production behavior needs diagnosis. +- Convex manages Freebuff Web production backend logs. Use Convex logs for backend runtime errors and operational warnings. +- GitHub Actions manages deployment status and required checks. Monitor checks actively after merging or pushing deploy fixes. +- If Convex APIs changed, regenerate types before typechecking with `bun x convex dev --once` from the relevant Convex app directory. In non-interactive agent terminals, never use `convex dev`, `npx convex dev`, or `bun x convex dev` without `--once`, and never hand-edit `src/convex/_generated/*` files to fake a clean typecheck. + +## Development Loop + +- Keep changes scoped and delivery-focused. Prefer the simplest implementation that matches existing repo patterns. +- Run targeted checks first. Typecheck or broader tests when the change touches shared contracts, backend behavior, generated Convex APIs, or critical UI paths. +- If typecheck items are skipped for speed, still ensure no obvious type issues in changed files and state what was skipped. +- Do not wait for perfect local coverage when the user asks for production iteration. Fix, push, merge, monitor, and verify in production. + +## Merge And Deploy + +- For changes intended to ship, merge directly with `main` when requested. +- After merge/push, monitor GitHub Actions for about 12 minutes or until the relevant checks/deployment state is clear. +- If checks fail, resolve merge conflicts or failures quickly, push fixes, and keep monitoring. +- When a fix needs a PR, keep iterations in a single PR thread where practical. Merge immediately when requested, then continue monitoring checks and deployment. + +## Production Verification + +- Test immediately in production after deployment: web changes on `https://freebuff.com/web`; CLI changes through tmux output. +- For Freebuff Web, assume the browser is logged in as Victor. If auth is missing, stop and ask the user to log in/authenticate before continuing tests. +- Keep testing fast and focused: verify each new feature or fix, click through the main affected paths, and make sure adjacent core behavior is not broken. +- For chat/platform changes, type into inputs, send messages, inspect output, navigate between relevant links/tabs, and test on production data. +- For complex features, broaden coverage enough to catch broken states, regressions, and missing interactions. For smaller fixes, run the minimum test that proves the core behavior. + +## Evidence + +- Capture screenshots for production verification, especially before/after states and the final working state. +- If video is needed, use a real click-through screen recording, not stitched screenshots. Make clicks visible and easy to follow. +- Attach screenshot/video evidence to the PR thread in comments. Keep all iteration history in that PR thread when possible. +- Final summaries should link the PR or PRs used for the branch and mention where the screenshot/video evidence lives. + +## External Services + +- If testing requires external accounts, email inboxes, payment flows, private provider dashboards, or other access the agent does not have, document it in the PR and final summary. +- Include a clear checklist of what the agent verified versus what the user must verify manually. + +## Final Report + +- State what shipped, what was verified in production, which checks/deployments were monitored, and any remaining risks. +- Call out skipped checks, auth blockers, external-service gaps, or logs that indicate follow-up is needed. diff --git a/.env.example b/.env.example index 9c3d6c493a..e51de11b67 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ OPENCODE_API_KEY=dummy_opencode_key # Database & Server DATABASE_URL=postgresql://manicode_user_local:secretpassword_local@localhost:5432/manicode_db_local +# Optional: Redis-compatible store for distributed free-mode rate limits. +# On Render, set this to the Key Value instance's internal URL. +# REDIS_URL=redis://localhost:6379 PORT=4242 # Authentication @@ -17,7 +20,19 @@ CODEBUFF_GITHUB_ID=dummy_github_id CODEBUFF_GITHUB_SECRET=dummy_github_secret FREEBUFF_GITHUB_ID=dummy_freebuff_github_id FREEBUFF_GITHUB_SECRET=dummy_freebuff_github_secret +# Google OAuth (optional). Authorized redirect URI per origin: +# {origin}/api/auth/callback/google +# Freebuff falls back to CODEBUFF_GOOGLE_* when its own are unset. +CODEBUFF_GOOGLE_ID=dummy_google_id +CODEBUFF_GOOGLE_SECRET=dummy_google_secret +FREEBUFF_GOOGLE_ID=dummy_freebuff_google_id +FREEBUFF_GOOGLE_SECRET=dummy_freebuff_google_secret +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_FREEBUFF_URL=http://localhost:3002 NEXTAUTH_SECRET=dummy_nextauth_secret_at_least_32_chars_long +# Codebuff user ID for the dedicated server-only Freebuff Web service account. +# Never set this to a personal user ID. +# FREEBUFF_WEB_SERVICE_USER_ID=00000000-0000-0000-0000-000000000000 # Payment (Stripe) STRIPE_SECRET_KEY=sk_test_dummy_stripe_secret @@ -29,6 +44,7 @@ STRIPE_SUBSCRIPTION_500_PRICE_ID=price_dummy_subscription_500_id # External Services SERPER_API_KEY=dummy_serper_key +GRAVITY_API_KEY=dummy_gravity_ads_key LOOPS_API_KEY=dummy_loops_key ZEROCLICK_API_KEY=dummy_zeroclick_key @@ -40,9 +56,11 @@ DISCORD_APPLICATION_ID=dummy_discord_app_id # Frontend/Public Variables NEXT_PUBLIC_CB_ENVIRONMENT=dev NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3000 +NEXT_PUBLIC_FREEBUFF_APP_URL=http://localhost:3002 NEXT_PUBLIC_SUPPORT_EMAIL=support@codebuff.com NEXT_PUBLIC_POSTHOG_API_KEY=phc_dummy_posthog_key NEXT_PUBLIC_POSTHOG_HOST_URL=https://us.i.posthog.com +NEXT_PUBLIC_GRAVITY_PIXEL_ID=00000000-0000-0000-0000-000000000000 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_dummy_publishable NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL=https://billing.stripe.com/p/login/test_dummy NEXT_PUBLIC_WEB_PORT=3000 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 79326c251c..e5797ae8c4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @brandonkachen @jahooma @charleslien +* @jahooma diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index 5fab54c9d4..2bc4001253 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -21,11 +21,22 @@ runs: path: | node_modules */node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*', '**/package.json') }} + packages/*/node_modules + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | - ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} ${{ runner.os }}-deps- - name: Install dependencies shell: bash - run: bun install --frozen-lockfile + run: | + for i in 1 2 3; do + bun install --frozen-lockfile && break + if [ "$i" -eq 3 ]; then + echo "bun install failed after $i attempts" + exit 1 + fi + echo "bun install failed (attempt $i), clearing download cache and retrying..." + rm -rf ~/.bun/install/cache + sleep 5 + done diff --git a/.github/knowledge.md b/.github/knowledge.md index 52f0843cf1..f776e4034a 100644 --- a/.github/knowledge.md +++ b/.github/knowledge.md @@ -25,8 +25,8 @@ GitHub API URLs are extracted as environment variables to avoid duplication: ```yaml env: - GITHUB_API_URL: https://api.github.com/repos/CodebuffAI/codebuff - GITHUB_UPLOADS_URL: https://uploads.github.com/repos/CodebuffAI/codebuff + GITHUB_API_URL: https://api.github.com/repos/CodebuffAI/freebuff-private + GITHUB_UPLOADS_URL: https://uploads.github.com/repos/CodebuffAI/freebuff-private ``` This pattern: diff --git a/.github/workflows/bot-sweep.yml b/.github/workflows/bot-sweep.yml index e9dec1ea5e..a8b6d78e23 100644 --- a/.github/workflows/bot-sweep.yml +++ b/.github/workflows/bot-sweep.yml @@ -1,13 +1,19 @@ name: Freebuff Bot Sweep +# DISABLED 2026-06-17: the hourly email report was ignored in practice and +# wasn't surfacing real bots. Replaced by the admin dashboard at +# freebuff.com/abuse (interactive suspect list + one-click bans). The +# /api/admin/bot-sweep endpoint still works; re-enable the schedule below +# if you want the email back. +# # Hourly dry-run sweep over active freebuff sessions. Calls the # /api/admin/bot-sweep endpoint, which emails james@codebuff.com with a # ranked list of suspects. No bans are issued — review and run # scripts/ban-freebuff-bots.ts manually. on: - schedule: - - cron: '0 * * * *' + # schedule: + # - cron: '0 * * * *' workflow_dispatch: jobs: diff --git a/.github/workflows/buffbench.yml b/.github/workflows/buffbench.yml index ac48369956..da4542ae91 100644 --- a/.github/workflows/buffbench.yml +++ b/.github/workflows/buffbench.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -23,7 +23,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- diff --git a/.github/workflows/chat-attachment-sweep.yml b/.github/workflows/chat-attachment-sweep.yml new file mode 100644 index 0000000000..292843b25f --- /dev/null +++ b/.github/workflows/chat-attachment-sweep.yml @@ -0,0 +1,46 @@ +name: Freebuff Chat Attachment Sweep + +# Reclaims storage by deleting the extracted-text blobs of chat document +# attachments older than the retention window (CHAT_DOC_RETENTION_DAYS), then +# clearing those messages' attachment refs. Calls the +# /api/admin/chat-attachment-sweep endpoint on the freebuff web deployment +# (which owns the chat Convex blob store). Idempotent and bounded per run, so a +# daily schedule comfortably drains the backlog. +# +# Requires: +# - secret CHAT_ATTACHMENT_SWEEP_SECRET (also set on the freebuff web service) +# - optional var CHAT_ATTACHMENT_SWEEP_URL to override the endpoint URL + +on: + schedule: + # Daily at 04:17 UTC — off-peak, retention is measured in days so frequency + # doesn't need to be high. + - cron: '17 4 * * *' + workflow_dispatch: + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Trigger chat-attachment-sweep + env: + CHAT_ATTACHMENT_SWEEP_SECRET: ${{ secrets.CHAT_ATTACHMENT_SWEEP_SECRET }} + CHAT_ATTACHMENT_SWEEP_URL: ${{ vars.CHAT_ATTACHMENT_SWEEP_URL || 'https://freebuff.com/api/admin/chat-attachment-sweep' }} + run: | + set -euo pipefail + if [ -z "$CHAT_ATTACHMENT_SWEEP_SECRET" ]; then + echo "CHAT_ATTACHMENT_SWEEP_SECRET is not set — skipping." + exit 0 + fi + status=$(curl -sS -o /tmp/resp.json -w '%{http_code}' \ + -X POST "$CHAT_ATTACHMENT_SWEEP_URL" \ + -H "Authorization: Bearer $CHAT_ATTACHMENT_SWEEP_SECRET" \ + -H "Content-Type: application/json" \ + --max-time 120) + echo "HTTP $status" + cat /tmp/resp.json + echo + if [ "$status" != "200" ]; then + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1723cd2e8..cbfcf902d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -32,7 +32,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- @@ -88,6 +89,8 @@ jobs: .agents, cli, common, + freebuff-desktop, + freebuff/web, packages/agent-runtime, packages/billing, packages/internal, @@ -103,7 +106,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -112,7 +115,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- @@ -140,7 +144,7 @@ jobs: run: cd cli && bun run prebuild:agents - name: Run ${{ matrix.package }} tests - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 @@ -200,7 +204,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -209,7 +213,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- @@ -237,7 +242,7 @@ jobs: run: cd cli && bun run prebuild:agents - name: Run ${{ matrix.package }} integration tests - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 15 max_attempts: 3 @@ -250,11 +255,22 @@ jobs: echo "No integration tests found in ${{ matrix.package }}" fi - # Billing integration tests (requires PostgreSQL) + # DB-backed integration tests (PostgreSQL container + migrated schema), one + # matrix entry per package so failures attribute cleanly and run in parallel. + # The name mirrors the DB-less test-integration job's naming so the check + # names stay test-integration-. # DATABASE_URL is set at job level to override any secrets injection - test-billing-integration: + test-db-integration: needs: [build-and-check] - name: test-integration-packages/billing + strategy: + fail-fast: false + matrix: + package: + [ + packages/billing, + packages/internal, + ] + name: test-integration-${{ matrix.package }} runs-on: ubuntu-latest env: DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb @@ -279,7 +295,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -288,7 +304,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- @@ -316,7 +333,7 @@ jobs: run: echo "DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/testdb" >> $GITHUB_ENV - name: Setup database schema - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 env: DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb with: @@ -324,106 +341,19 @@ jobs: max_attempts: 3 command: cd packages/internal && bun run db:migrate - - name: Run billing integration tests - uses: nick-fields/retry@v3 + - name: Run ${{ matrix.package }} integration tests + uses: nick-fields/retry@v4 with: timeout_minutes: 15 max_attempts: 3 command: | - cd packages/billing + cd ${{ matrix.package }} TEST_FILES=$(find src -name '*.integration.test.ts' 2>/dev/null | sort | tr '\n' ' ') if [ -n "$TEST_FILES" ]; then bun test --timeout=60000 $TEST_FILES else - echo "No integration tests found in packages/billing" + echo "No integration tests found in ${{ matrix.package }}" fi - # Internal package integration tests (requires PostgreSQL for advisory lock tests) - # DATABASE_URL is set at job level to override any secrets injection - test-internal-integration: - needs: [build-and-check] - name: test-integration-packages/internal - runs-on: ubuntu-latest - env: - DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: testdb - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.5' - - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: | - node_modules - */node_modules - packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} - restore-keys: | - ${{ runner.os }}-deps- - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Set environment variables - env: - SECRETS_CONTEXT: ${{ toJSON(secrets) }} - run: | - VAR_NAMES=$(bun scripts/generate-ci-env.ts) - echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' - to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value - ' >> $GITHUB_ENV - echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV - echo "NEXT_PUBLIC_CB_ENVIRONMENT=test" >> $GITHUB_ENV - echo "NEXT_PUBLIC_INFISICAL_UP=true" >> $GITHUB_ENV - echo "CODEBUFF_GITHUB_TOKEN=${{ secrets.CODEBUFF_GITHUB_TOKEN }}" >> $GITHUB_ENV - - - name: Build SDK before integration tests - run: cd sdk && bun run build - - # Override any DATABASE_URL injected from secrets with our test container URL - - name: Override DATABASE_URL for test container - run: echo "DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/testdb" >> $GITHUB_ENV - - - name: Setup database schema - uses: nick-fields/retry@v3 - env: - DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb - with: - timeout_minutes: 2 - max_attempts: 3 - command: cd packages/internal && bun run db:migrate - - - name: Run internal integration tests - uses: nick-fields/retry@v3 - with: - timeout_minutes: 15 - max_attempts: 3 - command: | - cd packages/internal - TEST_FILES=$(find src -name '*.integration.test.ts' 2>/dev/null | sort | tr '\n' ' ') - if [ -n "$TEST_FILES" ]; then - bun test --timeout=60000 $TEST_FILES - else - echo "No integration tests found in packages/internal" - fi # E2E tests for web intentionally omitted for now. diff --git a/.github/workflows/cli-avx2-probe-smoke.yml b/.github/workflows/cli-avx2-probe-smoke.yml new file mode 100644 index 0000000000..95422279ef --- /dev/null +++ b/.github/workflows/cli-avx2-probe-smoke.yml @@ -0,0 +1,60 @@ +# Smoke-tests the Windows AVX2 CPU-feature probe used by the CLI launcher +# scripts to pick between the optimized (AVX2) and baseline binaries. +# +# The probe shells out to PowerShell + Add-Type to call kernel32's +# IsProcessorFeaturePresent. None of that can be exercised on Linux/macOS, so +# this job runs the exact invocation on a real Windows runner and asserts it +# yields a clean boolean — catching breakage in the PowerShell string, the +# Add-Type P/Invoke, or the output format before it ships to users. +# +# It cannot test the AVX2-absent case (runners are AVX2-capable), but it locks +# down the mechanism the code depends on. + +name: CLI AVX2 probe smoke test + +on: + push: + branches: ['main'] + paths: + - 'cli/release/index.js' + - 'cli/release-staging/index.js' + - 'freebuff/cli/release/index.js' + - '.github/workflows/cli-avx2-probe-smoke.yml' + pull_request: + branches: ['main'] + paths: + - 'cli/release/index.js' + - 'cli/release-staging/index.js' + - 'freebuff/cli/release/index.js' + - '.github/workflows/cli-avx2-probe-smoke.yml' + +jobs: + windows-avx2-probe: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + # Syntax-check all three launchers on Windows (they run under the + # system Node, not Bun). + - name: Syntax-check launcher scripts + shell: bash + run: | + for f in cli/release/index.js cli/release-staging/index.js freebuff/cli/release/index.js; do + node --check "$f" + echo "ok: $f" + done + + # Run the exact PowerShell invocation the launchers use and assert it + # prints a clean "True"/"False" — i.e. Add-Type + IsProcessorFeaturePresent + # works and the output is parseable by probeWindowsAvx2(). + - name: Probe AVX2 via IsProcessorFeaturePresent + shell: pwsh + run: | + $script = "`$f = Add-Type -MemberDefinition '[DllImport(`"kernel32.dll`")] public static extern bool IsProcessorFeaturePresent(uint feature);' -Name Cpu -Namespace Win32 -PassThru; `$f::IsProcessorFeaturePresent(40)" + $out = (& powershell.exe -NoProfile -NonInteractive -Command $script | Out-String).Trim() + Write-Host "Probe output: '$out'" + if ($out -ne 'True' -and $out -ne 'False') { + Write-Error "Expected probe to print 'True' or 'False', got: '$out'" + exit 1 + } + Write-Host "AVX2 probe returned a clean boolean: $out" diff --git a/.github/workflows/cli-release-build.yml b/.github/workflows/cli-release-build.yml index ad7c40d908..ecb719c667 100644 --- a/.github/workflows/cli-release-build.yml +++ b/.github/workflows/cli-release-build.yml @@ -37,6 +37,11 @@ jobs: bun_target: bun-linux-x64 platform: linux arch: x64 + - os: ubuntu-latest + target: linux-x64-baseline + bun_target: bun-linux-x64-baseline + platform: linux + arch: x64 - os: ubuntu-latest target: linux-arm64 bun_target: bun-linux-arm64 @@ -209,6 +214,13 @@ jobs: path: ${{ inputs.binary-name }}-${{ matrix.target }}.tar.gz build-windows-binary: + strategy: + matrix: + include: + - target: win32-x64 + bun_target: bun-windows-x64 + - target: win32-x64-baseline + bun_target: bun-windows-x64-baseline runs-on: windows-latest steps: - uses: actions/checkout@v6 @@ -318,7 +330,7 @@ jobs: - name: Prepare Windows Bun compile runtime uses: ./.github/actions/setup-bun-compile-runtime with: - target: bun-windows-x64 + target: ${{ matrix.bun_target }} - name: Build binary run: bun run scripts/build-binary.ts ${{ inputs.binary-name }} ${{ inputs.new-version }} @@ -326,7 +338,7 @@ jobs: shell: bash env: VERBOSE: true - OVERRIDE_TARGET: bun-windows-x64 + OVERRIDE_TARGET: ${{ matrix.bun_target }} OVERRIDE_PLATFORM: win32 OVERRIDE_ARCH: x64 @@ -350,10 +362,10 @@ jobs: BINARY_FILE="${{ inputs.binary-name }}.exe" # Bundle tree-sitter.wasm next to the binary; see the # equivalent matrix-job tar step for context. - tar -czf ${{ inputs.binary-name }}-win32-x64.tar.gz -C cli/bin "$BINARY_FILE" tree-sitter.wasm + tar -czf ${{ inputs.binary-name }}-${{ matrix.target }}.tar.gz -C cli/bin "$BINARY_FILE" tree-sitter.wasm - name: Upload binary artifact uses: actions/upload-artifact@v7 with: - name: ${{ inputs.binary-name }}-win32-x64 - path: ${{ inputs.binary-name }}-win32-x64.tar.gz + name: ${{ inputs.binary-name }}-${{ matrix.target }} + path: ${{ inputs.binary-name }}-${{ matrix.target }}.tar.gz diff --git a/.github/workflows/cli-release-prod.yml b/.github/workflows/cli-release-prod.yml index ded7b4ed32..030c3e2d75 100644 --- a/.github/workflows/cli-release-prod.yml +++ b/.github/workflows/cli-release-prod.yml @@ -125,10 +125,12 @@ jobs: ### Platform Binaries - `codebuff-linux-x64.tar.gz` - Linux x64 + - `codebuff-linux-x64-baseline.tar.gz` - Linux x64 older CPUs - `codebuff-linux-arm64.tar.gz` - Linux ARM64 - `codebuff-darwin-x64.tar.gz` - macOS Intel - `codebuff-darwin-arm64.tar.gz` - macOS Apple Silicon - `codebuff-win32-x64.tar.gz` - Windows x64 + - `codebuff-win32-x64-baseline.tar.gz` - Windows x64 older CPUs files: | binaries/*/codebuff-* repository: CodebuffAI/codebuff-community diff --git a/.github/workflows/cli-release-staging.yml b/.github/workflows/cli-release-staging.yml index 617e7f38ff..c353541c48 100644 --- a/.github/workflows/cli-release-staging.yml +++ b/.github/workflows/cli-release-staging.yml @@ -188,10 +188,12 @@ jobs: ### Included Binaries - `codecane-linux-x64.tar.gz` + - `codecane-linux-x64-baseline.tar.gz` - `codecane-linux-arm64.tar.gz` - `codecane-darwin-x64.tar.gz` - `codecane-darwin-arm64.tar.gz` - `codecane-win32-x64.tar.gz` + - `codecane-win32-x64-baseline.tar.gz` After downloading, extract the tarball, add the binary to your PATH, and run `codecane --help` for usage. files: | diff --git a/.github/workflows/evals.yml b/.github/workflows/evals.yml index de7cceae11..43f3d1189a 100644 --- a/.github/workflows/evals.yml +++ b/.github/workflows/evals.yml @@ -30,7 +30,7 @@ jobs: if: ${{ steps.check_commit.outputs.should_run_evals == 'true' }} uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies if: ${{ steps.check_commit.outputs.should_run_evals == 'true' }} @@ -40,7 +40,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- diff --git a/.github/workflows/freebuff-desktop-release.yml b/.github/workflows/freebuff-desktop-release.yml new file mode 100644 index 0000000000..1b490cdff1 --- /dev/null +++ b/.github/workflows/freebuff-desktop-release.yml @@ -0,0 +1,243 @@ +name: Freebuff Desktop Release + +# Builds the Freebuff Desktop Electron app for macOS (arm64 + Intel), Windows, +# and Linux, then publishes the installers to a public GitHub Release so users +# can download them. Mirrors the CLI release flow (freebuff-release.yml), but +# uses electron-builder instead of `bun build --compile`. + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Version bump type' + required: true + default: 'patch' + type: choice + options: + - patch + - minor + - major + checkout_ref: + description: 'Git ref to build from (commit SHA, branch, or tag). Defaults to latest main.' + required: false + default: '' + type: string + +concurrency: + group: freebuff-desktop-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + prepare-and-commit: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.bump_version.outputs.new_version }} + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.checkout_ref || '' }} + + - uses: ./.github/actions/setup-project + + - name: Calculate and update version + id: bump_version + run: | + cd freebuff-desktop + + CURRENT_VERSION=$(bun -e "console.log(require('./package.json').version)") + echo "Current version: $CURRENT_VERSION" + + # Use a bun script rather than `npm version`: freebuff-desktop is a + # member of the root Bun workspace, so npm resolves the whole + # workspace and dies on the sibling package `@codebuff/.agents` + # ("name cannot start with a period"). + NEW_VERSION=$(bun scripts/bump-version.ts ${{ inputs.version_type }}) + + echo "New Freebuff Desktop version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - name: Commit and push version bump + run: | + git stash + git pull --rebase origin main + git stash pop + git add freebuff-desktop/package.json + git commit -m "Bump Freebuff Desktop version to ${{ steps.bump_version.outputs.new_version }}" + git push + + - name: Create and push tag + run: | + git tag "freebuff-desktop-v${{ steps.bump_version.outputs.new_version }}" + git push origin "freebuff-desktop-v${{ steps.bump_version.outputs.new_version }}" + + build: + needs: prepare-and-commit + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: mac-arm64 + - os: macos-15-intel + target: mac-x64 + - os: windows-latest + target: win-x64 + - os: ubuntu-latest + target: linux-x64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout_ref || github.sha }} + + - uses: ./.github/actions/setup-project + + - name: Stamp release version + shell: bash + working-directory: freebuff-desktop + run: | + # See prepare-and-commit for why this uses a bun script, not npm. + bun scripts/bump-version.ts ${{ needs.prepare-and-commit.outputs.new_version }} + + - name: Configure environment variables + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + # FREEBUFF_MODE is read by the orchestrator at runtime; the + # NEXT_PUBLIC_* vars are baked into the orchestrator bundle at build + # time (see freebuff-desktop/scripts/build-orchestrator.ts). + ENV_OVERRIDES: '{"FREEBUFF_MODE": "true", "NEXT_PUBLIC_CB_ENVIRONMENT": "prod"}' + shell: bash + run: | + VAR_NAMES=$(bun scripts/generate-ci-env.ts --scope client) + + echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' + to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value + ' >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV + echo "$ENV_OVERRIDES" | jq -r 'to_entries | .[] | .key + "=" + .value' >> $GITHUB_ENV + + - name: Stage app (UI + icons + Bun + orchestrator) + shell: bash + working-directory: freebuff-desktop + run: bun run prepackage + + # macOS Developer ID signing + notarization is auto-gating: it activates + # only when the APPLE_* secrets are present. Until they are, this step is + # a no-op and the build falls back to the ad-hoc-unsigned path below. The + # same wiring works for an individual OR an organization cert — switching + # identity is purely a matter of overwriting these six secrets, no code + # change. See freebuff-desktop/docs/code-signing.md. + - name: Configure macOS signing + if: runner.os == 'macOS' + shell: bash + env: + APPLE_CERT_P12_BASE64: ${{ secrets.APPLE_CERT_P12_BASE64 }} + APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }} + APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + if [ -z "$APPLE_CERT_P12_BASE64" ]; then + echo "No APPLE_* signing secrets set — building UNSIGNED (ad-hoc)." + echo "CSC_IDENTITY_AUTO_DISCOVERY=false" >> "$GITHUB_ENV" + exit 0 + fi + echo "Signing secrets present — configuring Developer ID sign + notarize." + echo "$APPLE_CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12" + echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" + { + # electron-builder imports this .p12 into a throwaway keychain itself. + echo "CSC_LINK=$RUNNER_TEMP/cert.p12" + echo "CSC_KEY_PASSWORD=$APPLE_CERT_PASSWORD" + echo "CSC_IDENTITY_AUTO_DISCOVERY=true" + # @electron/notarize (App Store Connect API key) reads these. Note + # the issuer var it expects is APPLE_API_ISSUER, *not* ..._ID — the + # secret is named APPLE_API_ISSUER_ID but must export under this name. + echo "APPLE_API_KEY=$RUNNER_TEMP/AuthKey.p8" + echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" + echo "APPLE_API_ISSUER=$APPLE_API_ISSUER_ID" + echo "APPLE_TEAM_ID=$APPLE_TEAM_ID" + # Flag consumed by the package step and the ad-hoc-sign afterPack hook. + echo "FREEBUFF_NOTARIZE=true" + } >> "$GITHUB_ENV" + + - name: Package installer (electron-builder) + shell: bash + working-directory: freebuff-desktop + env: + # On Windows/Linux (no "Configure macOS signing" step) keep auto + # identity discovery off. On macOS the step above sets this via + # $GITHUB_ENV: false when unsigned, true when signing secrets exist. + CSC_IDENTITY_AUTO_DISCOVERY: ${{ env.CSC_IDENTITY_AUTO_DISCOVERY || 'false' }} + run: | + if [ "$FREEBUFF_NOTARIZE" = "true" ]; then + echo "Packaging with Developer ID signing + notarization." + bun run pack:ci -c.mac.notarize=true + else + bun run pack:ci + fi + + - name: Collect installers + shell: bash + working-directory: freebuff-desktop + run: | + mkdir -p out + # Only the distributable installers — skip the unpacked dir/, the + # builder-debug.yml, etc. + find dist -maxdepth 1 -type f \ + \( -name '*.dmg' -o -name '*.exe' -o -name '*.AppImage' \) \ + -exec cp {} out/ \; + echo "Collected:" && ls -la out + + - name: Upload installer artifact + uses: actions/upload-artifact@v7 + with: + name: freebuff-desktop-${{ matrix.target }} + path: freebuff-desktop/out/* + if-no-files-found: error + + create-release: + needs: [prepare-and-commit, build] + runs-on: ubuntu-latest + steps: + - name: Download all installer artifacts + uses: actions/download-artifact@v8 + with: + path: installers/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: freebuff-desktop-v${{ needs.prepare-and-commit.outputs.new_version }} + name: Freebuff Desktop v${{ needs.prepare-and-commit.outputs.new_version }} + prerelease: true + body: | + ## Freebuff Desktop v${{ needs.prepare-and-commit.outputs.new_version }} + + Desktop app for the Freebuff coding agent. Download the installer for your platform: + + | Platform | File | + | --- | --- | + | macOS (Apple Silicon) | `Freebuff-${{ needs.prepare-and-commit.outputs.new_version }}-mac-arm64.dmg` | + | macOS (Intel) | `Freebuff-${{ needs.prepare-and-commit.outputs.new_version }}-mac-x64.dmg` | + | Windows (x64) | `Freebuff-${{ needs.prepare-and-commit.outputs.new_version }}-win-x64.exe` | + | Linux (x64) | `Freebuff-${{ needs.prepare-and-commit.outputs.new_version }}-linux-x64.AppImage` | + + > **Note:** ${{ secrets.APPLE_CERT_P12_BASE64 != '' && 'macOS builds are signed with a Developer ID and notarized by Apple — no right-click-to-open needed.' || 'macOS builds are not yet code-signed; macOS users may need to right-click → Open (or allow it in System Settings → Privacy & Security).' }} + > Windows builds are unsigned, so Windows users may see a SmartScreen warning. + files: | + installers/**/*.dmg + installers/**/*.exe + installers/**/*.AppImage + repository: CodebuffAI/codebuff-community + token: ${{ secrets.CODEBUFF_GITHUB_TOKEN }} diff --git a/.github/workflows/freebuff-release.yml b/.github/workflows/freebuff-release.yml index 5b0d1c9156..e170341657 100644 --- a/.github/workflows/freebuff-release.yml +++ b/.github/workflows/freebuff-release.yml @@ -127,10 +127,12 @@ jobs: ### Platform Binaries - `freebuff-linux-x64.tar.gz` - Linux x64 + - `freebuff-linux-x64-baseline.tar.gz` - Linux x64 older CPUs - `freebuff-linux-arm64.tar.gz` - Linux ARM64 - `freebuff-darwin-x64.tar.gz` - macOS Intel - `freebuff-darwin-arm64.tar.gz` - macOS Apple Silicon - `freebuff-win32-x64.tar.gz` - Windows x64 + - `freebuff-win32-x64-baseline.tar.gz` - Windows x64 older CPUs files: | binaries/*/freebuff-* repository: CodebuffAI/codebuff-community @@ -158,6 +160,10 @@ jobs: registry-url: https://registry.npmjs.org/ - name: Publish to npm + env: + # npm trusted publishing can authenticate from private repositories, + # but provenance statements are only supported for public sources. + NPM_CONFIG_PROVENANCE: 'false' run: | cd freebuff/cli/release - npm publish --access public --provenance + npm publish --access public diff --git a/.github/workflows/mirror-dot-agents.yml b/.github/workflows/mirror-dot-agents.yml deleted file mode 100644 index 67bb820186..0000000000 --- a/.github/workflows/mirror-dot-agents.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Mirror subdir to dot-agents -on: - push: - branches: [main] - paths: - - 'common/src/templates/initial-agents-dir/**' - -jobs: - mirror: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - # (belt-and-suspenders) ensure the injected header is gone even if persist-credentials isn't honored - - name: Clear default GitHub auth header - run: git config --global --unset-all http.https://github.com/.extraheader || true - - - name: Configure Git - run: | - git config user.name "codebuff mirror bot" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Push subtree - env: - TOKEN: ${{ secrets.CODEBUFF_GITHUB_TOKEN }} - run: | - git subtree push --prefix=common/src/templates/initial-agents-dir \ - https://x-access-token:${TOKEN}@github.com/codebuffai/codebuff-dot-agents.git \ - main diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 684a104e1b..050e9a7100 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -26,7 +26,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- diff --git a/.github/workflows/nightly-evals.yml b/.github/workflows/nightly-evals.yml index a8a776d75f..11b4b3df1d 100644 --- a/.github/workflows/nightly-evals.yml +++ b/.github/workflows/nightly-evals.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: '1.3.5' + bun-version-file: .bun-version - name: Cache dependencies uses: actions/cache@v5 @@ -26,7 +26,8 @@ jobs: node_modules */node_modules packages/*/node_modules - key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + freebuff/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('bun.lock') }} restore-keys: | ${{ runner.os }}-deps- diff --git a/.github/workflows/referral-sweep.yml b/.github/workflows/referral-sweep.yml new file mode 100644 index 0000000000..153239394d --- /dev/null +++ b/.github/workflows/referral-sweep.yml @@ -0,0 +1,41 @@ +name: Freebuff Referral Sweep + +# Backstop that completes pending referrals whose live trigger was missed, or +# whose referred GitHub account only ages past the program bar later. Calls the +# /api/admin/referral-sweep endpoint, which re-evaluates pending rows across all +# programs (cli, web, glm) and completes any that now qualify. Idempotent and +# cheap (GitHub facts are cached), so frequent runs are fine. + +on: + schedule: + # Every 15 minutes — bounds the worst-case "my friend qualified but I didn't + # get my GLM yet" delay for friends who aren't actively using the app. + - cron: '*/15 * * * *' + workflow_dispatch: + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Trigger referral-sweep + env: + REFERRAL_SWEEP_SECRET: ${{ secrets.REFERRAL_SWEEP_SECRET }} + REFERRAL_SWEEP_URL: ${{ vars.REFERRAL_SWEEP_URL || 'https://www.codebuff.com/api/admin/referral-sweep' }} + run: | + set -euo pipefail + if [ -z "$REFERRAL_SWEEP_SECRET" ]; then + echo "REFERRAL_SWEEP_SECRET is not set — skipping." + exit 0 + fi + status=$(curl -sS -o /tmp/resp.json -w '%{http_code}' \ + -X POST "$REFERRAL_SWEEP_URL" \ + -H "Authorization: Bearer $REFERRAL_SWEEP_SECRET" \ + -H "Content-Type: application/json" \ + --max-time 120) + echo "HTTP $status" + cat /tmp/resp.json + echo + if [ "$status" != "200" ]; then + exit 1 + fi diff --git a/.github/workflows/sync-public-repo.yml b/.github/workflows/sync-public-repo.yml new file mode 100644 index 0000000000..3f1b73a45a --- /dev/null +++ b/.github/workflows/sync-public-repo.yml @@ -0,0 +1,61 @@ +name: Sync Public Repo + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + dry_run: + description: 'Preview only; do not push to the public repo' + required: true + default: true + type: boolean + confirm_push: + description: 'Type sync-public to allow a non-dry-run push' + required: false + default: '' + type: string + +concurrency: + group: sync-public-repo + cancel-in-progress: false + +permissions: + contents: read + +jobs: + sync-public: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + + # Manual dry-run=false pushes require an explicit confirmation string. + # Pushes to main always sync for real and skip this gate. + - name: Validate push confirmation + if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run && inputs.confirm_push != 'sync-public' }} + run: | + echo "Refusing to push: confirm_push must be sync-public when dry_run is false." + exit 1 + + - name: Export public snapshot + env: + # Push events sync for real; manual runs honor the dry_run input. + PUBLIC_SYNC_DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '0' }} + PUBLIC_REPO: CodebuffAI/codebuff + PUBLIC_REPO_PUSH_TOKEN: ${{ secrets.CODEBUFF_GITHUB_TOKEN }} + PUBLIC_BRANCH: main + run: scripts/sync-public-repo.sh + + - name: Upload dry-run preview + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + uses: actions/upload-artifact@v7 + with: + name: public-export-preview + path: .context/public-export-preview/ diff --git a/.github/workflows/web-peak-scale.yml b/.github/workflows/web-peak-scale.yml new file mode 100644 index 0000000000..83b2d65734 --- /dev/null +++ b/.github/workflows/web-peak-scale.yml @@ -0,0 +1,43 @@ +name: Web Peak-Hours Scaling + +# Drives the Render `web` service instance count on a fixed daily curve so it can +# absorb the 12-17Z load peak (see scripts/ops/scale-web.ts / docs/web-scaling.md). +# The web tier is ingress/connection-bound and its load is clock-predictable, so a +# time-based instance floor beats CPU-target autoscaling, which reacts late. +# +# Idempotent: runs hourly and just POSTs the curve's target for the current UTC +# hour. A missed run self-corrects on the next tick. Requires native autoscaling +# to be OFF on the service (the manual scale endpoint is ignored otherwise). +# +# Setup: add the `RENDER_API_KEY` secret (Render API key with access to the web +# service). Optionally override the service id with the `RENDER_WEB_SERVICE_ID` +# repo variable; the script defaults to the prod web service. + +on: + schedule: + - cron: '5 * * * *' # hourly, at :05 to avoid the top-of-hour cron stampede + workflow_dispatch: + inputs: + instances: + description: 'Override: set an explicit instance count now (blank = use curve)' + required: false + type: string + +jobs: + scale: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - name: Scale web service + env: + RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} + RENDER_WEB_SERVICE_ID: ${{ vars.RENDER_WEB_SERVICE_ID }} + run: | + set -euo pipefail + if [ -n "${{ github.event.inputs.instances }}" ]; then + bun scripts/ops/scale-web.ts --instances "${{ github.event.inputs.instances }}" + else + bun scripts/ops/scale-web.ts + fi diff --git a/.gitignore b/.gitignore index 139bdfc07c..991a4c08fa 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,9 @@ npm-app/src/__tests__/data/ **.log debug/ +.context/ docs/bot-detection.md +.codex # Nx cache directories .nx/cache @@ -31,3 +33,7 @@ docs/bot-detection.md # Infisical config (user-specific) .infisical.json +.gstack/ + +# Buffbench DEBUG_ERROR dumps +evals/buffbench/*-error-*.json diff --git a/AGENTS.md b/AGENTS.md index 8d17b3f567..257b31f9dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,3 +42,6 @@ IMPORTANT: Prefer retrieval-led reasoning over pre-training-led reasoning. Alway - `docs/environment-variables.md` — Env var rules, DI helpers, loading order - `docs/agents-and-tools.md` — Agent system, shell shims, tool definitions - `docs/patterns/handle-steps-generators.md` — handleSteps generator patterns and spawn_agents tool calls +- `docs/freebuff-abuse-detection.md` — Finding/actioning free-mode endpoint abuse: detection scripts, signals, ban playbook +- `docs/logging.md` — Unified Axiom `freebuff` dataset: how server/CLI/browser logs+events get there, APL query scripts (`scripts/logs/`), and the ingest-cost levers +- `docs/desktop/e2e-testing.md` — How to drive Freebuff Desktop end-to-end headlessly (start server, send a build via the HTTP/SSE API, approve tasks, verify outcomes); gotchas & failure modes diff --git a/LICENSE b/LICENSE index 0af9142a49..446a604f1b 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Manicode, Inc. + Copyright 2025 Freebuff, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 477dc753f9..716cf6e703 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,71 @@ Instead of using one model for everything, Codebuff coordinates specialized agen Codebuff beats Claude Code at 61% vs 53% on [our evals](evals/README.md) across 175+ coding tasks over multiple open-source repos that simulate real-world tasks. +## Freebuff: the free coding agent + +**[Freebuff](https://www.npmjs.com/package/freebuff)** is the free, ad-supported version of Codebuff. No subscription. No credits. No configuration. Just install and start coding in your terminal. + +### Install + +```bash +npm install -g freebuff +``` + +### Usage + +```bash +cd ~/my-project +freebuff +``` + +Then tell Freebuff what you want — it finds the right files, makes the changes, and runs your tests. + +### Why Freebuff? + +- **Best open-source models** — Powered by the strongest open-source models available, like DeepSeek, Kimi, and MiniMax — no proprietary lock-in. +- **Fast** — 5–10× speed up. Faster models plus context gathering in seconds rather than minutes. +- **Loaded** — Built-in web research, browser use, and more. + +### Features + +- **File mentions** — Use `@filename` to reference specific files +- **Agent mentions** — Use `@AgentName` to invoke specialized agents +- **Bash mode** — Run terminal commands with `!command` or `/bash` +- **Chat history** — Resume past conversations with `/history` +- **Knowledge files** — Add `knowledge.md` to your project for context +- **Themes** — Toggle light/dark mode with `/theme:toggle` + +### Commands + +| Command | Description | +| --------------- | -------------------------------- | +| `/help` | Show keyboard shortcuts and tips | +| `/new` | Start a new conversation | +| `/history` | Browse past conversations | +| `/bash` | Enter bash mode | +| `/init` | Create a starter knowledge.md | +| `/feedback` | Share feedback | +| `/theme:toggle` | Toggle light/dark mode | +| `/logout` | Sign out | +| `/exit` | Quit | + +### FAQ + +**How can it be free?** Freebuff is supported by text ads. + +**What models do you use?** The best open-source models available. In full mode you can choose from DeepSeek V4 Pro, MiMo 2.5 Pro, Kimi K2.6, DeepSeek V4 Flash, MiMo 2.5, and MiniMax M3. Limited mode uses DeepSeek V4 Flash and MiMo 2.5. Gemini 3.1 Flash Lite handles file finding and research. + +**Which countries is Freebuff available in?** All countries. Freebuff runs in "full" mode in the US, Canada, UK, EU, and other select countries, and in "limited" mode everywhere else (or while using a VPN). See [freebuff.com](https://freebuff.com) for the full list. + +**What is limited mode?** Limited mode lets you use Freebuff outside the full-access countries, or while using a VPN. It includes DeepSeek V4 Flash and MiMo 2.5, with 5 one-hour sessions per day. + +**Are you training on my data?** No. We don't share your data with third parties that would train on it or use it for another purpose, unless you choose a model clearly labeled as "Collects data for training." + +**What data do you store?** We don't store your codebase. We only collect minimal logs for debugging purposes. + +--- + +The rest of this README covers **Codebuff**, the full platform Freebuff is built on — its multi-agent architecture, custom agents, and SDK. ## How it works @@ -151,18 +216,6 @@ await client.run({ Learn more about the SDK [here](https://www.npmjs.com/package/@codebuff/sdk). -## Freebuff: The free coding agent - -Don't want a subscription? **[Freebuff](https://www.npmjs.com/package/freebuff)** is a free variant of Codebuff — no subscription, no credits, no configuration. Just install and start coding. - -```bash -npm install -g freebuff -cd your-project -freebuff -``` - -Freebuff is ad-supported and uses models optimized for fast, high-quality assistance. It includes built-in web research, browser use, and more. Learn more in the [Freebuff README](./freebuff/README.md). - ## Why choose Codebuff **Custom workflows**: TypeScript generators let you mix AI generation with programmatic control. Agents can spawn subagents, branch on conditions, and run multi-step processes. diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts index a6da96c58c..26cc73d0f2 100644 --- a/agents/__tests__/base2.test.ts +++ b/agents/__tests__/base2.test.ts @@ -5,16 +5,38 @@ import { FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, FREEBUFF_KIMI_MODEL_ID, FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_MINIMAX_M3_MODEL_ID, + FREEBUFF_MIMO_V25_MODEL_ID, + FREEBUFF_MIMO_V25_PRO_MODEL_ID, } from '@codebuff/common/constants/freebuff-models' import { createBase2 } from '../base2/base2' +import codeReviewerLite from '../reviewer/code-reviewer-lite' describe('base2 reviewer selection', () => { + test('Codebuff lite uses MiniMax M3 and its matching reviewer', () => { + const base2 = createBase2('lite') + + expect(base2.model).toBe(FREEBUFF_MINIMAX_M3_MODEL_ID) + expect(base2.spawnableAgents).toContain('code-reviewer-minimax-m3') + expect(base2.instructionsPrompt).toContain( + 'Spawn a code-reviewer-minimax-m3', + ) + expect(base2.stepPrompt).toContain('spawn a code-reviewer-minimax-m3') + }) + + test('legacy lite reviewer definition uses DeepSeek V4 Flash', () => { + expect(codeReviewerLite.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) + }) + test.each([ [FREEBUFF_MINIMAX_MODEL_ID, 'code-reviewer-minimax'], + [FREEBUFF_MINIMAX_M3_MODEL_ID, 'code-reviewer-minimax-m3'], [FREEBUFF_KIMI_MODEL_ID, 'code-reviewer-kimi'], [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 'code-reviewer-deepseek'], [FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, 'code-reviewer-deepseek-flash'], + [FREEBUFF_MIMO_V25_PRO_MODEL_ID, 'code-reviewer-mimo-pro'], + [FREEBUFF_MIMO_V25_MODEL_ID, 'code-reviewer-mimo'], ])('uses matching reviewer for model %p', (model, expectedReviewer) => { const base2 = createBase2('free', { model }) @@ -23,3 +45,141 @@ describe('base2 reviewer selection', () => { expect(base2.stepPrompt).toContain(`spawn a ${expectedReviewer}`) }) }) + +describe('base2 context pruning', () => { + const getContextPrunerParams = ( + mode: Parameters[0], + options?: Parameters[1], + params?: Record, + ) => { + const base2 = createBase2(mode, options) + const generator = base2.handleSteps!({ params } as any) + const step = generator.next().value as any + return step.input.params + } + + const getSerializedContextPrunerParams = ( + mode: Parameters[0], + options?: Parameters[1], + ) => { + const base2 = createBase2(mode, options) + const handleStepsString = base2.handleSteps!.toString() + expect(handleStepsString).toMatch(/^function\*\s*\(/) + const isolatedHandleSteps = new Function( + `return (${handleStepsString})`, + )() as NonNullable + const generator = isolatedHandleSteps({ params: undefined } as any) + const step = generator.next().value as any + return step.input.params + } + + test('free MiniMax mode defaults context pruning to 200k tokens', () => { + const base2 = createBase2('free') + const generator = base2.handleSteps!({ params: undefined } as any) + + expect(generator.next().value).toMatchObject({ + toolName: 'spawn_agent_inline', + input: { + agent_type: 'context-pruner', + params: { + maxContextLength: 200_000, + cacheExpiryMs: 30 * 60 * 1000, + }, + }, + includeToolCall: false, + }) + }) + + test('free Kimi mode defaults context pruning to 250k tokens', () => { + expect( + getContextPrunerParams('free', { model: FREEBUFF_KIMI_MODEL_ID }), + ).toEqual({ + maxContextLength: 250_000, + cacheExpiryMs: 30 * 60 * 1000, + }) + }) + + test('free non-MiniMax/Kimi models default context pruning to 400k tokens', () => { + expect( + getContextPrunerParams('free', { + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + }), + ).toEqual({ + maxContextLength: 400_000, + cacheExpiryMs: 30 * 60 * 1000, + }) + }) + + test('free mode preserves explicit context pruning params', () => { + const base2 = createBase2('free') + const generator = base2.handleSteps!({ + params: { maxContextLength: 123_000, assistantToolBudget: 10_000 }, + } as any) + + expect(generator.next().value).toMatchObject({ + input: { + params: { + maxContextLength: 123_000, + assistantToolBudget: 10_000, + cacheExpiryMs: 30 * 60 * 1000, + }, + }, + }) + }) + + test.each(['default', 'lite', 'max', 'fast'] as const)( + '%s mode defaults context pruning to 400k tokens without a cache expiry override', + (mode) => { + expect(getContextPrunerParams(mode)).toEqual({ + maxContextLength: 400_000, + }) + }, + ) + + test.each([ + [FREEBUFF_MINIMAX_MODEL_ID, 200_000], + [FREEBUFF_KIMI_MODEL_ID, 250_000], + [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 400_000], + ] as const)( + 'non-free model %p defaults context pruning to %p tokens', + (model, maxContextLength) => { + expect(getContextPrunerParams('default', { model })).toEqual({ + maxContextLength, + }) + }, + ) + + test.each([ + ['free', { model: FREEBUFF_MINIMAX_MODEL_ID }, 200_000], + ['free', { model: FREEBUFF_KIMI_MODEL_ID }, 250_000], + ['free', { model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID }, 400_000], + ['default', { model: FREEBUFF_MINIMAX_MODEL_ID }, 200_000], + ['default', { model: FREEBUFF_KIMI_MODEL_ID }, 250_000], + ['default', { model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID }, 400_000], + ] as const)( + 'serialized %s handleSteps for model %p defaults to %p tokens', + (mode, options, maxContextLength) => { + expect(getSerializedContextPrunerParams(mode, options)).toMatchObject({ + maxContextLength, + }) + }, + ) + + test('non-free mode preserves explicit context pruning params', () => { + expect( + getContextPrunerParams( + 'default', + { + model: FREEBUFF_KIMI_MODEL_ID, + }, + { + maxContextLength: 123_000, + assistantToolBudget: 10_000, + }, + ), + ).toEqual({ + maxContextLength: 123_000, + assistantToolBudget: 10_000, + }) + }) +}) diff --git a/agents/__tests__/data/run-state-context-overflow.json b/agents/__tests__/data/run-state-context-overflow.json index 82a8c01b65..5edcb9bccd 100644 --- a/agents/__tests__/data/run-state-context-overflow.json +++ b/agents/__tests__/data/run-state-context-overflow.json @@ -19370,7 +19370,7 @@ "cli/knowledge.md": "# CLI Package Knowledge\n\n## Import Guidelines\n\n**Never use dynamic `await import()` calls.** Always use static imports at the top of the file.\n\n```typescript\n// ❌ WRONG: Dynamic import\nconst { someFunction } = await import('./some-module')\n\n// ✅ CORRECT: Static import at top of file\nimport { someFunction } from './some-module'\n```\n\nDynamic imports make code harder to analyze, break tree-shaking, and can hide circular dependency issues. If you need conditional loading, reconsider the architecture instead.\n\n**Exceptions** (where dynamic imports are acceptable):\n- **WASM modules**: Heavy WASM binaries that need lazy loading (e.g., QuickJS)\n- **Client-side only libraries in Next.js**: Libraries like Stripe that must only load in the browser\n- **Test utilities**: Mock module helpers that intentionally use dynamic imports\n\n## Test Naming Conventions\n\n**IMPORTANT**: Follow these naming patterns for automatic dependency detection:\n\n- **Unit tests:** `*.test.ts` (e.g., `cli-args.test.ts`)\n- **E2E tests:** `e2e-*.test.ts` (e.g., `e2e-cli.test.ts`)\n- **Integration tests:** `integration-*.test.ts` (e.g., `integration-tmux.test.ts`)\n\n**Why?** The `.bin/bun` wrapper detects files matching `*integration*.test.ts` or `*e2e*.test.ts` patterns and automatically checks for tmux availability. If tmux is missing, it shows installation instructions but lets tests continue (they skip gracefully).\n\n**Benefits:**\n\n- Project-wide convention (not CLI-specific)\n- No hardcoded directory paths\n- Automatic dependency validation\n- Clear test categorization\n\n## Testing CLI Changes with tmux\n\nUse tmux to test CLI behavior in a controlled, scriptable way. This is especially useful for testing UI updates, authentication flows, and time-dependent behavior.\n\n### Basic Pattern\n\n```bash\ntmux new-session -d -s test-session 'cd /path/to/codebuff && bun --cwd=cli run dev 2>&1' && \\\n sleep 2 && \\\n echo '---AFTER 2 SECONDS---' && \\\n tmux capture-pane -t test-session -p && \\\n sleep 3 && \\\n echo '---AFTER 5 SECONDS---' && \\\n tmux capture-pane -t test-session -p && \\\n tmux kill-session -t test-session 2>/dev/null\n```\n\n### How It Works\n\n1. **`tmux new-session -d -s test-session '...'`** - Creates a detached tmux session running the CLI\n2. **`sleep N`** - Waits for N seconds to let the CLI initialize or update\n3. **`tmux capture-pane -t test-session -p`** - Captures and prints the current terminal output\n4. **`tmux kill-session -t test-session`** - Cleans up the session when done\n\n### Use Cases\n\n- **Authentication flows**: Capture login screen states at different intervals\n- **Loading states**: Verify shimmer text, spinners, and status indicators\n- **Auto-refresh behavior**: Test components that update over time\n- **Error states**: Capture how errors appear in the TUI\n- **Layout changes**: Verify responsive behavior based on terminal dimensions\n\n### Tips\n\n- Use unique session names (e.g., `login-url-test`, `auth-check-test`) to run multiple tests in parallel\n- Redirect stderr with `2>&1` to capture all output including errors\n- Add `2>/dev/null` to `tmux kill-session` to suppress errors if session doesn't exist\n- Adjust sleep timings based on what you're testing (auth checks, network requests, etc.)\n\n### Sending Input to the CLI via tmux\n\n**See [`tmux.knowledge.md`](./tmux.knowledge.md) for comprehensive tmux documentation.**\n\n**Key point:** Standard `tmux send-keys` does NOT work - you must use bracketed paste mode:\n\n```bash\n# ❌ Broken: tmux send-keys -t session \"hello\"\n# ✅ Works: tmux send-keys -t session $'\\e[200~hello\\e[201~'\n```\n\n## Migration from Custom OpenTUI Fork\n\n**October 2024**: Migrated from custom `CodebuffAI/opentui#codebuff/custom` fork to official `@opentui/react@^0.1.27` and `@opentui/core@^0.1.27` packages. Updated to `^0.1.28` in February 2025.\n\n**Lost Features from Custom Fork:**\n\n- `usePaste` hook - Direct paste event handling is no longer available. Terminal paste (Ctrl+V/Cmd+V) now appears as regular key input events.\n\n**Impact:**\n\n- Paste functionality still works through the terminal's native paste mechanism, but we can no longer intercept paste events separately from typing.\n- If custom paste handling is needed in the future, it must be reimplemented using `useKeyboard` hook or by checking the official OpenTUI for updates.\n\n## OpenTUI Text Rendering Constraints\n\n**CRITICAL**: OpenTUI has strict requirements for text rendering that must be followed:\n\n### JSX Content Rules\n\n**DO NOT use `{' '}` or similar JSX expressions for whitespace in OpenTUI components.** This will cause the entire app to go blank.\n\n```tsx\n// ❌ WRONG: Will break the app\nHello{' '}World\n{'Some text'}\n\n// ✅ CORRECT: Use plain text or template literals\nHello World\n\n```\n\nOpenTUI expects plain text content or the `content` prop - it does not handle JSX expressions within text elements.\n\n## Screen Mode and TODO List Positioning\n\nThe CLI chat interface adapts its layout based on terminal dimensions:\n\n### Screen Modes\n\n- **Full-screen**: width ≥ 70 AND height ≥ 30\n- **Wide-screen**: width ≥ 70 AND height < 30\n- **Tall-screen**: width < 70 AND height ≥ 30\n- **Small-screen**: width < 70 AND height < 30\n\n### TODO List Positioning\n\n- **Right side**: Full-screen and wide-screen modes (when there's sufficient horizontal space)\n- **Top**: Tall-screen and small-screen modes (when terminal is narrow)\n\nThe TODO list automatically repositions based on available space to ensure optimal visibility and usability.\n\n### Text Styling Components Must Be Wrapped in ``\n\nAll text styling components (``, ``, ``, etc.) **MUST** be nested inside a `` component. They cannot be returned directly from render functions.\n\n**INCORRECT** ❌:\n\n```tsx\n// This will cause a black screen!\nfunction renderMarkdown(content: string) {\n return (\n <>\n Bold text\n Italic text\n \n )\n}\n```\n\n**CORRECT** ✅:\n\n```tsx\n// All styling must be inside \nfunction renderMarkdown(content: string) {\n return (\n \n Bold text\n Italic text\n \n )\n}\n```\n\n### Why This Matters\n\n- Returning styling components without `` wrapper causes the entire app to render as a black screen\n- No error messages are shown - the app just fails silently\n- This applies to ALL text styling: ``, ``, ``, ``, etc.\n\n### Available OpenTUI Components\n\n**Core Components**:\n\n- `` - The fundamental component for displaying all text content\n- `` - Container for layout and grouping\n- `` - Text input field\n- `` - Text input field\n- ` not + - Use + - Use not custom modals + - Use Toast (via Sonner) for notifications + +COMPONENT STRUCTURE: +- Keep components small and focused (under 200 lines) +- Extract repeated patterns into reusable components +- Use composition over configuration +- Props should be minimal and well-typed + +USER FEEDBACK: +- Use toast (Sonner) for success/error messages +- Use loading states for async operations +- Use skeleton loaders for content loading +- Provide visual feedback for all user actions + +`; diff --git a/freebuff/web/convex/coding_agent/shared/geoAccess.ts b/freebuff/web/convex/coding_agent/shared/geoAccess.ts new file mode 100644 index 0000000000..f96e9b25b7 --- /dev/null +++ b/freebuff/web/convex/coding_agent/shared/geoAccess.ts @@ -0,0 +1,268 @@ +import { + FREEBUFF_WEB_LIMITED_PROJECT_DAILY_LIMIT, + FREEBUFF_WEB_LIMITED_SESSION_LENGTH_MS, + FREEBUFF_WEB_LIMITED_SESSION_LIMIT, +} from "@codebuff/common/constants/freebuff-models"; + +import type { FreebuffWebAccessTier } from "@codebuff/common/constants/freebuff-models"; +import type { Id } from "../../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../../_generated/server"; + +const PACIFIC_TIMEZONE = "America/Los_Angeles"; +/** Fallback when Intl timezone data is unavailable: fixed UTC-8 (PST). */ +const PACIFIC_FALLBACK_OFFSET_MS = 8 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; +const LIMITED_PROJECT_DAILY_LIMIT_MESSAGE = +"your region is limited to 1 project per day"; + +/** + * Reads the geo-derived access tier from the Convex JWT. The claim is set by + * the Next.js convex-token route (the only place request headers/IP are + * visible) and refreshes with the token (<=10 min). A missing claim — e.g. a + * token minted before this deploy — is treated as full; it self-corrects on + * the next refresh. + */ +export async function getWebAccessTier( + ctx: QueryCtx | MutationCtx, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + const tier = (identity as Record | null)?.access_tier; + if (tier === "limited" || tier === "blocked") return tier; + return "full"; +} + +function pacificDayParts(now: number): { + dayKey: string; + msIntoDay: number; +} { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: PACIFIC_TIMEZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(new Date(now)); + const get = (type: string) => + parts.find((part) => part.type === type)?.value ?? "00"; + const dayKey = `${get("year")}-${get("month")}-${get("day")}`; + const msIntoDay = + (Number(get("hour")) * 3600 + + Number(get("minute")) * 60 + + Number(get("second"))) * + 1000; + return { dayKey, msIntoDay }; + } catch { + const shifted = new Date(now - PACIFIC_FALLBACK_OFFSET_MS); + return { + dayKey: shifted.toISOString().slice(0, 10), + msIntoDay: + (shifted.getUTCHours() * 3600 + + shifted.getUTCMinutes() * 60 + + shifted.getUTCSeconds()) * + 1000, + }; + } +} + +export function getPacificDayKey(now: number): string { + return pacificDayParts(now).dayKey; +} + +/** Approximate ms until the next midnight Pacific (DST transition days are + * off by up to an hour, which only affects the displayed retry time). */ +export function msUntilNextPacificMidnight(now: number): number { + return Math.max(0, DAY_MS - pacificDayParts(now).msIntoDay); +} + +async function countProjectsCreatedTodayForOwner( + ctx: QueryCtx | MutationCtx, + userId: Id<"users">, + dayKey: string, +): Promise { + const memberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", userId)) + .collect(); + + const ownerProjectIds = Array.from( + new Set( + memberships + .filter((membership) => membership.project_role === "owner") + .map((membership) => membership.project), + ), + ); + + if (ownerProjectIds.length === 0) return 0; + + const ownedProjects = await Promise.all( + ownerProjectIds.map((projectId) => ctx.db.get(projectId)), + ); + + let projectsCreatedToday = 0; + for (const project of ownedProjects) { + if (!project) continue; + if (getPacificDayKey(project._creationTime) !== dayKey) continue; + projectsCreatedToday += 1; + } + + return projectsCreatedToday; +} + +export type LimitedProjectCreationStatus = { + dailyLimit: number; + projectsCreatedToday: number; + projectsRemaining: number; + resetsInMs: number; +}; + +/** Read-only view of the limited-tier project-creation quota for UI display. */ +export async function getLimitedProjectCreationStatus( + ctx: QueryCtx | MutationCtx, + userId: Id<"users">, +): Promise { + const now = Date.now(); + const dayKey = getPacificDayKey(now); + const projectsCreatedToday = await countProjectsCreatedTodayForOwner( + ctx, + userId, + dayKey, + ); + + return { + dailyLimit: FREEBUFF_WEB_LIMITED_PROJECT_DAILY_LIMIT, + projectsCreatedToday, + projectsRemaining: Math.max( + 0, + FREEBUFF_WEB_LIMITED_PROJECT_DAILY_LIMIT - projectsCreatedToday, + ), + resetsInMs: msUntilNextPacificMidnight(now), + }; +} + +export type LimitedProjectGateResult = + | { success: true } + | { + success: false; + error: { kind: string; retryAfter: number; message: string }; + }; + +/** + * Limited-tier project quota: outer-region users may create at most + * FREEBUFF_WEB_LIMITED_PROJECT_DAILY_LIMIT projects per Pacific day. + */ +export async function checkLimitedProjectCreationGate( + ctx: MutationCtx, + userId: Id<"users">, +): Promise { + const status = await getLimitedProjectCreationStatus(ctx, userId); + if (status.projectsCreatedToday < status.dailyLimit) { + return { success: true }; + } + + return { + success: false, + error: { + kind: "LimitedProjectRateLimited", + retryAfter: status.resetsInMs, + message: LIMITED_PROJECT_DAILY_LIMIT_MESSAGE, + }, + }; +} + +export type LimitedSessionGateResult = + | { success: true } + | { + success: false; + error: { kind: string; retryAfter: number; message: string }; + }; + +/** + * Limited-tier session quota: an active session (started within the last + * hour) admits the message for free; otherwise a new 1-hour session starts, + * capped at FREEBUFF_WEB_LIMITED_SESSION_LIMIT starts per Pacific day. All + * reads are point-indexed and bounded (at most LIMIT rows per user per day). + */ +export async function checkLimitedSessionGate( + ctx: MutationCtx, + userId: Id<"users">, +): Promise { + const now = Date.now(); + + const activeSession = await ctx.db + .query("web_limited_sessions") + .withIndex("by_user_and_expires", (q) => + q.eq("user_id", userId).gt("expires_at", now), + ) + .first(); + if (activeSession) return { success: true }; + + const dayKey = getPacificDayKey(now); + const sessionsToday = await ctx.db + .query("web_limited_sessions") + .withIndex("by_user_and_day", (q) => + q.eq("user_id", userId).eq("day", dayKey), + ) + .collect(); + + if (sessionsToday.length >= FREEBUFF_WEB_LIMITED_SESSION_LIMIT) { + const retryAfter = msUntilNextPacificMidnight(now); + return { + success: false, + error: { + kind: "LimitedRateLimited", + retryAfter, + message: `You've used all ${FREEBUFF_WEB_LIMITED_SESSION_LIMIT} one-hour sessions for today. Your sessions reset at midnight Pacific time.`, + }, + }; + } + + await ctx.db.insert("web_limited_sessions", { + user_id: userId, + started_at: now, + expires_at: now + FREEBUFF_WEB_LIMITED_SESSION_LENGTH_MS, + day: dayKey, + }); + return { success: true }; +} + +export type LimitedSessionStatus = { + sessionsRemaining: number; + activeSessionExpiresAt: number | null; + resetsInMs: number; +}; + +/** Read-only view of the limited-tier quota for UI display. */ +export async function getLimitedSessionStatus( + ctx: QueryCtx, + userId: Id<"users">, +): Promise { + const now = Date.now(); + + const activeSession = await ctx.db + .query("web_limited_sessions") + .withIndex("by_user_and_expires", (q) => + q.eq("user_id", userId).gt("expires_at", now), + ) + .first(); + + const dayKey = getPacificDayKey(now); + const sessionsToday = await ctx.db + .query("web_limited_sessions") + .withIndex("by_user_and_day", (q) => + q.eq("user_id", userId).eq("day", dayKey), + ) + .collect(); + + return { + sessionsRemaining: Math.max( + 0, + FREEBUFF_WEB_LIMITED_SESSION_LIMIT - sessionsToday.length, + ), + activeSessionExpiresAt: activeSession?.expires_at ?? null, + resetsInMs: msUntilNextPacificMidnight(now), + }; +} diff --git a/freebuff/web/convex/coding_agent/shared/triggerGates.ts b/freebuff/web/convex/coding_agent/shared/triggerGates.ts new file mode 100644 index 0000000000..f4dd8950a8 --- /dev/null +++ b/freebuff/web/convex/coding_agent/shared/triggerGates.ts @@ -0,0 +1,300 @@ +import type { MutationCtx } from "../../_generated/server"; +import type { Id } from "../../_generated/dataModel"; +import { internal } from "../../_generated/api"; +import { getAuthUser, getQualifiedReferralCount } from "../../users"; +import { getVerifiedAccessProject } from "../../project"; +import { + checkUserRateLimit, + checkPremiumModelRateLimit, + checkStandardModelRateLimit, + checkFreebuffRateLimit, +} from "../rateLimiter"; +import { + isFreebuffPremiumModelId, + isFreebuffWebGeoExemptModelId, + resolveFreebuffModel, + resolveFreebuffWebModelForLimitedTier, +} from "@codebuff/common/constants/freebuff-models"; +import { checkLimitedSessionGate, getWebAccessTier } from "./geoAccess"; +import { + readActiveProjectConflict, + refreshActiveProjectSlot, +} from "../../active_session"; + +import type { FreebuffWebAccessTier } from "@codebuff/common/constants/freebuff-models"; + +type User = NonNullable>>; +type Project = NonNullable< + Awaited> +>; + +interface GateSuccess { + ok: true; + user: User; + project: Project; + isPlatformAdmin: boolean; + isSelfHosted: boolean; + accessTier: FreebuffWebAccessTier; + /** Freebuff model after tier enforcement. Limited-tier users get premium / + * unknown selections coerced to a limited-tier model (mirrors the CLI). */ + freebuffModel?: string; +} + +interface GateFailure { + ok: false; + error: { kind: string; retryAfter?: number; message?: string }; +} + +export type GateResult = GateSuccess | GateFailure; + +interface GateArgs { + ctx: MutationCtx; + message: string; + projectSemanticIdentifier?: string; + projectId?: Id<"project">; + skipRateLimitCheck?: boolean; + agentType?: "Claude Code" | "Codex" | "Gemini CLI" | "Freebuff"; + /** Selected open-source model id (Freebuff only). Premium ids carry an extra + * daily quota on top of the normal per-message rate limit. */ + freebuffModel?: string; +} + +function getRateLimitKeyForUser(user: User): string { + // The React hook API keys rate limits by ctx.auth.getUserIdentity().subject. + // Store that same value on users as freebuff_user_id/clerk_id, and consume the + // server-side buckets with it so UI counters decrement when messages send. + return user.freebuff_user_id ?? user.clerk_id ?? user._id; +} + +/** Inputs for the gate checks that do NOT require a JWT — everything is + * resolved from the DB (or a caller-supplied policy value). The human path + * resolves these from `ctx.auth` in `runTriggerGates`; the automation path + * (convex/automations.ts) resolves user/project from the row, reads + * `referralCount` from the users table, and passes `accessTier: "full"` so the + * geo/session gates are skipped while rate-limit/quota/pause still apply. */ +export interface ResolvedGateArgs { + ctx: MutationCtx; + user: User; + /** Already access-verified project (caller resolves + checks ownership). */ + project: Project; + agentType?: "Claude Code" | "Codex" | "Gemini CLI" | "Freebuff"; + freebuffModel?: string; + /** Web referral score: from the JWT for human sends, or + * `users.qualified_referral_count` for automations. Scales the daily quota. */ + referralCount: number; + /** Geo tier: from the JWT for human sends, or `"full"` for automations + * (geo is decided once at automation-create time, not re-checked per fire). */ + accessTier: FreebuffWebAccessTier; + skipRateLimitCheck?: boolean; +} + +/** + * The gate checks that key off the DB-resolved user/project/tier/referral — + * rate limit, deployments-paused, daily model quota, and (for limited tier) the + * session pool. Shared by the human path (`runTriggerGates`) and the automation + * path. Contains no JWT access; the caller supplies `referralCount`/`accessTier`. + */ +export async function runResolvedGates( + args: ResolvedGateArgs, +): Promise { + const { ctx, user, project } = args; + + const isGodRole = user.role === "god"; + const isPlatformAdmin = isGodRole || user.role === "admin"; + const rateLimitKey = getRateLimitKeyForUser(user); + + if (args.accessTier === "blocked") { + return { + ok: false, + error: { + kind: "GeoBlocked", + message: + "Access from anonymous networks (Tor, residential proxies) is not supported. Please disable any VPN or proxy and try again.", + }, + }; + } + + // Limited-region users may only use the geo-exempt free model set; coerce + // anything else (premium ids, stale localStorage selections) before model + // quota checks. This means referral tiers still raise limited regions' + // standard/free-model daily quota, but never unlock premium-model usage + // there. Full/allowed regions keep both standard and premium tier scaling. + const freebuffModel = + args.agentType === "Freebuff" + ? args.accessTier === "limited" + ? resolveFreebuffWebModelForLimitedTier(args.freebuffModel) + : resolveFreebuffModel(args.freebuffModel) + : args.freebuffModel; + + if (!args.skipRateLimitCheck && !isGodRole) { + // Freebuff agent chat is gated by its own stricter bucket + // (20 messages / 3 hours, full burst). All other paths — project + // creation and the legacy/non-Freebuff agents — keep using the + // shared userMessages bucket so their behavior is unchanged. + const rl = + args.agentType === "Freebuff" + ? await checkFreebuffRateLimit(ctx, rateLimitKey) + : await checkUserRateLimit(ctx, rateLimitKey); + if (!rl.success) return { ok: false, error: rl.error }; + } + + const selfHostedConnection = await ctx.runQuery( + internal.convex_oauth.connections.getConnectionByProjectId, + { projectId: project._id }, + ); + const isSelfHosted = !!selfHostedConnection; + + const isCodexWithChatGptAuth = + args.agentType === "Codex" && user.codex_auth_mode === "chatgpt"; + + if (!isPlatformAdmin && !isSelfHosted && !isCodexWithChatGptAuth) { + const pauseStatus = await ctx.runQuery( + internal.deployment_queries.getUserPauseStatusInternal, + { userId: user._id }, + ); + if (pauseStatus) { + await ctx.scheduler.runAfter( + 0, + internal.deployment_helpers.checkAndUnpauseUser, + { userId: user._id }, + ); + return { + ok: false, + error: { + kind: "DEPLOYMENTS_PAUSED", + message: + "Your Convex deployments are paused. Please add more Convex credits to continue. If you just added credits, please try again in a few moments.", + }, + }; + } + } + + // Every Freebuff send consumes a daily model quota whose rate scales with + // the user's referral tier: premium models burn the strict premium bucket, + // everything else burns the standard/free bucket. Limited-region sends have + // already been coerced to the allowed free model set above, so referrals only + // scale the free-model bucket for India/other limited regions. + // Done late so we only consume the allowance once the other gates pass. + if (!args.skipRateLimitCheck && !isGodRole && args.agentType === "Freebuff") { + const daily = isFreebuffPremiumModelId(freebuffModel) + ? await checkPremiumModelRateLimit( + ctx, + rateLimitKey, + args.referralCount, + ) + : await checkStandardModelRateLimit( + ctx, + rateLimitKey, + args.referralCount, + ); + if (!daily.success) return { ok: false, error: daily.error }; + } + + // Limited tier: 5 one-hour sessions per Pacific day. Done last so a send + // that fails an earlier gate never consumes a session start. Geo-exempt + // Freebuff models (DeepSeek V4 Flash, MiMo 2.5) bypass the session pool + // entirely — limited regions get unlimited usage on those. + const isGeoExemptFreebuffSend = + args.agentType === "Freebuff" && isFreebuffWebGeoExemptModelId(freebuffModel); + if ( + !args.skipRateLimitCheck && + args.accessTier === "limited" && + !isGeoExemptFreebuffSend + ) { + const session = await checkLimitedSessionGate(ctx, user._id); + if (!session.success) return { ok: false, error: session.error }; + } + + return { + ok: true, + user, + project, + isPlatformAdmin, + isSelfHosted, + accessTier: args.accessTier, + freebuffModel, + }; +} + +/** + * Human-send gate: resolves the user, geo tier, referral score, and project + * from the request's JWT (`ctx.auth`), then delegates the actual checks to + * `runResolvedGates`. Behavior is unchanged from the original single function. + */ +export async function runTriggerGates(args: GateArgs): Promise { + const user = await getAuthUser(args.ctx); + if (!user) { + return { + ok: false, + error: { kind: "AUTH_ERROR", message: "User not found" }, + }; + } + + const identity = await args.ctx.auth.getUserIdentity(); + const qualifiedReferralCount = getQualifiedReferralCount(identity, user); + + // Geographic access tier, resolved by the Next.js token route and carried + // as a tamper-proof JWT claim. God-role users are exempt from all geo + // enforcement — checked against the users row, so it can't be spoofed. + const isGodRole = user.role === "god"; + const accessTier: FreebuffWebAccessTier = + isGodRole ? "full" : await getWebAccessTier(args.ctx); + + const project = await getVerifiedAccessProject( + args.ctx, + user._id, + args.projectSemanticIdentifier, + args.projectId, + ); + if (!project) { + return { + ok: false, + error: { + kind: "PROJECT_NOT_FOUND", + message: "Project not found or access denied", + }, + }; + } + + // One active project/agent at a time per user (backstop for the seamless + // take-over UI). If a *different* project is actively held, block this send; + // the client shows a "take over" prompt that pauses the other project. + if (!isGodRole) { + const conflict = await readActiveProjectConflict( + args.ctx, + user._id, + project._id, + ); + if (conflict) { + return { + ok: false, + error: { + kind: "CONCURRENT_SESSION", + message: + "Freebuff is already running in another project. Take over here to continue — this pauses the other project.", + }, + }; + } + } + + const result = await runResolvedGates({ + ctx: args.ctx, + user, + project, + agentType: args.agentType, + freebuffModel: args.freebuffModel, + referralCount: qualifiedReferralCount, + accessTier, + skipRateLimitCheck: args.skipRateLimitCheck, + }); + + // Claim/refresh the active slot for this project once the send is allowed, so + // the lock is server-authoritative even if a client never heartbeats. + if (result.ok && !isGodRole) { + const surface = + project.project_type === "connected_repo" ? "cloud" : "web"; + await refreshActiveProjectSlot(args.ctx, user._id, project._id, surface); + } + + return result; +} diff --git a/freebuff/web/convex/coding_agent/terminationThrottle.ts b/freebuff/web/convex/coding_agent/terminationThrottle.ts new file mode 100644 index 0000000000..1e49744be8 --- /dev/null +++ b/freebuff/web/convex/coding_agent/terminationThrottle.ts @@ -0,0 +1,29 @@ +import { ActionCtx } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { Id } from "../_generated/dataModel"; + +/** + * Throttles repeated termination checks inside a single long-running action. + * Reduces internalQuery fan-out on thread.checkIfProjectTerminated during agent loops. + */ +export function createTerminationQueryThrottler( + projectId: Id<"project">, + intervalMs = 2500, +) { + let lastAt = 0; + let lastValue = false; + let hasQueried = false; + + return async (ctx: ActionCtx): Promise => { + const now = Date.now(); + if (hasQueried && now - lastAt < intervalMs) { + return lastValue; + } + hasQueried = true; + lastAt = now; + lastValue = await ctx.runQuery(internal.thread.checkIfProjectTerminated, { + projectId, + }); + return lastValue; + }; +} diff --git a/freebuff/web/convex/coding_agent/trigger.ts b/freebuff/web/convex/coding_agent/trigger.ts new file mode 100644 index 0000000000..d439b85922 --- /dev/null +++ b/freebuff/web/convex/coding_agent/trigger.ts @@ -0,0 +1,404 @@ +import { + internalAction, + internalMutation, + mutation, +} from "!/_generated/server"; +import { createNewThread } from "!/thread"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { agentModeValidator } from "../utils/registry_validators"; +import { contextLengthValidator } from "./config/contextLengthPresets"; +import { runTriggerGates } from "./shared/triggerGates"; +import type { AgentMode } from "../utils/registry_validators"; + +const FRONTEND_AGENT_MODES = [ + "POWERFUL", + "EFFICIENT", + "PRECISE", + "CHEAP", + "STANDARD", + "OPUS", + "PLANNING", + // Legacy aliases kept valid so old stored values continue to work. + "EXPENSIVE", + "ULTRA_CHEAP", +] as const; + +function normalizeAgentMode(agentMode?: AgentMode): AgentMode { + switch (agentMode) { + case "EXPENSIVE": + return "POWERFUL"; + case "ULTRA_CHEAP": + return "CHEAP"; + case "MINIMAX": + return "STANDARD"; + default: + return agentMode ?? "POWERFUL"; + } +} + +function truncateForMessageInsert( + value: string, + maxBytes: number, + fieldName: string, +) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(value); + if (bytes.length <= maxBytes) { + return value; + } + + console.warn( + `[Trigger] Truncating ${fieldName} before insert: ${(bytes.length / 1024).toFixed(1)} KB -> ${(maxBytes / 1024).toFixed(1)} KB`, + ); + + const decoder = new TextDecoder(); + const headBytes = Math.floor(maxBytes * 0.8); + const tailBytes = Math.floor(maxBytes * 0.2); + return [ + decoder.decode(bytes.slice(0, headBytes)), + `\n\n[${fieldName} truncated]\n\n`, + decoder.decode(bytes.slice(-tailBytes)), + ].join(""); +} + +// saves the user message and starts the workflow +export const saveMessageAndStartWorkflow = mutation({ + args: { + projectSemanticIdentifier: v.optional(v.string()), + message: v.string(), + displayMessage: v.optional(v.string()), + agentMode: v.optional(agentModeValidator), // Optional agent mode selection + contextLength: v.optional(contextLengthValidator), // Optional context length preset (small, medium, long) + projectId: v.optional(v.id("project")), + images: v.optional(v.array(v.id("_storage"))), + tempPageContext: v.optional(v.string()), // Current page URL for temporary injection + + // THIS IS NOT SECURE + _skipRateLimitCheck: v.optional(v.boolean()), // Internal use only - skip rate limiting for internal calls + }, + returns: v.union( + v.object({ success: v.literal(true) }), + v.object({ + success: v.literal(false), + error: v.object({ + kind: v.string(), + retryAfter: v.optional(v.number()), + message: v.optional(v.string()), + }), + }), + ), + handler: async (ctx, args) => { + void ctx; + void args; + + // Legacy chats are retired and read-only. The UI no longer renders an input + // for these threads; this is the server-side enforcement so the deprecated + // agent can never be invoked, even via a direct/stale client call. + return { + success: false as const, + error: { + kind: "LEGACY_AGENT_DISABLED", + message: + "This chat uses the retired legacy agent and is now read-only. Start a new thread on the Freebuff agent to continue.", + }, + }; + }, +}); + +// Internal action to create checkpoint, update message, then start agent cycle +export const createCheckpointThenStartCycle = internalAction({ + args: { + projectId: v.id("project"), + messageId: v.id("messages"), + message: v.string(), + agentMode: v.optional(agentModeValidator), + contextLength: v.optional(contextLengthValidator), + executingUserIsPlatformAdmin: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + console.log( + `[Checkpoint] Starting checkpoint creation for message ${args.messageId}`, + ); + try { + // Create checkpoint using the centralized API + console.log( + `[Checkpoint] Calling createCheckpoint with projectId: ${args.projectId}, message: "${args.message}"`, + ); + const checkpointResult = await ctx.runAction( + internal.versioning.checkpoint.createCheckpoint, + { + projectId: args.projectId, + message: args.message, + messageId: args.messageId, + }, + ); + console.log(`[Checkpoint] createCheckpoint returned:`, checkpointResult); + + // Update the message with the checkpoint hash if successful + if (checkpointResult.success && checkpointResult.checkpointId) { + await ctx.runMutation( + internal.coding_agent.trigger.updateMessageCheckpoint, + { + messageId: args.messageId, + checkpointId: checkpointResult.checkpointId, + }, + ); + console.log( + `[Checkpoint] Updated message ${args.messageId} with checkpoint ${checkpointResult.checkpointId}`, + ); + } else if (checkpointResult.error) { + // Check if it's a retry scenario + if (checkpointResult.error.includes("retrying")) { + console.log( + `[Checkpoint] Checkpoint creation is being retried for message ${args.messageId}`, + ); + // Keep the "creating" state during retries + // The retry will update the state when it succeeds or ultimately fails + } else { + console.error( + `[Checkpoint] Failed to create checkpoint: ${checkpointResult.error}`, + ); + console.log( + `[Checkpoint] Setting message ${args.messageId} to failed state`, + ); + // Set failed state only for permanent failures + await ctx.runMutation( + internal.coding_agent.trigger.updateMessageCheckpoint, + { + messageId: args.messageId, + checkpointId: "failed", + }, + ); + console.log( + `[Checkpoint] Message ${args.messageId} set to failed state`, + ); + } + } else { + console.error( + "[Checkpoint] Unexpected result from createCheckpoint:", + checkpointResult, + ); + console.log( + `[Checkpoint] Setting message ${args.messageId} to failed state due to unexpected result`, + ); + // Set failed state for unexpected results + await ctx.runMutation( + internal.coding_agent.trigger.updateMessageCheckpoint, + { + messageId: args.messageId, + checkpointId: "failed", + }, + ); + console.log( + `[Checkpoint] Message ${args.messageId} set to failed state`, + ); + } + } catch (error) { + console.error( + "[Checkpoint] Exception during checkpoint creation:", + error, + ); + console.error( + "[Checkpoint] Error stack:", + error instanceof Error ? error.stack : "No stack trace", + ); + // Set failed state if there's an exception + try { + console.log( + `[Checkpoint] Setting message ${args.messageId} to failed state due to exception`, + ); + await ctx.runMutation( + internal.coding_agent.trigger.updateMessageCheckpoint, + { + messageId: args.messageId, + checkpointId: "failed", + }, + ); + console.log( + `[Checkpoint] Message ${args.messageId} set to failed state after exception`, + ); + } catch (updateError) { + console.error( + "[Checkpoint] Failed to set error state after exception:", + updateError, + ); + } + } + + // Start agent cycle AFTER checkpoint is created (or failed) + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + console.error(`[Checkpoint] Project not found: ${args.projectId}`); + return; + } + + console.log(`[Checkpoint] Starting agent cycle after checkpoint creation`); + await ctx.scheduler.runAfter( + 0, + internal.coding_agent.cycle.primaryAgenticCycle, + { + project: project, + agentMode: args.agentMode, + contextLength: args.contextLength, + cycleCount: 0, + executingUserIsPlatformAdmin: args.executingUserIsPlatformAdmin, + }, + ); + }, +}); + +// Internal mutation to update message with checkpoint ID +export const updateMessageCheckpoint = internalMutation({ + args: { + messageId: v.id("messages"), + checkpointId: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + commit_hash: args.checkpointId, + }); + }, +}); + +// TODO: FIX THIS +export const internal_saveMessageAndStartWorkflow = internalMutation({ + args: { + message: v.string(), + displayMessage: v.optional(v.string()), + agentMode: v.optional(agentModeValidator), + contextLength: v.optional(contextLengthValidator), + projectId: v.id("project"), + images: v.optional(v.array(v.id("_storage"))), + tempPageContext: v.optional(v.string()), // Current page URL for temporary injection + executingUserIsPlatformAdmin: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + let threadId = project.active_thread; + let isFirstMessage = false; + + if (!threadId) { + // create new thread + threadId = await createNewThread(ctx, project._id); + isFirstMessage = true; + } else { + // Check if this is the first message in an existing thread + const existingMessages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", threadId).eq("streaming", false), + ) + .first(); + isFirstMessage = !existingMessages; + } + + // Create a checkpoint before processing (except for the first message) + // We'll schedule this immediately after inserting the message + const shouldCreateCheckpoint = !isFirstMessage; + const normalizedAgentMode = normalizeAgentMode(args.agentMode); + const storedMessage = truncateForMessageInsert( + args.message, + 220 * 1024, + "message", + ); + const storedDisplayMessage = args.displayMessage + ? truncateForMessageInsert( + args.displayMessage, + 40 * 1024, + "display message", + ) + : undefined; + const storedPageContext = args.tempPageContext + ? truncateForMessageInsert(args.tempPageContext, 8 * 1024, "page context") + : undefined; + + // insert user message with a temporary checkpoint indicator for non-first messages + const userMessageId = await ctx.db.insert("messages", { + project_id: project._id, + role: "user", + content: storedMessage, + ...(storedDisplayMessage && { core_message: storedDisplayMessage }), + date: Date.now(), + selected_entry_point_ids: [], + thread_id: threadId, + streaming: false, + deactivated: false, + ...(args.images && { images: args.images }), + ...(storedPageContext && { pageContext: storedPageContext }), + // Set temporary commit hash for non-first messages to enable undo button + ...(shouldCreateCheckpoint && { commit_hash: "creating" }), + }); + + await ctx.scheduler.runAfter( + 0, + internal.coding_agent.helpers.fastReturn.saveFastReturnPreview, + { + messageId: userMessageId, + message: storedDisplayMessage ?? storedMessage, + }, + ); + + // Create checkpoint and start agent cycle + // For non-first messages, checkpoint must be created BEFORE agent can modify files + // For first messages, start agent cycle immediately + if (shouldCreateCheckpoint) { + await ctx.scheduler.runAfter( + 0, + internal.coding_agent.trigger.createCheckpointThenStartCycle, + { + projectId: project._id, + messageId: userMessageId, + message: `Before: ${storedMessage.slice(0, 100)}${storedMessage.length > 100 ? "..." : ""}`, + agentMode: normalizedAgentMode, + contextLength: args.contextLength, + executingUserIsPlatformAdmin: args.executingUserIsPlatformAdmin, + }, + ); + } else { + await ctx.scheduler.runAfter( + 0, + internal.coding_agent.cycle.primaryAgenticCycle, + { + project: project, + agentMode: normalizedAgentMode, + contextLength: args.contextLength, + cycleCount: 0, + executingUserIsPlatformAdmin: args.executingUserIsPlatformAdmin, + }, + ); + } + + console.log("[JulyAgent] Started cycle"); + + await ctx.db.patch(project._id, { + terminated: false, + state: "processing", + }); + + if (project.active_thread) { + console.log("[JulyAgent] Scheduling thread namer"); + await ctx.scheduler.runAfter( + 0, + internal.coding_agent.helpers.thread_namer.nameThread, + { + threadId: project.active_thread, + }, + ); + } else { + console.log("[JulyAgent] No active thread found"); + } + + console.log("[JulyAgent] Updated project state to processing"); + }, +}); diff --git a/freebuff/web/convex/community.ts b/freebuff/web/convex/community.ts new file mode 100644 index 0000000000..2940c787e2 --- /dev/null +++ b/freebuff/web/convex/community.ts @@ -0,0 +1,2104 @@ +import { v } from "convex/values"; +import { + query, + mutation, + internalMutation, + QueryCtx, + MutationCtx, +} from "./_generated/server"; +import { internal } from "./_generated/api"; +import { Doc, Id } from "./_generated/dataModel"; +import { signedInUser } from "!/users"; + +// Helper function to get the screenshot URL for a post +// Prefers fresh URL from storage ID, falls back to stored URL +async function getPostScreenshotUrl( + ctx: QueryCtx, + post: { + screenshotStorageId?: Id<"_storage">; + screenshotUrl?: string; + }, +): Promise { + // If we have a storage ID, generate a fresh URL + if (post.screenshotStorageId) { + const freshUrl = await ctx.storage.getUrl(post.screenshotStorageId); + if (freshUrl) { + return freshUrl; + } + } + // Fall back to stored URL + return post.screenshotUrl; +} + +async function getCurrentCommunityUser( + ctx: QueryCtx | MutationCtx, +): Promise | null> { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) return null; + + const byFreebuffId = await ctx.db + .query("users") + .withIndex("by_freebuff_user_id", (q) => + q.eq("freebuff_user_id", identity.subject), + ) + .unique(); + + if (byFreebuffId) return byFreebuffId; + + const byClerkId = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerk_id", identity.subject)) + .unique(); + + if (byClerkId) return byClerkId; + + if (identity.email) { + return await ctx.db + .query("users") + .withIndex("by_email", (q) => + q.eq("email", identity.email!.trim().toLowerCase()), + ) + .first(); + } + + return null; +} + +async function requireCurrentCommunityUser( + ctx: MutationCtx, +): Promise> { + const existingUser = await getCurrentCommunityUser(ctx); + if (existingUser) return existingUser; + + const userId = await signedInUser(ctx); + const user = await ctx.db.get(userId); + if (!user) { + throw new Error("User not found"); + } + return user; +} + +// ============================================ +// POSTS +// ============================================ + +// Public community URLs for sitemap generation. +export const getPublicPostsForSitemap = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.object({ + posts: v.array( + v.object({ + _id: v.id("community_posts"), + userId: v.id("users"), + updatedAt: v.number(), + }), + ), + users: v.array( + v.object({ + _id: v.id("users"), + updatedAt: v.number(), + }), + ), + }), + handler: async (ctx, args) => { + const limit = args.limit || 500; + const posts = await ctx.db + .query("community_posts") + .withIndex("by_published_at") + .order("desc") + .take(limit * 2); + + const publicPosts = posts + .filter((post) => post.isPublic !== false) + .slice(0, limit); + + const usersById = new Map, number>(); + for (const post of publicPosts) { + const timestamp = post.updatedAt ?? post.publishedAt; + usersById.set( + post.userId, + Math.max(usersById.get(post.userId) ?? 0, timestamp), + ); + } + + return { + posts: publicPosts.map((post) => ({ + _id: post._id, + userId: post.userId, + updatedAt: post.updatedAt ?? post.publishedAt, + })), + users: Array.from(usersById.entries()).map(([userId, updatedAt]) => ({ + _id: userId, + updatedAt, + })), + }; + }, +}); + +// Publish a project to the community +export const publishProject = mutation({ + args: { + projectId: v.id("project"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + }, + returns: v.id("community_posts"), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + // Get project info + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + // Require deployment before publishing to community + if (!project.prod_deployment_slug) { + throw new Error( + "Project must be deployed before publishing to community", + ); + } + + // Use the deployed URL (not the dev URL) + const deployedUrl = `https://${project.prod_deployment_slug}.freebuff.app`; + + // Get screenshot URL if available (screenshot_r2_url is already a public URL) + let screenshotUrl: string | undefined; + if (project.screenshot_r2_url) { + screenshotUrl = project.screenshot_r2_url; + } + + // Check if already published + const existingPost = await ctx.db + .query("community_posts") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .unique(); + + if (existingPost) { + // Update existing post with the current deployed URL and screenshot + await ctx.db.patch(existingPost._id, { + title: args.title, + description: args.description, + tags: args.tags, + previewUrl: deployedUrl, + screenshotUrl, + updatedAt: Date.now(), + }); + return existingPost._id; + } + + // Create new post + const postId = await ctx.db.insert("community_posts", { + projectId: args.projectId, + userId: user._id, + title: args.title, + description: args.description, + tags: args.tags, + previewUrl: deployedUrl, + screenshotUrl, + likesCount: 0, + commentsCount: 0, + viewsCount: 0, + isPublic: true, // Public by default + publishedAt: Date.now(), + }); + + // Update user's posts count + const profile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .unique(); + + if (profile) { + await ctx.db.patch(profile._id, { + postsCount: profile.postsCount + 1, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("community_profiles", { + userId: user._id, + followersCount: 0, + followingCount: 0, + postsCount: 1, + totalLikesReceived: 0, + updatedAt: Date.now(), + }); + } + + return postId; + }, +}); + +// Get featured posts for the explore page +export const getFeaturedPosts = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 10; + + const posts = await ctx.db + .query("community_posts") + .withIndex("by_featured") + .order("desc") + .take(limit * 2); // Fetch more to account for filtered private posts + + // Filter out private posts + const publicPosts = posts + .filter((p) => p.isPublic !== false) + .slice(0, limit); + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedPosts = await Promise.all( + publicPosts.map(async (post) => { + const user = await ctx.db.get(post.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + // Sort by community badge tier (higher first), then by likes + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.likesCount - a.likesCount; + }); + + return enrichedPosts; + }, +}); + +// Get explore feed (recent posts) +export const getExplorePosts = query({ + args: { + limit: v.optional(v.number()), + cursor: v.optional(v.number()), + }, + returns: v.object({ + posts: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + nextCursor: v.union(v.number(), v.null()), + }), + handler: async (ctx, args) => { + const limit = args.limit || 20; + + let postsQuery = ctx.db + .query("community_posts") + .withIndex("by_published_at") + .order("desc"); + + if (args.cursor) { + postsQuery = postsQuery.filter((q) => + q.lt(q.field("publishedAt"), args.cursor!), + ); + } + + const posts = await postsQuery.take((limit + 1) * 2); // Fetch more to account for private posts + + // Filter out private posts + const publicPosts = posts.filter((p) => p.isPublic !== false); + const hasMore = publicPosts.length > limit; + const postsToReturn = hasMore ? publicPosts.slice(0, limit) : publicPosts; + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedPosts = await Promise.all( + postsToReturn.map(async (post) => { + const user = await ctx.db.get(post.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + // Sort by community badge tier (higher first), then by publishedAt + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.publishedAt - a.publishedAt; + }); + + return { + posts: enrichedPosts, + nextCursor: hasMore + ? postsToReturn[postsToReturn.length - 1].publishedAt + : null, + }; + }, +}); + +// Get trending posts (by likes) +export const getTrendingPosts = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 10; + + const posts = await ctx.db + .query("community_posts") + .withIndex("by_likes_count") + .order("desc") + .take(limit * 2); // Fetch more to account for private posts + + // Filter out private posts + const publicPosts = posts + .filter((p) => p.isPublic !== false) + .slice(0, limit); + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedPosts = await Promise.all( + publicPosts.map(async (post) => { + const user = await ctx.db.get(post.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + // Sort by community badge tier (higher first), then by likes + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.likesCount - a.likesCount; + }); + + return enrichedPosts; + }, +}); + +// Get a single post with details +export const getPost = query({ + args: { + postId: v.id("community_posts"), + }, + returns: v.union( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + isPublic: v.boolean(), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + isOwner: v.boolean(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const post = await ctx.db.get(args.postId); + if (!post) return null; + + const user = await ctx.db.get(post.userId); + let currentUser = null; + let hasLiked = false; + let isOwner = false; + + currentUser = await getCurrentCommunityUser(ctx); + if (currentUser) { + isOwner = currentUser._id === post.userId; + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser!._id), + ) + .unique(); + hasLiked = !!like; + } + + // Private posts are only visible to their owner + if (post.isPublic === false && !isOwner) { + return null; + } + + // Get the screenshot URL (fresh from storage ID if available) + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + isPublic: post.isPublic !== false, // defaults to true if undefined + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + isOwner, + }; + }, +}); + +// Search posts +export const searchPosts = query({ + args: { + searchQuery: v.string(), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 20; + + const posts = await ctx.db + .query("community_posts") + .withSearchIndex("search_posts", (q) => + q.search("title", args.searchQuery), + ) + .take(limit); + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedPosts = await Promise.all( + posts.map(async (post) => { + const user = await ctx.db.get(post.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + // Sort by community badge tier (higher first), then by likes + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.likesCount - a.likesCount; + }); + + return enrichedPosts; + }, +}); + +// ============================================ +// LIKES +// ============================================ + +// Like a post +export const likePost = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + // Check if already liked + const existingLike = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", args.postId).eq("userId", user._id), + ) + .unique(); + + if (existingLike) { + return false; + } + + // Add like + await ctx.db.insert("community_likes", { + postId: args.postId, + userId: user._id, + createdAt: Date.now(), + }); + + // Update post likes count + const post = await ctx.db.get(args.postId); + if (post) { + await ctx.db.patch(args.postId, { + likesCount: post.likesCount + 1, + }); + + // Update author's total likes received + const authorProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", post.userId)) + .unique(); + + if (authorProfile) { + await ctx.db.patch(authorProfile._id, { + totalLikesReceived: authorProfile.totalLikesReceived + 1, + updatedAt: Date.now(), + }); + } + } + + return true; + }, +}); + +// Unlike a post +export const unlikePost = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + // Find existing like + const existingLike = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", args.postId).eq("userId", user._id), + ) + .unique(); + + if (!existingLike) { + return false; + } + + // Remove like + await ctx.db.delete(existingLike._id); + + // Update post likes count + const post = await ctx.db.get(args.postId); + if (post) { + await ctx.db.patch(args.postId, { + likesCount: Math.max(0, post.likesCount - 1), + }); + + // Update author's total likes received + const authorProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", post.userId)) + .unique(); + + if (authorProfile) { + await ctx.db.patch(authorProfile._id, { + totalLikesReceived: Math.max(0, authorProfile.totalLikesReceived - 1), + updatedAt: Date.now(), + }); + } + } + + return true; + }, +}); + +// ============================================ +// COMMENTS +// ============================================ + +// Add a comment +export const addComment = mutation({ + args: { + postId: v.id("community_posts"), + content: v.string(), + parentCommentId: v.optional(v.id("community_comments")), + }, + returns: v.id("community_comments"), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const commentId = await ctx.db.insert("community_comments", { + postId: args.postId, + userId: user._id, + content: args.content, + parentCommentId: args.parentCommentId, + likesCount: 0, + createdAt: Date.now(), + }); + + // Update post comments count + const post = await ctx.db.get(args.postId); + if (post) { + await ctx.db.patch(args.postId, { + commentsCount: post.commentsCount + 1, + }); + } + + return commentId; + }, +}); + +// Get comments for a post +export const getComments = query({ + args: { + postId: v.id("community_posts"), + }, + returns: v.array( + v.object({ + _id: v.id("community_comments"), + postId: v.id("community_posts"), + userId: v.id("users"), + content: v.string(), + parentCommentId: v.optional(v.id("community_comments")), + likesCount: v.number(), + createdAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const comments = await ctx.db + .query("community_comments") + .withIndex("by_post", (q) => q.eq("postId", args.postId)) + .order("asc") + .collect(); + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedComments = await Promise.all( + comments.map(async (comment) => { + const user = await ctx.db.get(comment.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_comment_likes") + .withIndex("by_comment_and_user", (q) => + q.eq("commentId", comment._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + return { + _id: comment._id, + postId: comment.postId, + userId: comment.userId, + content: comment.content, + parentCommentId: comment.parentCommentId, + likesCount: comment.likesCount, + createdAt: comment.createdAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + hasLiked, + }; + }), + ); + + return enrichedComments; + }, +}); + +// Like a comment +export const likeComment = mutation({ + args: { + commentId: v.id("community_comments"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const existingLike = await ctx.db + .query("community_comment_likes") + .withIndex("by_comment_and_user", (q) => + q.eq("commentId", args.commentId).eq("userId", user._id), + ) + .unique(); + + if (existingLike) { + // Unlike + await ctx.db.delete(existingLike._id); + const comment = await ctx.db.get(args.commentId); + if (comment) { + await ctx.db.patch(args.commentId, { + likesCount: Math.max(0, comment.likesCount - 1), + }); + } + return false; + } else { + // Like + await ctx.db.insert("community_comment_likes", { + commentId: args.commentId, + userId: user._id, + createdAt: Date.now(), + }); + const comment = await ctx.db.get(args.commentId); + if (comment) { + await ctx.db.patch(args.commentId, { + likesCount: comment.likesCount + 1, + }); + } + return true; + } + }, +}); + +// ============================================ +// FOLLOWS +// ============================================ + +// Follow a user +export const followUser = mutation({ + args: { + userId: v.id("users"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const currentUser = await requireCurrentCommunityUser(ctx); + + if (currentUser._id === args.userId) { + throw new Error("Cannot follow yourself"); + } + + // Check if already following + const existingFollow = await ctx.db + .query("community_follows") + .withIndex("by_follower_and_following", (q) => + q.eq("followerId", currentUser._id).eq("followingId", args.userId), + ) + .unique(); + + if (existingFollow) { + return false; + } + + // Create follow + await ctx.db.insert("community_follows", { + followerId: currentUser._id, + followingId: args.userId, + createdAt: Date.now(), + }); + + // Update follower's following count + const followerProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", currentUser._id)) + .unique(); + + if (followerProfile) { + await ctx.db.patch(followerProfile._id, { + followingCount: followerProfile.followingCount + 1, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("community_profiles", { + userId: currentUser._id, + followersCount: 0, + followingCount: 1, + postsCount: 0, + totalLikesReceived: 0, + updatedAt: Date.now(), + }); + } + + // Update following user's followers count + const followingProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + + if (followingProfile) { + await ctx.db.patch(followingProfile._id, { + followersCount: followingProfile.followersCount + 1, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("community_profiles", { + userId: args.userId, + followersCount: 1, + followingCount: 0, + postsCount: 0, + totalLikesReceived: 0, + updatedAt: Date.now(), + }); + } + + return true; + }, +}); + +// Unfollow a user +export const unfollowUser = mutation({ + args: { + userId: v.id("users"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const currentUser = await requireCurrentCommunityUser(ctx); + + // Find existing follow + const existingFollow = await ctx.db + .query("community_follows") + .withIndex("by_follower_and_following", (q) => + q.eq("followerId", currentUser._id).eq("followingId", args.userId), + ) + .unique(); + + if (!existingFollow) { + return false; + } + + // Remove follow + await ctx.db.delete(existingFollow._id); + + // Update follower's following count + const followerProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", currentUser._id)) + .unique(); + + if (followerProfile) { + await ctx.db.patch(followerProfile._id, { + followingCount: Math.max(0, followerProfile.followingCount - 1), + updatedAt: Date.now(), + }); + } + + // Update following user's followers count + const followingProfile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + + if (followingProfile) { + await ctx.db.patch(followingProfile._id, { + followersCount: Math.max(0, followingProfile.followersCount - 1), + updatedAt: Date.now(), + }); + } + + return true; + }, +}); + +// ============================================ +// PROFILES +// ============================================ + +// Get current user's ID for profile navigation +export const getCurrentUserId = query({ + args: {}, + returns: v.union(v.id("users"), v.null()), + handler: async (ctx) => { + const user = await getCurrentCommunityUser(ctx); + + return user?._id ?? null; + }, +}); + +// Get user profile +export const getUserProfile = query({ + args: { + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("users"), + name: v.string(), + email: v.string(), + profileImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + bio: v.optional(v.string()), + website: v.optional(v.string()), + twitter: v.optional(v.string()), + github: v.optional(v.string()), + followersCount: v.number(), + followingCount: v.number(), + postsCount: v.number(), + totalLikesReceived: v.number(), + isFollowing: v.boolean(), + isOwnProfile: v.boolean(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user) return null; + + const profile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .unique(); + + let isFollowing = false; + let isOwnProfile = false; + + const currentUser = await getCurrentCommunityUser(ctx); + if (currentUser) { + isOwnProfile = currentUser._id === args.userId; + + if (!isOwnProfile) { + const follow = await ctx.db + .query("community_follows") + .withIndex("by_follower_and_following", (q) => + q.eq("followerId", currentUser._id).eq("followingId", args.userId), + ) + .unique(); + isFollowing = !!follow; + } + } + + return { + _id: user._id, + name: user.name, + email: user.email, + profileImage: user.profile_image, + isPaidUser: user.tier === "pro", + communityBadgeTier: user.community_badge_tier ?? 0, + bio: profile?.bio, + website: profile?.website, + twitter: profile?.twitter, + github: profile?.github, + followersCount: profile?.followersCount || 0, + followingCount: profile?.followingCount || 0, + postsCount: profile?.postsCount || 0, + totalLikesReceived: profile?.totalLikesReceived || 0, + isFollowing, + isOwnProfile, + }; + }, +}); + +// Get user's posts +export const getUserPosts = query({ + args: { + userId: v.id("users"), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + isPublic: v.boolean(), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 20; + + const posts = await ctx.db + .query("community_posts") + .withIndex("by_user", (q) => q.eq("userId", args.userId)) + .order("desc") + .take(limit); + + const user = await ctx.db.get(args.userId); + + const currentUser = await getCurrentCommunityUser(ctx); + + // Check if viewing own profile + const isOwnProfile = currentUser?._id === args.userId; + + // Filter out private posts for non-owners + const visiblePosts = isOwnProfile + ? posts + : posts.filter((p) => p.isPublic !== false); + + const enrichedPosts = await Promise.all( + visiblePosts.map(async (post) => { + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + isPublic: post.isPublic !== false, // defaults to true + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + return enrichedPosts; + }, +}); + +// Update profile +export const updateProfile = mutation({ + args: { + bio: v.optional(v.string()), + website: v.optional(v.string()), + twitter: v.optional(v.string()), + github: v.optional(v.string()), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const profile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .unique(); + + if (profile) { + await ctx.db.patch(profile._id, { + bio: args.bio, + website: args.website, + twitter: args.twitter, + github: args.github, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("community_profiles", { + userId: user._id, + bio: args.bio, + website: args.website, + twitter: args.twitter, + github: args.github, + followersCount: 0, + followingCount: 0, + postsCount: 0, + totalLikesReceived: 0, + updatedAt: Date.now(), + }); + } + + return true; + }, +}); + +// ============================================ +// LEADERBOARDS +// ============================================ + +// Get top creators (by total likes received) +export const getTopCreators = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("users"), + name: v.string(), + profileImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + followersCount: v.number(), + postsCount: v.number(), + totalLikesReceived: v.number(), + rank: v.number(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 10; + + const profiles = await ctx.db + .query("community_profiles") + .withIndex("by_total_likes") + .order("desc") + .take(limit); + + const creators = await Promise.all( + profiles.map(async (profile) => { + const user = await ctx.db.get(profile.userId); + return { + _id: profile.userId, + name: user?.name || "Anonymous", + profileImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + followersCount: profile.followersCount, + postsCount: profile.postsCount, + totalLikesReceived: profile.totalLikesReceived, + rank: 0, // Will be set after sorting + }; + }), + ); + + // Sort by community badge tier (higher first), then by total likes + creators.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.totalLikesReceived - a.totalLikesReceived; + }); + + // Assign ranks after sorting + return creators.map((creator, index) => ({ + ...creator, + rank: index + 1, + })); + }, +}); + +export const getCommunityProfilesByFreebuffUserIds = query({ + args: { + freebuffUserIds: v.array(v.string()), + }, + returns: v.array( + v.object({ + freebuffUserId: v.string(), + userId: v.optional(v.id("users")), + name: v.string(), + profileImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + followersCount: v.number(), + followingCount: v.number(), + postsCount: v.number(), + totalLikesReceived: v.number(), + }), + ), + handler: async (ctx, args) => { + const uniqueIds = Array.from(new Set(args.freebuffUserIds)).slice(0, 50); + + const profiles = await Promise.all( + uniqueIds.map(async (freebuffUserId) => { + const user = await ctx.db + .query("users") + .withIndex("by_freebuff_user_id", (q) => + q.eq("freebuff_user_id", freebuffUserId), + ) + .unique(); + + if (!user) { + return { + freebuffUserId, + name: "Anonymous", + isPaidUser: false, + communityBadgeTier: 0, + followersCount: 0, + followingCount: 0, + postsCount: 0, + totalLikesReceived: 0, + }; + } + + const profile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .unique(); + + return { + freebuffUserId, + userId: user._id, + name: user.name || "Anonymous", + profileImage: user.profile_image, + isPaidUser: user.tier === "pro", + communityBadgeTier: user.community_badge_tier ?? 0, + followersCount: profile?.followersCount || 0, + followingCount: profile?.followingCount || 0, + postsCount: profile?.postsCount || 0, + totalLikesReceived: profile?.totalLikesReceived || 0, + }; + }), + ); + + return profiles; + }, +}); + +// Get top projects (by likes) +export const getTopProjects = query({ + args: { + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + rank: v.number(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 10; + + const posts = await ctx.db + .query("community_posts") + .withIndex("by_likes_count") + .order("desc") + .take(limit); + + const enrichedPosts = await Promise.all( + posts.map(async (post) => { + const user = await ctx.db.get(post.userId); + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + rank: 0, // Will be set after sorting + }; + }), + ); + + // Sort by community badge tier (higher first), then by likes + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.likesCount - a.likesCount; + }); + + // Assign ranks after sorting + return enrichedPosts.map((post, index) => ({ + ...post, + rank: index + 1, + })); + }, +}); + +// ============================================ +// VIEWS +// ============================================ + +// Record a view +export const recordView = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getCurrentCommunityUser(ctx); + const userId = user?._id; + + // Only record view if user is logged in (to prevent spam) + if (userId) { + // Check if user already viewed recently (within last hour) + const recentView = await ctx.db + .query("community_views") + .withIndex("by_post_and_viewer", (q) => + q.eq("postId", args.postId).eq("viewerUserId", userId), + ) + .first(); + + if (!recentView || Date.now() - recentView.viewedAt > 3600000) { + await ctx.db.insert("community_views", { + postId: args.postId, + viewerUserId: userId, + viewedAt: Date.now(), + }); + + // Update post views count + const post = await ctx.db.get(args.postId); + if (post) { + await ctx.db.patch(args.postId, { + viewsCount: post.viewsCount + 1, + }); + } + } + } + + return null; + }, +}); + +// Get user's projects for publishing (shows deployment status) +export const getUnpublishedProjects = query({ + args: {}, + returns: v.array( + v.object({ + _id: v.id("project"), + name: v.optional(v.string()), + semanticIdentifier: v.string(), + previewUrl: v.optional(v.string()), + deployedUrl: v.optional(v.string()), + isPublished: v.boolean(), + hasDeployment: v.boolean(), + }), + ), + handler: async (ctx) => { + const user = await getCurrentCommunityUser(ctx); + + if (!user) { + return []; + } + + // Get user's projects + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(); + + const projects = await Promise.all( + projectMembers.map(async (member) => { + const project = await ctx.db.get(member.project); + if (!project || project.deleted) return null; + + // Check if already published + const existingPost = await ctx.db + .query("community_posts") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .unique(); + + // Use deployed URL if available, otherwise dev URL for preview + const hasDeployment = !!project.prod_deployment_slug; + const deployedUrl = hasDeployment + ? `https://${project.prod_deployment_slug}.freebuff.app` + : undefined; + + return { + _id: project._id, + name: project.name, + semanticIdentifier: project.semantic_identifier, + previewUrl: project.pretty_preview_url || project.preview_url, + deployedUrl, + isPublished: !!existingPost, + hasDeployment, + }; + }), + ); + + return projects.filter((p): p is NonNullable => p !== null); + }, +}); + +// Get community post by project ID +export const getPostByProject = query({ + args: { + projectId: v.id("project"), + }, + returns: v.union( + v.object({ + _id: v.id("community_posts"), + title: v.string(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const post = await ctx.db + .query("community_posts") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .unique(); + + if (!post) return null; + + return { + _id: post._id, + title: post.title, + }; + }, +}); + +// Delete a post - deletes post immediately, schedules cleanup for related data +export const deletePost = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const post = await ctx.db.get(args.postId); + if (!post) { + throw new Error("Post not found"); + } + + // Check ownership or admin + if ( + post.userId !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Not authorized to delete this post"); + } + + // Update user's profile first + const profile = await ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", post.userId)) + .unique(); + + if (profile) { + await ctx.db.patch(profile._id, { + postsCount: Math.max(0, profile.postsCount - 1), + totalLikesReceived: Math.max( + 0, + profile.totalLikesReceived - post.likesCount, + ), + updatedAt: Date.now(), + }); + } + + // Delete the post immediately + await ctx.db.delete(args.postId); + + // Schedule background cleanup for related data (runs in separate transactions) + await ctx.scheduler.runAfter(0, internal.community.cleanupPostLikes, { + postId: args.postId, + }); + await ctx.scheduler.runAfter(100, internal.community.cleanupPostComments, { + postId: args.postId, + }); + await ctx.scheduler.runAfter(200, internal.community.cleanupPostViews, { + postId: args.postId, + }); + + return true; + }, +}); + +// Internal mutation to clean up post likes in batches +export const cleanupPostLikes = internalMutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const BATCH_SIZE = 200; + + const likes = await ctx.db + .query("community_likes") + .withIndex("by_post", (q) => q.eq("postId", args.postId)) + .take(BATCH_SIZE); + + for (const like of likes) { + await ctx.db.delete(like._id); + } + + // If there are more, schedule another cleanup + if (likes.length === BATCH_SIZE) { + await ctx.scheduler.runAfter(50, internal.community.cleanupPostLikes, { + postId: args.postId, + }); + } + + return null; + }, +}); + +// Internal mutation to clean up post comments in batches +export const cleanupPostComments = internalMutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const BATCH_SIZE = 50; + + const comments = await ctx.db + .query("community_comments") + .withIndex("by_post", (q) => q.eq("postId", args.postId)) + .take(BATCH_SIZE); + + for (const comment of comments) { + // Delete comment likes first (small batch) + const commentLikes = await ctx.db + .query("community_comment_likes") + .withIndex("by_comment", (q) => q.eq("commentId", comment._id)) + .take(50); + + for (const commentLike of commentLikes) { + await ctx.db.delete(commentLike._id); + } + + await ctx.db.delete(comment._id); + } + + // If there are more, schedule another cleanup + if (comments.length === BATCH_SIZE) { + await ctx.scheduler.runAfter(50, internal.community.cleanupPostComments, { + postId: args.postId, + }); + } + + return null; + }, +}); + +// Internal mutation to clean up post views in batches +export const cleanupPostViews = internalMutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const BATCH_SIZE = 500; + + const views = await ctx.db + .query("community_views") + .withIndex("by_post", (q) => q.eq("postId", args.postId)) + .take(BATCH_SIZE); + + for (const view of views) { + await ctx.db.delete(view._id); + } + + // If there are more, schedule another cleanup + if (views.length === BATCH_SIZE) { + await ctx.scheduler.runAfter(50, internal.community.cleanupPostViews, { + postId: args.postId, + }); + } + + return null; + }, +}); + +// Update a post (title, description, tags) +export const updatePost = mutation({ + args: { + postId: v.id("community_posts"), + title: v.optional(v.string()), + description: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const post = await ctx.db.get(args.postId); + if (!post) { + throw new Error("Post not found"); + } + + // Check ownership + if ( + post.userId !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Not authorized to edit this post"); + } + + // Update the post + await ctx.db.patch(args.postId, { + ...(args.title && { title: args.title }), + ...(args.description && { description: args.description }), + ...(args.tags && { tags: args.tags }), + updatedAt: Date.now(), + }); + + return true; + }, +}); + +// Generate upload URL for community post screenshot +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Not authenticated"); + } + return await ctx.storage.generateUploadUrl(); + }, +}); + +// Update post screenshot +export const updatePostScreenshot = mutation({ + args: { + postId: v.id("community_posts"), + storageId: v.id("_storage"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + console.log("[updatePostScreenshot] Starting with args:", { + postId: args.postId, + storageId: args.storageId, + }); + + const user = await requireCurrentCommunityUser(ctx); + + const post = await ctx.db.get(args.postId); + if (!post) { + throw new Error("Post not found"); + } + + // Check ownership + if ( + post.userId !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Not authorized to edit this post"); + } + + // Get the URL for the uploaded file to verify it exists + const screenshotUrl = await ctx.storage.getUrl(args.storageId); + console.log("[updatePostScreenshot] Got screenshotUrl:", screenshotUrl); + + if (!screenshotUrl) { + throw new Error("Failed to get screenshot URL"); + } + + // Store both the storage ID (for future URL generation) and the current URL + await ctx.db.patch(args.postId, { + screenshotStorageId: args.storageId, + screenshotUrl, + updatedAt: Date.now(), + }); + + console.log("[updatePostScreenshot] Successfully updated post screenshot"); + return true; + }, +}); + +// Make a post private (unlisted) +export const makePostPrivate = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const post = await ctx.db.get(args.postId); + if (!post) { + throw new Error("Post not found"); + } + + // Check ownership + if ( + post.userId !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Not authorized to modify this post"); + } + + // Make private + await ctx.db.patch(args.postId, { + isPublic: false, + updatedAt: Date.now(), + }); + + return true; + }, +}); + +// Make a post public (relist) +export const makePostPublic = mutation({ + args: { + postId: v.id("community_posts"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + const post = await ctx.db.get(args.postId); + if (!post) { + throw new Error("Post not found"); + } + + // Check ownership + if ( + post.userId !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Not authorized to modify this post"); + } + + // Make public + await ctx.db.patch(args.postId, { + isPublic: true, + updatedAt: Date.now(), + }); + + return true; + }, +}); + +// Get related/recommended posts +export const getRelatedPosts = query({ + args: { + postId: v.id("community_posts"), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + _id: v.id("community_posts"), + projectId: v.id("project"), + userId: v.id("users"), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + publishedAt: v.number(), + userName: v.string(), + userImage: v.optional(v.string()), + isPaidUser: v.boolean(), + communityBadgeTier: v.number(), + hasLiked: v.boolean(), + }), + ), + handler: async (ctx, args) => { + const limit = args.limit || 6; + + // Get posts excluding the current one, ordered by likes + const posts = await ctx.db + .query("community_posts") + .withIndex("by_likes_count") + .order("desc") + .take((limit + 1) * 2); // Fetch more to account for private posts + + // Filter out the current post and private posts + const filteredPosts = posts + .filter((p) => p._id !== args.postId && p.isPublic !== false) + .slice(0, limit); + + const currentUser = await getCurrentCommunityUser(ctx); + + const enrichedPosts = await Promise.all( + filteredPosts.map(async (post) => { + const user = await ctx.db.get(post.userId); + let hasLiked = false; + + if (currentUser) { + const like = await ctx.db + .query("community_likes") + .withIndex("by_post_and_user", (q) => + q.eq("postId", post._id).eq("userId", currentUser._id), + ) + .unique(); + hasLiked = !!like; + } + + const screenshotUrl = await getPostScreenshotUrl(ctx, post); + + return { + _id: post._id, + projectId: post.projectId, + userId: post.userId, + title: post.title, + description: post.description, + tags: post.tags, + screenshotUrl, + previewUrl: post.previewUrl, + likesCount: post.likesCount, + commentsCount: post.commentsCount, + viewsCount: post.viewsCount, + featured: post.featured, + publishedAt: post.publishedAt, + userName: user?.name || "Anonymous", + userImage: user?.profile_image, + isPaidUser: user?.tier === "pro", + communityBadgeTier: user?.community_badge_tier ?? 0, + hasLiked, + }; + }), + ); + + // Sort by community badge tier (higher first), then by likes + enrichedPosts.sort((a, b) => { + if (b.communityBadgeTier !== a.communityBadgeTier) { + return b.communityBadgeTier - a.communityBadgeTier; + } + return b.likesCount - a.likesCount; + }); + + return enrichedPosts; + }, +}); + +// Update user's community badge tier (called from client when Autumn data changes) +export const updateCommunityBadgeTier = mutation({ + args: { + communityBadgeTier: v.number(), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await requireCurrentCommunityUser(ctx); + + // Only update if the tier has changed + if (user.community_badge_tier !== args.communityBadgeTier) { + await ctx.db.patch(user._id, { + community_badge_tier: args.communityBadgeTier, + }); + } + + return true; + }, +}); diff --git a/freebuff/web/convex/constants.ts b/freebuff/web/convex/constants.ts new file mode 100644 index 0000000000..ca392248fd --- /dev/null +++ b/freebuff/web/convex/constants.ts @@ -0,0 +1,26 @@ +import { Infer, v } from "convex/values"; + +/** + * Shared validator for pause reasons across the application. + * + * Pause reasons indicate why a user's deployments were paused: + * - db_bandwidth_depleted: Database bandwidth limit exceeded + * - compute_depleted: Compute resource limit exceeded + * - db_storage_depleted: Database storage limit exceeded + * - file_bandwidth_depleted: File bandwidth limit exceeded + * - function_calls_depleted: Function calls limit exceeded + * - manual_admin: Manually paused by an administrator + */ +export const PAUSE_REASON_VALIDATOR = v.union( + v.literal("db_bandwidth_depleted"), + v.literal("compute_depleted"), + v.literal("db_storage_depleted"), + v.literal("file_bandwidth_depleted"), + v.literal("function_calls_depleted"), + v.literal("manual_admin"), +); + +/** + * TypeScript type for pause reasons (inferred from validator) + */ +export type PauseReason = Infer; diff --git a/freebuff/web/convex/content_moderation.ts b/freebuff/web/convex/content_moderation.ts new file mode 100644 index 0000000000..172c455176 --- /dev/null +++ b/freebuff/web/convex/content_moderation.ts @@ -0,0 +1,128 @@ +import { v } from "convex/values"; +import { action } from "./_generated/server"; + +/** + * Content moderation - check for restricted project types. + */ + +export type ModerationCategory = + | "crypto" + | "stock_market" + | "constant_monitoring" + | "high_resource"; + +const PATTERNS: Record = { + crypto: [ + /\bcrypto\b/i, + /\bblockchain\b/i, + /\bweb3\b/i, + /\bnft\b/i, + /\bdefi\b/i, + /\bsmart\s*contract\b/i, + /\berc[\-\s]?20\b/i, + /\berc[\-\s]?721\b/i, + /\bethereum\b/i, + /\bsolana\b/i, + /\bbitcoin\b/i, + /\bmetamask\b/i, + /\btoken\s+web/i, + /\btoken\s+site/i, + /\btoken\s+app/i, + /\btoken\s+platform/i, + /\btokenomics\b/i, + /\bdapp\b/i, + /\bwagmi\b/i, + /\bhardhat\b/i, + /\bopenzeppelin\b/i, + /\bconnect\s*wallet\b/i, + /\bmint.*token/i, + /\bmint.*nft/i, + /\bairdrop\b/i, + /\bstaking\b/i, + /\bico\b/i, + /\bpresale\b/i, + ], + stock_market: [ + /\bstock\s+(?:market|trading|tracker)\b/i, + /\btrading\s+(?:bot|platform|system)\b/i, + /\bforex\b/i, + /\balgo(?:rithmic)?\s+trading\b/i, + /\bday\s+trading\b/i, + /\bhigh[\-\s]?frequency\s+trading\b/i, + /\bhedge\s+fund\b/i, + ], + constant_monitoring: [ + /\b24\/7\s+monitor/i, + /\bcontinuous\s+(?:scraping|polling|monitor)/i, + /\bprice\s+(?:alert|monitor|tracker)\b/i, + /\barbitrage\b/i, + /\bauto(?:mated)?\s+trading\b/i, + ], + high_resource: [ + /\bmassive\s+(?:data|traffic)\b/i, + /\b(?:millions?|billions?)\s+(?:of\s+)?requests?\b/i, + /\bweb\s+crawler\b/i, + /\bddos\b/i, + /\bload\s+test/i, + ], +}; + +const CATEGORY_MESSAGES: Record = { + crypto: "Cryptocurrency, blockchain, or Web3 projects", + stock_market: "Stock market or trading software", + constant_monitoring: "Software requiring constant monitoring", + high_resource: "High bandwidth or compute-intensive applications", +}; + +export interface ModerationResult { + blocked: boolean; + category?: ModerationCategory; + message?: string; +} + +/** + * Pure function - check if content should be blocked (regex only). + * Use this in mutations. + */ +export function checkContentModeration(content: string): ModerationResult { + if (!content) return { blocked: false }; + + for (const [category, patterns] of Object.entries(PATTERNS)) { + for (const pattern of patterns) { + if (pattern.test(content)) { + const cat = category as ModerationCategory; + return { + blocked: true, + category: cat, + message: `This platform doesn't support: ${CATEGORY_MESSAGES[cat]}`, + }; + } + } + } + + return { blocked: false }; +} + +/** + * Action - check content with regex (AI removed for simplicity). + * Use this from frontend. + */ +export const checkContent = action({ + args: { content: v.string() }, + handler: async (ctx, args) => { + if (!args.content?.trim()) { + return { flagged: false, categories: [], reason: "Empty content" }; + } + + const result = checkContentModeration(args.content); + if (result.blocked) { + return { + flagged: true, + categories: result.category ? [result.category] : [], + reason: result.message || "Matched restricted keywords", + }; + } + + return { flagged: false, categories: [], reason: "Content allowed" }; + }, +}); diff --git a/freebuff/web/convex/convex.config.ts b/freebuff/web/convex/convex.config.ts new file mode 100644 index 0000000000..93c615424a --- /dev/null +++ b/freebuff/web/convex/convex.config.ts @@ -0,0 +1,42 @@ +// convex/convex.config.ts +import crons from '@convex-dev/crons/convex.config' +import migrations from '@convex-dev/migrations/convex.config' +import rateLimiter from '@convex-dev/rate-limiter/convex.config' +import workflow from '@convex-dev/workflow/convex.config' +import workpool from '@convex-dev/workpool/convex.config' +import aggregate from '@convex-dev/aggregate/convex.config' + +import { defineApp } from 'convex/server' + +const app = defineApp() +app.use(workflow) + +app.use(migrations) + +app.use(workpool, { name: 'migrationWorkpool' }) + +app.use(rateLimiter) + +// Runtime-registered cron jobs, one per Automation (see convex/automations.ts). +app.use(crons) + +// Separate aggregate instances to avoid data interference and improve throughput +// Each aggregate type gets its own component to prevent count conflicts +app.use(aggregate, { name: 'allUsersAggregate' }) +app.use(aggregate, { name: 'userActivityAggregate' }) +app.use(aggregate, { name: 'activeUsersByDayAggregate' }) +app.use(aggregate, { name: 'usersByRoleAggregate' }) +app.use(aggregate, { name: 'usersByTierAggregate' }) +app.use(aggregate, { name: 'usersByDayAggregate' }) +app.use(aggregate, { name: 'allProjectsAggregate' }) +app.use(aggregate, { name: 'projectsByDayAggregate' }) +// Freebuff Cloud (connected_repo) usage analytics +app.use(aggregate, { name: 'cloudActiveUsersByDayAggregate' }) +app.use(aggregate, { name: 'cloudProjectsByTypeDayAggregate' }) +// Freebuff Web (template / non-connected_repo) usage analytics +app.use(aggregate, { name: 'webActiveUsersByDayAggregate' }) +app.use(aggregate, { name: 'allConvexInstancesAggregate' }) +app.use(aggregate, { name: 'pausedProjectsByActiveAggregate' }) +app.use(aggregate, { name: 'pausedUsersByActiveAggregate' }) + +export default app diff --git a/freebuff/web/convex/convex_instance.ts b/freebuff/web/convex/convex_instance.ts new file mode 100644 index 0000000000..4e9e2a8db6 --- /dev/null +++ b/freebuff/web/convex/convex_instance.ts @@ -0,0 +1,29047 @@ +import { internal } from "!/_generated/api"; +import { Doc } from "!/_generated/dataModel"; +import { internalMutation, internalQuery, query } from "!/_generated/server"; +import { v } from "convex/values"; +import { allConvexInstances } from "./aggregates/admin_aggregates"; + +export const save = internalMutation({ + args: { + projectId: v.id("project"), + convexProjectId: v.number(), + devDeploymentName: v.string(), + prodDeploymentName: v.union(v.string(), v.null()), + upsert: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const matchingRecord = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + // if the record exists and we're not upserting, throw an error + if (matchingRecord && !args.upsert) { + throw new Error("Convex instance already exists, cannot re-save"); + } + + // If upserting and record exists, update it + if (matchingRecord && args.upsert) { + const oldDoc = matchingRecord; + await ctx.db.patch(matchingRecord._id, { + convexProjectId: args.convexProjectId, + devDeploymentName: args.devDeploymentName, + prodDeploymentName: args.prodDeploymentName, + }); + const newDoc = (await ctx.db.get( + matchingRecord._id, + )) as Doc<"project_convex_instance"> | null; + if (newDoc) { + // Update aggregate with the replacement + await allConvexInstances.replace(ctx, oldDoc, newDoc); + } + return; + } + + // Otherwise, insert a new record + const instanceId = await ctx.db.insert("project_convex_instance", { + projectId: args.projectId, + convexProjectId: args.convexProjectId, + devDeploymentName: args.devDeploymentName, + prodDeploymentName: args.prodDeploymentName, + }); + + // Update aggregates for new convex instance + const newInstance = await ctx.db.get(instanceId); + if (newInstance) { + await allConvexInstances.insert(ctx, newInstance); + } + }, +}); + +export const get = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async ( + ctx, + args, + ): Promise | null> => { + return await ctx.db + .query("project_convex_instance") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .first(); + }, +}); + +// Internal cacheable version - accepts semanticIdentifier directly +export const lookupInternal = internalQuery({ + args: { + semanticIdentifier: v.string(), + }, + handler: async ( + ctx, + args, + ): Promise | null> => { + const matchingProject = await ctx.db + .query("project") + .withIndex("by_semantic_identifier", (q) => + q.eq("semantic_identifier", args.semanticIdentifier), + ) + .unique(); + + if (!matchingProject) { + return null; + } + + return await ctx.db + .query("project_convex_instance") + .withIndex("by_project", (q) => q.eq("projectId", matchingProject._id)) + .first(); + }, +}); + +export const lookup = query({ + args: { + semanticIdentifier: v.optional(v.string()), + }, + handler: async ( + ctx, + args, + ): Promise | null> => { + if (!args.semanticIdentifier) { + return null; + } + + // Delegate to internal cached version + return await ctx.runQuery(internal.convex_instance.lookupInternal, { + semanticIdentifier: args.semanticIdentifier, + }); + }, +}); + +export const CONVEX_MAPPING: Record = { + "innocent-pig-19": 31252, + "careless-wren-748": 31252, + "blessed-viper-540": 31331, + "greedy-grouse-850": 31494, + "steady-wasp-755": 31494, + "intent-corgi-371": 103604, + "exciting-mockingbird-351": 103604, + "content-chihuahua-344": 104221, + "charming-alpaca-843": 104221, + "small-moose-689": 115889, + "amiable-ant-356": 116191, + "agreeable-meerkat-731": 116191, + "sincere-sockeye-836": 125648, + "honorable-orca-97": 125648, + "whimsical-lobster-50": 128052, + "neighborly-mule-711": 128052, + "insightful-finch-572": 175625, + "combative-bear-33": 179299, + "bright-perch-274": 180382, + "famous-bloodhound-686": 180382, + "wry-salmon-264": 180402, + "insightful-hound-865": 181139, + "dutiful-fish-103": 181139, + "qualified-kingfisher-917": 182034, + "perceptive-sturgeon-568": 182034, + "useful-dotterel-229": 182926, + "artful-ocelot-50": 224196, + "glad-robin-461": 224196, + "aware-donkey-887": 239413, + "effervescent-canary-91": 245781, + "kindred-ferret-805": 245781, + "loyal-husky-208": 254909, + "secret-cod-395": 254909, + "precise-woodpecker-798": 257089, + "intent-viper-262": 257918, + "trustworthy-deer-11": 258055, + "scintillating-barracuda-202": 258055, + "knowing-lemming-597": 258055, + "hip-cod-340": 259378, + "majestic-avocet-868": 260442, + "sleek-bison-276": 260459, + "rapid-panda-311": 260849, + "sincere-chihuahua-271": 260922, + "graceful-narwhal-380": 261709, + "strong-albatross-237": 261709, + "resolute-panther-315": 262058, + "fiery-cod-27": 262059, + "posh-antelope-994": 262084, + "wandering-leopard-942": 262086, + "fortunate-hornet-277": 264630, + "helpful-grasshopper-84": 265972, + "limitless-vole-512": 265988, + "loyal-impala-119": 265989, + "giant-tern-998": 265993, + "energized-leopard-182": 265994, + "exciting-bat-149": 267401, + "qualified-corgi-353": 267557, + "agile-orca-926": 269792, + "animated-jackal-825": 271160, + "precise-perch-431": 271263, + "agile-gecko-447": 271273, + "tacit-eel-657": 271486, + "zealous-sardine-571": 271617, + "marvelous-bobcat-719": 271617, + "bright-crab-19": 271709, + "combative-stork-189": 271723, + "aware-goshawk-787": 273887, + "insightful-cuttlefish-969": 281784, + "good-perch-859": 281817, + "unique-starfish-125": 282294, + "giddy-chinchilla-19": 283166, + "pastel-whale-993": 283236, + "brave-toucan-775": 283249, + "jovial-wren-240": 283393, + "flippant-guineapig-180": 283406, + "adamant-antelope-277": 283420, + "aromatic-fish-666": 283421, + "wry-mammoth-233": 283490, + "precious-mastiff-97": 283665, + "stoic-ant-758": 283677, + "energetic-cassowary-523": 283683, + "peaceful-pigeon-738": 283696, + "aware-caiman-31": 283699, + "woozy-raccoon-349": 283712, + "doting-rhinoceros-901": 283732, + "oceanic-puffin-258": 283733, + "honorable-vulture-9": 283735, + "oceanic-rabbit-176": 283746, + "youthful-wildcat-7": 283747, + "glorious-chinchilla-221": 283749, + "good-bandicoot-933": 283752, + "outgoing-curlew-144": 283753, + "clever-wren-995": 283797, + "gallant-shrimp-217": 283819, + "determined-ram-556": 283824, + "canny-terrier-337": 283856, + "terrific-goose-388": 284411, + "fast-nightingale-757": 284430, + "superb-dinosaur-93": 284438, + "aromatic-dalmatian-198": 284557, + "healthy-tern-681": 284578, + "abundant-bullfrog-182": 284578, + "valiant-kudu-870": 284771, + "proper-squirrel-304": 284773, + "wandering-swordfish-224": 284784, + "hardy-cormorant-473": 284802, + "giant-barracuda-4": 284812, + "calm-crow-140": 284814, + "little-puma-842": 284874, + "dashing-tern-787": 284892, + "elegant-chipmunk-838": 284895, + "quick-crow-815": 284901, + "academic-pheasant-109": 284906, + "majestic-snail-25": 284909, + "agreeable-bee-945": 284911, + "helpful-elk-153": 284912, + "incredible-rook-595": 284913, + "agile-stork-586": 284943, + "prestigious-mongoose-930": 284946, + "majestic-ibis-499": 284954, + "oceanic-pika-451": 284964, + "cheery-elephant-913": 284983, + "aromatic-nightingale-263": 284988, + "frugal-owl-491": 284999, + "utmost-echidna-469": 285006, + "proficient-chinchilla-860": 285018, + "gregarious-dog-82": 285069, + "tacit-antelope-66": 285070, + "tremendous-boar-339": 285484, + "judicious-spoonbill-593": 285572, + "earnest-mongoose-525": 285623, + "disciplined-narwhal-495": 285814, + "sleek-hedgehog-842": 285908, + "accurate-lapwing-917": 285931, + "rosy-wolverine-99": 285952, + "industrious-rabbit-613": 286307, + "gallant-rabbit-461": 286398, + "robust-terrier-254": 286419, + "tough-hamster-128": 286564, + "confident-jackal-546": 287026, + "capable-peacock-515": 287211, + "quixotic-hound-543": 287419, + "prestigious-ram-872": 290298, + "intent-sardine-571": 290370, + "descriptive-peacock-122": 290470, + "moonlit-bass-250": 290480, + "harmless-wombat-639": 294339, + "posh-kookabura-643": 294343, + "cheery-chihuahua-749": 298224, + "academic-monitor-721": 299609, + "focused-pony-232": 300798, + "deafening-anteater-840": 300810, + "beaming-goose-569": 300837, + "dazzling-sheep-991": 301215, + "valuable-snail-904": 301219, + "careful-mammoth-503": 301230, + "blissful-elephant-684": 302361, + "outstanding-impala-91": 302452, + "agreeable-bloodhound-284": 302691, + "content-kingfisher-555": 302745, + "hip-dog-727": 302855, + "first-hawk-922": 302969, + "dapper-civet-254": 302980, + "oceanic-hedgehog-746": 302987, + "valuable-rabbit-551": 303622, + "hallowed-guineapig-947": 303631, + "grandiose-stingray-596": 307964, + "joyous-mammoth-165": 309195, + "industrious-bulldog-459": 309206, + "nautical-zebra-997": 309209, + "energetic-bear-750": 309282, + "shiny-duck-115": 309440, + "agile-kangaroo-85": 309549, + "outstanding-cobra-246": 310469, + "valuable-anteater-164": 310481, + "silent-shark-236": 310496, + "earnest-seahorse-298": 310892, + "acrobatic-cricket-393": 310937, + "successful-rooster-978": 311775, + "energetic-anteater-375": 313123, + "amicable-swan-202": 318739, + "whimsical-mandrill-622": 318864, + "pastel-jay-157": 318895, + "laudable-antelope-523": 318961, + "resolute-dotterel-917": 318967, + "grand-ibis-56": 318981, + "formal-canary-520": 319035, + "famous-mammoth-496": 319049, + "grandiose-bison-994": 319050, + "hardy-puffin-657": 319055, + "lovable-chickadee-930": 319065, + "calm-lyrebird-257": 319067, + "reliable-deer-144": 319069, + "calm-starfish-291": 319076, + "hidden-owl-477": 319080, + "agreeable-hummingbird-323": 319084, + "fabulous-crocodile-252": 319085, + "precise-hornet-851": 319089, + "chatty-eagle-468": 319098, + "fastidious-kookabura-582": 319098, + "doting-toucan-530": 320330, + "useful-walrus-90": 320357, + "energized-crocodile-203": 320443, + "fine-crow-548": 321563, + "aware-buzzard-196": 322882, + "perceptive-owl-934": 323006, + "unique-penguin-878": 323018, + "diligent-cobra-196": 323059, + "courteous-quail-370": 323080, + "compassionate-mallard-303": 323092, + "cheerful-tern-159": 323212, + "rapid-meerkat-430": 323335, + "insightful-gopher-496": 323352, + "gallant-fox-576": 323355, + "gallant-emu-987": 323355, + "hallowed-salmon-164": 323381, + "colorless-vulture-667": 323382, + "adjoining-manatee-859": 323384, + "brazen-capybara-584": 323386, + "impartial-koala-969": 323477, + "fantastic-crow-139": 323481, + "valuable-sheep-451": 323497, + "earnest-beagle-920": 323498, + "handsome-crocodile-412": 323501, + "savory-parrot-380": 323744, + "affable-pony-202": 323779, + "focused-dotterel-13": 323781, + "impartial-elk-724": 323783, + "disciplined-marten-469": 325853, + "fearless-tiger-629": 325975, + "standing-cassowary-644": 326010, + "keen-buffalo-611": 326011, + "brave-fox-185": 326038, + "shiny-schnauzer-21": 326039, + "vivid-platypus-285": 326053, + "warmhearted-guineapig-859": 326066, + "clever-tiger-970": 326067, + "deafening-pelican-935": 326068, + "cautious-cobra-840": 326073, + "famous-badger-653": 326084, + "gallant-whale-994": 326104, + "laudable-porpoise-428": 326151, + "accomplished-salamander-929": 327274, + "clever-cow-359": 327276, + "nautical-pheasant-195": 327277, + "honorable-scorpion-205": 327283, + "flexible-tern-855": 327391, + "frugal-shrimp-301": 327394, + "cautious-dotterel-679": 327394, + "intent-partridge-366": 327411, + "tame-finch-283": 327451, + "fantastic-dinosaur-56": 328335, + "perfect-coyote-928": 328679, + "marvelous-dalmatian-585": 328679, + "wooden-pelican-155": 328852, + "rosy-pony-303": 328860, + "lovely-bear-114": 328961, + "beaming-mammoth-926": 329075, + "rare-fox-98": 329125, + "agreeable-giraffe-245": 329125, + "effervescent-mole-473": 329125, + "dynamic-vulture-331": 329125, + "amiable-dogfish-6": 329125, + "pastel-stork-921": 329125, + "descriptive-squid-758": 329125, + "tacit-bandicoot-442": 329125, + "harmless-tapir-303": 329125, + "exciting-scorpion-525": 329125, + "rosy-rhinoceros-739": 329125, + "rapid-zebra-786": 329125, + "cheerful-leopard-166": 329125, + "cheery-mouse-785": 329125, + "steady-lobster-569": 329125, + "dashing-badger-432": 329125, + "keen-labrador-687": 329125, + "quick-hippopotamus-141": 329125, + "steady-hedgehog-339": 329125, + "fiery-jaguar-814": 329125, + "harmless-camel-583": 329125, + "doting-cobra-395": 329125, + "valiant-dove-786": 329125, + "upbeat-crow-45": 329125, + "oceanic-panther-809": 329125, + "beaming-dinosaur-824": 329125, + "different-leopard-84": 329125, + "dapper-terrier-563": 329125, + "stoic-weasel-32": 329125, + "wry-kingfisher-459": 329125, + "formal-panda-586": 329125, + "wandering-kookabura-185": 329125, + "resilient-cow-503": 329125, + "harmless-hare-504": 329125, + "quaint-pheasant-519": 329125, + "basic-lemming-621": 329691, + "acoustic-puffin-658": 329692, + "mild-lion-662": 329694, + "prestigious-porpoise-735": 329708, + "posh-marmot-449": 329709, + "little-clownfish-910": 329711, + "exuberant-reindeer-825": 329764, + "content-heron-530": 329766, + "blessed-reindeer-205": 329769, + "polite-emu-207": 329770, + "blissful-mink-845": 329771, + "tremendous-emu-456": 329772, + "graceful-viper-121": 329774, + "reminiscent-greyhound-98": 329776, + "ardent-alpaca-166": 329779, + "quick-rat-851": 330019, + "calculating-duck-13": 330020, + "first-lobster-126": 330021, + "elegant-gnat-41": 330021, + "hip-sparrow-841": 337211, + "polished-kudu-226": 343056, + "limitless-giraffe-464": 343059, + "glorious-dodo-461": 343060, + "wandering-cod-322": 343323, + "shocking-yak-286": 343395, + "harmless-perch-136": 343853, + "hardy-sockeye-135": 343978, + "outstanding-viper-767": 344030, + "polished-goat-709": 344077, + "adept-chipmunk-583": 344090, + "reminiscent-grasshopper-105": 344092, + "beloved-mallard-424": 344104, + "amiable-mandrill-149": 344115, + "aromatic-wildcat-476": 344141, + "cautious-chickadee-823": 344185, + "youthful-husky-531": 344185, + "moonlit-goshawk-647": 344264, + "pleasant-armadillo-247": 344264, + "helpful-ocelot-559": 345009, + "modest-opossum-671": 345029, + "wandering-hound-237": 346705, + "determined-chipmunk-960": 346705, + "ceaseless-kudu-934": 347727, + "deafening-pelican-607": 347728, + "unique-snake-252": 347799, + "affable-stingray-423": 347811, + "giant-partridge-45": 347818, + "spotted-hare-919": 347871, + "elegant-finch-97": 347892, + "elegant-chameleon-891": 347910, + "beaming-crab-618": 347920, + "optimistic-shrimp-251": 347921, + "fantastic-hyena-262": 348010, + "canny-iguana-99": 348305, + "impartial-husky-482": 348434, + "nautical-buzzard-38": 348434, + "optimistic-tapir-927": 350918, + "scintillating-wildcat-237": 352564, + "wooden-chipmunk-597": 352565, + "sincere-badger-111": 352565, + "proper-stoat-522": 356900, + "polished-otter-232": 356901, + "avid-moose-806": 356901, + "grand-stoat-760": 358352, + "curious-vulture-586": 358352, + "fiery-hornet-788": 358352, + "wandering-snake-377": 358649, + "resolute-ibex-318": 358665, + "ardent-ptarmigan-874": 358669, + "fast-cod-691": 358683, + "brave-ladybug-995": 363466, + "dusty-caribou-728": 363664, + "groovy-hornet-647": 386902, + "descriptive-bison-579": 386963, + "artful-mammoth-564": 387309, + "academic-mouse-980": 387618, + "glad-bird-553": 387958, + "reliable-crab-765": 387979, + "quirky-akita-453": 390876, + "moonlit-chameleon-310": 391777, + "fabulous-wildebeest-536": 412774, + "adorable-eagle-640": 412778, + "artful-kookabura-955": 412782, + "resilient-turtle-285": 412785, + "zealous-eel-399": 412789, + "veracious-pelican-688": 412794, + "ideal-roadrunner-955": 424488, + "posh-malamute-322": 424557, + "reminiscent-labrador-199": 424572, + "trustworthy-pheasant-349": 424573, + "fleet-pigeon-967": 424607, + "fastidious-opossum-908": 424611, + "shocking-barracuda-314": 424728, + "impartial-cricket-277": 424730, + "descriptive-clownfish-277": 424731, + "curious-cheetah-962": 424743, + "superb-panther-73": 424744, + "animated-armadillo-25": 424744, + "spotted-zebra-684": 424947, + "vivid-goat-437": 424947, + "quixotic-crane-588": 425067, + "beloved-shark-182": 428922, + "beaming-newt-18": 428924, + "amiable-coyote-628": 428979, + "silent-sardine-943": 428979, + "rugged-dogfish-254": 429166, + "abundant-leopard-444": 429199, + "beloved-snake-243": 429264, + "neat-grouse-863": 429322, + "loyal-eel-992": 429336, + "dutiful-porcupine-620": 429356, + "little-okapi-271": 429358, + "compassionate-rabbit-829": 429368, + "gallant-bird-296": 429371, + "elegant-albatross-709": 429376, + "steady-echidna-578": 429382, + "resilient-wolverine-248": 429388, + "determined-octopus-464": 429393, + "tacit-ox-318": 429416, + "exuberant-egret-662": 429420, + "terrific-octopus-167": 429422, + "peaceful-capybara-660": 429426, + "impressive-shepherd-853": 429426, + "small-mongoose-967": 429452, + "precise-badger-59": 429692, + "keen-jaguar-373": 429694, + "clean-okapi-352": 430003, + "outgoing-labrador-763": 430308, + "insightful-akita-951": 430385, + "quixotic-reindeer-930": 430819, + "exciting-zebra-108": 433465, + "merry-alligator-154": 433500, + "sensible-gnu-916": 433763, + "brazen-fennec-294": 433914, + "amicable-hamster-849": 433967, + "optimistic-possum-317": 434205, + "brainy-spider-417": 434570, + "valuable-echidna-495": 434590, + "benevolent-mouse-247": 434590, + "cautious-alpaca-518": 434609, + "dynamic-bird-694": 434619, + "helpful-heron-161": 434723, + "tough-jaguar-75": 434726, + "quirky-wren-382": 434726, + "artful-elephant-271": 434757, + "polite-wolf-956": 434772, + "glad-sparrow-341": 434773, + "quaint-mandrill-137": 434841, + "brainy-dodo-465": 434846, + "patient-llama-518": 434875, + "artful-weasel-614": 435026, + "spotted-tortoise-906": 435058, + "vibrant-akita-138": 435073, + "lovely-armadillo-325": 435103, + "giddy-marmot-992": 435103, + "formal-orca-969": 435190, + "giddy-antelope-379": 435190, + "perceptive-dove-466": 435273, + "nautical-herring-393": 435595, + "keen-parrot-13": 435666, + "strong-hawk-476": 435826, + "standing-starling-5": 435835, + "scintillating-llama-871": 435840, + "hushed-fly-703": 435845, + "earnest-pony-452": 435856, + "perfect-leopard-279": 435860, + "useful-gnu-500": 435864, + "valiant-hound-130": 435925, + "kindhearted-manatee-928": 435934, + "woozy-goose-704": 436044, + "amicable-shepherd-142": 436311, + "original-starling-331": 436381, + "first-ptarmigan-520": 437138, + "blissful-tapir-124": 437930, + "bright-mammoth-502": 437932, + "exciting-falcon-119": 438324, + "majestic-hippopotamus-501": 438857, + "merry-robin-45": 438882, + "enduring-blackbird-672": 439468, + "helpful-vulture-242": 440332, + "rugged-ostrich-736": 440332, + "helpful-lyrebird-366": 440915, + "good-caterpillar-538": 440924, + "proficient-spaniel-24": 441406, + "secret-vulture-882": 441541, + "robust-clownfish-601": 441910, + "scrupulous-buffalo-121": 442131, + "reliable-leopard-383": 442278, + "astute-gerbil-774": 442601, + "secret-ptarmigan-598": 442807, + "wandering-jackal-421": 443834, + "adept-bobcat-346": 443905, + "gregarious-heron-844": 443963, + "giant-malamute-157": 444053, + "spotted-elk-878": 444177, + "brave-coyote-125": 444208, + "usable-dogfish-595": 444567, + "insightful-lobster-738": 444567, + "wary-viper-52": 445435, + "dependable-mole-31": 445435, + "scintillating-snake-174": 445823, + "hidden-barracuda-785": 445872, + "laudable-ocelot-657": 445872, + "laudable-dinosaur-550": 447509, + "quick-sandpiper-836": 448555, + "usable-turtle-395": 449117, + "neighborly-crocodile-123": 449562, + "whimsical-rooster-550": 450835, + "incredible-cardinal-935": 451309, + "healthy-canary-596": 451813, + "oceanic-lemur-686": 452634, + "flippant-firefly-289": 452704, + "perfect-gull-342": 452924, + "first-turtle-641": 454041, + "rightful-possum-50": 454286, + "notable-bee-741": 454466, + "kindhearted-frog-997": 454606, + "colorful-lark-939": 454623, + "proper-mallard-229": 454639, + "cheery-emu-33": 454670, + "coordinated-frog-501": 455160, + "proper-reindeer-712": 455225, + "optimistic-snail-439": 455281, + "standing-monitor-922": 457141, + "steady-wombat-628": 457315, + "resilient-bobcat-532": 458108, + "scrupulous-newt-887": 458841, + "fine-falcon-834": 461784, + "adamant-hawk-73": 462026, + "brave-cod-185": 462026, + "hushed-hamster-114": 462467, + "cheery-rabbit-132": 463328, + "rosy-mouse-668": 463344, + "resilient-finch-553": 463347, + "ardent-clam-715": 463349, + "canny-newt-27": 463732, + "first-caterpillar-272": 463793, + "marvelous-shark-726": 463842, + "spotted-rhinoceros-792": 464011, + "festive-lemming-539": 464246, + "dependable-gerbil-589": 464373, + "hip-marten-820": 465005, + "elegant-weasel-170": 465125, + "outgoing-lynx-806": 465313, + "posh-orca-149": 469891, + "hidden-labrador-957": 470113, + "modest-dachshund-83": 470121, + "superb-orca-305": 470592, + "proper-kingfisher-627": 470838, + "formal-clownfish-880": 470842, + "clear-coyote-953": 470842, + "third-dog-42": 471043, + "adventurous-bison-711": 471052, + "determined-panther-108": 471111, + "glad-dodo-275": 471133, + "rightful-gnat-933": 471787, + "acrobatic-peccary-777": 471835, + "greedy-meerkat-63": 471920, + "grandiose-sheep-814": 472201, + "third-eel-643": 472287, + "neat-hornet-843": 472445, + "mild-avocet-382": 472449, + "healthy-marlin-671": 472468, + "quaint-eagle-811": 472496, + "tough-labrador-208": 472499, + "fine-mockingbird-338": 472560, + "tough-malamute-714": 473018, + "impressive-walrus-172": 473136, + "quick-cow-129": 473206, + "perceptive-lynx-442": 473301, + "elated-skunk-738": 473695, + "elated-ibex-420": 474479, + "fleet-rook-580": 474607, + "small-frog-225": 475019, + "precise-sockeye-958": 475256, + "tremendous-terrier-603": 475404, + "giant-panther-922": 476517, + "next-tapir-663": 477772, + "determined-hawk-645": 478637, + "grateful-loris-103": 478668, + "adamant-setter-260": 478896, + "dynamic-caiman-83": 479098, + "mild-bloodhound-732": 479134, + "warmhearted-parakeet-579": 479152, + "perfect-warbler-695": 479170, + "pastel-perch-613": 479393, + "aware-guanaco-35": 479672, + "benevolent-seahorse-22": 479741, + "fine-peacock-488": 479786, + "quixotic-jaguar-440": 479823, + "sincere-warbler-751": 479827, + "dutiful-quail-134": 479892, + "chatty-alpaca-484": 479921, + "astute-wren-900": 479950, + "precise-pika-845": 480009, + "scrupulous-squirrel-760": 480084, + "aware-wolf-826": 480456, + "woozy-raven-776": 480505, + "strong-duck-281": 480762, + "polished-capybara-33": 480983, + "cheery-ibex-779": 481033, + "calm-lobster-705": 481037, + "polite-guanaco-630": 481066, + "animated-condor-447": 481181, + "adjoining-marmot-221": 481187, + "necessary-axolotl-592": 481284, + "proficient-moose-475": 481946, + "zealous-lion-107": 482125, + "intent-swan-529": 482571, + "groovy-goldfinch-346": 484380, + "adept-bloodhound-696": 484387, + "zany-elephant-813": 484445, + "robust-rhinoceros-665": 484529, + "rapid-coyote-168": 484539, + "adorable-fish-843": 484604, + "academic-crocodile-320": 486614, + "hardy-lobster-282": 487044, + "neat-porpoise-960": 487051, + "fortunate-egret-756": 487538, + "amicable-ermine-595": 487588, + "content-mammoth-992": 487718, + "quaint-raven-286": 487725, + "hearty-gazelle-1": 487804, + "sleek-hedgehog-479": 488203, + "dusty-sockeye-340": 488214, + "calm-bobcat-824": 488378, + "wonderful-sandpiper-473": 488702, + "unique-porcupine-496": 488773, + "blissful-possum-634": 488795, + "sincere-ladybug-473": 488827, + "admired-duck-552": 488841, + "whimsical-salmon-640": 488896, + "wonderful-goldfish-190": 488986, + "frugal-dalmatian-397": 489178, + "quirky-moose-108": 489193, + "different-hamster-562": 489218, + "rare-peacock-218": 489227, + "watchful-octopus-896": 489233, + "diligent-stoat-341": 489237, + "tacit-meerkat-690": 489237, + "watchful-ibex-989": 489241, + "agile-chameleon-885": 489354, + "brazen-ram-834": 489835, + "quirky-salamander-278": 490386, + "giant-salamander-645": 490406, + "flexible-turtle-652": 490424, + "lovable-tiger-60": 490784, + "grandiose-lyrebird-676": 490824, + "wonderful-sardine-181": 491189, + "bold-wolverine-540": 491225, + "quiet-rooster-972": 491264, + "adept-mammoth-622": 491280, + "third-mockingbird-231": 491283, + "impressive-stingray-651": 491390, + "colorful-poodle-681": 491409, + "acrobatic-lapwing-766": 493238, + "dapper-peacock-141": 494594, + "colorless-mallard-901": 494972, + "utmost-vole-932": 495171, + "mellow-wombat-678": 495186, + "blissful-hare-455": 495228, + "dutiful-stoat-468": 495236, + "tame-avocet-738": 495559, + "groovy-ostrich-543": 495581, + "disciplined-lynx-84": 495588, + "uncommon-poodle-362": 495589, + "savory-alpaca-828": 495603, + "disciplined-schnauzer-985": 495855, + "original-parrot-902": 495857, + "descriptive-tapir-947": 495984, + "necessary-goldfinch-908": 496002, + "energetic-magpie-901": 496020, + "cheery-tapir-599": 496075, + "calculating-lemur-464": 496078, + "basic-crow-96": 496231, + "impressive-coyote-105": 497048, + "fearless-raven-10": 497882, + "rightful-dotterel-777": 497886, + "frugal-manatee-332": 498135, + "content-ladybug-562": 498392, + "limitless-chipmunk-582": 498393, + "fine-oyster-93": 498395, + "blessed-dachshund-479": 499040, + "decisive-tern-463": 499042, + "quick-guanaco-255": 499056, + "determined-firefly-914": 499058, + "ceaseless-koala-208": 501093, + "shiny-lemur-442": 501097, + "useful-gull-378": 501161, + "handsome-crane-464": 501393, + "tremendous-crow-345": 501638, + "notable-sandpiper-708": 501824, + "colorless-yak-690": 502786, + "proper-clownfish-997": 502821, + "befitting-goldfish-148": 502823, + "determined-hare-667": 502906, + "robust-koala-361": 502925, + "valiant-perch-870": 503024, + "brainy-raccoon-188": 503371, + "careful-moose-612": 503506, + "shiny-monitor-480": 503522, + "rosy-crab-726": 503565, + "standing-porpoise-746": 503719, + "proficient-ibis-741": 503723, + "combative-grouse-991": 503742, + "standing-hare-561": 503763, + "outgoing-minnow-666": 503794, + "savory-goose-347": 503806, + "necessary-dotterel-259": 503815, + "quaint-peacock-553": 503816, + "pleasant-scorpion-921": 503819, + "capable-caribou-125": 503827, + "brilliant-bird-360": 503831, + "agreeable-vulture-481": 503860, + "friendly-peacock-434": 503880, + "fabulous-magpie-589": 503890, + "secret-salmon-455": 503936, + "giant-dodo-263": 503938, + "amiable-gecko-297": 503945, + "adamant-armadillo-370": 503951, + "peaceful-roadrunner-140": 503957, + "steady-iguana-788": 503957, + "compassionate-iguana-257": 503960, + "effervescent-cardinal-879": 503962, + "brainy-salmon-141": 503965, + "successful-crocodile-297": 503966, + "resilient-ant-230": 503967, + "rare-ostrich-152": 503968, + "aware-warbler-298": 503969, + "courteous-flamingo-477": 503970, + "moonlit-giraffe-342": 503970, + "reminiscent-avocet-353": 503990, + "laudable-husky-913": 504043, + "brave-herring-795": 504066, + "spotted-goose-413": 504163, + "cool-wolf-847": 504189, + "trustworthy-flamingo-74": 504190, + "joyous-okapi-496": 504192, + "dutiful-bat-632": 504216, + "majestic-deer-503": 504229, + "nautical-jackal-780": 504237, + "rugged-lark-302": 504244, + "adjoining-cat-897": 504254, + "rosy-chipmunk-476": 504352, + "judicious-leopard-853": 504405, + "vibrant-kangaroo-380": 504506, + "hidden-hamster-198": 504563, + "judicious-alligator-351": 504696, + "resilient-pigeon-878": 504709, + "vibrant-shrimp-400": 504720, + "different-moose-906": 504856, + "fabulous-jay-285": 505001, + "fine-penguin-823": 505039, + "impartial-yak-14": 505283, + "limitless-ocelot-702": 505323, + "frugal-bird-851": 505340, + "expert-badger-898": 505345, + "warmhearted-beagle-953": 505347, + "lovable-axolotl-279": 505352, + "sincere-ox-765": 505470, + "ceaseless-otter-655": 505490, + "quick-civet-20": 505525, + "wonderful-cheetah-887": 505529, + "ardent-buffalo-340": 505540, + "brave-stork-611": 505583, + "limitless-chihuahua-641": 506074, + "quaint-sardine-509": 506424, + "stoic-finch-572": 506430, + "tacit-toucan-117": 506478, + "pleasant-seahorse-157": 506896, + "energized-canary-776": 506941, + "watchful-camel-814": 507039, + "bold-spoonbill-649": 507118, + "merry-caiman-830": 507281, + "incredible-spider-20": 507591, + "neat-kingfisher-168": 507731, + "dutiful-bat-741": 507784, + "kindly-cuttlefish-864": 507813, + "careful-wildcat-990": 507832, + "diligent-fly-210": 508044, + "expert-guineapig-435": 508054, + "wonderful-armadillo-149": 508077, + "dazzling-beagle-304": 508277, + "fine-eel-922": 508287, + "tacit-spider-373": 508420, + "efficient-pika-938": 508433, + "keen-pika-55": 508442, + "valuable-pelican-305": 508443, + "precise-puma-282": 508450, + "striped-hedgehog-172": 508544, + "precious-parakeet-17": 508597, + "valuable-starling-393": 508629, + "kindred-pig-623": 508698, + "dynamic-clam-590": 508870, + "woozy-butterfly-909": 508930, + "reliable-blackbird-865": 508955, + "prestigious-goat-303": 508966, + "glad-wolf-421": 508983, + "terrific-fox-784": 508985, + "jovial-wildcat-41": 509003, + "spotted-mule-791": 509035, + "fastidious-starling-759": 509126, + "enchanted-cormorant-578": 509126, + "beaming-dog-692": 509155, + "courteous-tapir-729": 509437, + "festive-gull-514": 509513, + "elegant-newt-40": 509758, + "fastidious-kiwi-564": 509786, + "sincere-ibex-416": 509787, + "dusty-leopard-13": 510098, + "shiny-viper-196": 510258, + "dependable-narwhal-170": 510287, + "different-starfish-17": 510596, + "polished-ostrich-196": 510676, + "wooden-beagle-595": 510823, + "effervescent-kangaroo-623": 510943, + "rare-malamute-210": 510956, + "dazzling-bullfrog-877": 511014, + "fine-blackbird-302": 511114, + "fastidious-chinchilla-185": 513047, + "quirky-cobra-207": 513580, + "laudable-mongoose-329": 515072, + "clean-shrimp-150": 515417, + "bold-gecko-277": 517345, + "basic-dragon-948": 517704, + "academic-spoonbill-690": 518237, + "polite-goldfinch-655": 518277, + "efficient-seahorse-330": 518636, + "outstanding-toad-193": 519118, + "precise-marlin-686": 519133, + "elated-caterpillar-371": 519162, + "stoic-cuttlefish-371": 519851, + "rare-meadowlark-127": 519941, + "loyal-axolotl-376": 520640, + "strong-barracuda-243": 520746, + "usable-meerkat-526": 520898, + "wonderful-hawk-159": 521372, + "frugal-raccoon-217": 521984, + "shiny-basilisk-716": 522149, + "oceanic-tortoise-569": 522479, + "elegant-viper-359": 523258, + "reminiscent-shrimp-475": 523693, + "courteous-roadrunner-730": 523696, + "glad-aardvark-548": 523919, + "superb-basilisk-89": 523931, + "adamant-caterpillar-137": 524163, + "agreeable-guanaco-211": 524771, + "rosy-weasel-181": 524912, + "healthy-avocet-647": 524949, + "flippant-caiman-34": 525001, + "courteous-goshawk-611": 525006, + "graceful-oyster-599": 525062, + "careful-corgi-42": 525072, + "grand-ant-24": 525153, + "robust-newt-875": 525182, + "hallowed-dog-17": 525246, + "wry-frog-33": 525287, + "prestigious-nightingale-2": 525299, + "valuable-swan-872": 525305, + "neighborly-koala-668": 525330, + "utmost-weasel-995": 525331, + "adorable-shark-627": 525446, + "dapper-rat-710": 525634, + "pleasant-loris-655": 525666, + "festive-camel-465": 525669, + "compassionate-minnow-496": 525673, + "quixotic-caribou-304": 525695, + "proficient-sheep-545": 525696, + "basic-puma-782": 525706, + "lovely-dogfish-613": 525707, + "little-minnow-805": 525709, + "fine-eel-240": 526600, + "striped-snail-261": 527420, + "robust-corgi-511": 528661, + "insightful-vole-869": 529145, + "curious-llama-88": 529226, + "ardent-shrimp-174": 529471, + "dazzling-tern-149": 529553, + "brave-grouse-738": 529644, + "quiet-raven-632": 529670, + "jovial-stork-233": 529844, + "whimsical-wildcat-530": 530256, + "spotted-anaconda-338": 530633, + "giant-schnauzer-516": 530643, + "colorless-lark-730": 530673, + "outstanding-seal-754": 530755, + "fiery-shark-444": 530917, + "disciplined-lark-76": 531197, + "astute-jay-542": 531319, + "hip-jackal-565": 531618, + "outgoing-dragon-587": 533243, + "elegant-bloodhound-563": 533979, + "perfect-mink-459": 533993, + "accurate-bird-114": 534018, + "zealous-seahorse-551": 534239, + "intent-cod-420": 534304, + "optimistic-pika-74": 534534, + "grateful-eel-163": 534573, + "ideal-raccoon-606": 534868, + "warmhearted-tortoise-357": 534932, + "amiable-lion-816": 534939, + "cheerful-marlin-395": 534940, + "aromatic-leopard-854": 534943, + "prestigious-caiman-717": 534949, + "modest-cricket-546": 534978, + "colorful-salamander-189": 534982, + "healthy-panther-931": 534991, + "lovable-jaguar-473": 534995, + "dapper-capybara-271": 535122, + "necessary-mongoose-115": 535600, + "grateful-greyhound-993": 535650, + "beaming-mole-264": 536271, + "neighborly-dog-524": 537004, + "outgoing-kudu-610": 537073, + "disciplined-mandrill-843": 537141, + "clever-dodo-829": 537209, + "opulent-gecko-499": 537241, + "quirky-clownfish-403": 537408, + "clear-iguana-537": 537617, + "rare-dodo-731": 537833, + "perceptive-pelican-812": 537990, + "neat-kingfisher-570": 538098, + "neat-dalmatian-778": 538421, + "frugal-mandrill-673": 538439, + "lovable-bullfrog-440": 538494, + "avid-llama-216": 538515, + "astute-rabbit-721": 538522, + "rosy-anteater-60": 538524, + "loyal-dodo-318": 538528, + "dependable-caribou-356": 538570, + "earnest-tiger-282": 538586, + "utmost-rooster-821": 538604, + "grandiose-elephant-945": 538610, + "tidy-bandicoot-168": 539208, + "hip-raven-115": 539561, + "veracious-rhinoceros-567": 539623, + "dutiful-bird-441": 539662, + "colorful-frog-267": 539867, + "tame-platypus-973": 540907, + "graceful-basilisk-821": 542115, + "charming-coyote-242": 542201, + "kindred-caiman-450": 542232, + "charming-crab-921": 542711, + "next-cod-385": 542899, + "cheery-wombat-691": 543146, + "amiable-okapi-95": 543603, + "energetic-octopus-218": 543731, + "clever-bat-601": 545887, + "astute-rhinoceros-770": 546331, + "optimistic-platypus-641": 546447, + "grandiose-mastiff-935": 546607, + "grandiose-clownfish-649": 546765, + "laudable-blackbird-531": 546978, + "tremendous-turtle-119": 547156, + "moonlit-seal-459": 547590, + "befitting-mole-795": 547717, + "joyous-husky-271": 547792, + "usable-crocodile-43": 547838, + "quixotic-parrot-591": 547879, + "doting-elk-536": 547948, + "formal-swordfish-6": 547956, + "ideal-bear-960": 547978, + "quirky-trout-120": 549990, + "resolute-chameleon-942": 550728, + "glad-tiger-408": 551005, + "accurate-mongoose-364": 551012, + "grandiose-lion-338": 551015, + "disciplined-cricket-557": 551030, + "outgoing-rat-581": 551048, + "dapper-deer-795": 551053, + "vibrant-chameleon-61": 551054, + "blissful-ram-525": 551092, + "cheery-impala-520": 551104, + "scrupulous-meadowlark-93": 551122, + "efficient-malamute-235": 551136, + "clean-armadillo-318": 551218, + "incredible-chicken-584": 551539, + "wooden-meerkat-160": 551544, + "wonderful-camel-234": 551705, + "giddy-panther-388": 552062, + "moonlit-grouse-271": 552545, + "grateful-avocet-270": 553271, + "amicable-mallard-595": 553934, + "robust-chicken-323": 554264, + "giddy-toucan-770": 554321, + "vivid-buffalo-610": 555003, + "hidden-mouse-220": 555195, + "industrious-toucan-60": 555291, + "reliable-starling-785": 555387, + "woozy-bulldog-356": 555665, + "prestigious-jay-609": 555694, + "watchful-jackal-842": 558168, + "wandering-mastiff-59": 558217, + "proficient-panther-337": 558337, + "rugged-ram-878": 558507, + "befitting-anaconda-444": 558541, + "adamant-starling-143": 558563, + "small-squirrel-620": 558626, + "neighborly-lobster-3": 558690, + "rosy-lyrebird-235": 558930, + "valiant-salamander-49": 560576, + "giant-goldfinch-613": 561121, + "wooden-goldfinch-106": 561320, + "industrious-lion-95": 561478, + "qualified-chipmunk-410": 561502, + "third-peacock-720": 561502, + "ardent-oriole-304": 561579, + "kindly-whale-495": 561649, + "small-aardvark-302": 561657, + "quick-sardine-893": 561837, + "mellow-porpoise-212": 561933, + "helpful-raccoon-811": 561939, + "beloved-porcupine-786": 562146, + "striped-dogfish-406": 562380, + "perceptive-porcupine-948": 562429, + "industrious-jackal-365": 562475, + "wonderful-porpoise-338": 564869, + "efficient-turtle-311": 565287, + "pastel-goldfish-658": 565734, + "helpful-bloodhound-498": 566681, + "charming-goldfinch-77": 567069, + "academic-caiman-454": 567148, + "ceaseless-fennec-704": 567461, + "bold-elk-133": 568526, + "beaming-dog-842": 569111, + "fastidious-duck-81": 569126, + "energetic-hippopotamus-112": 569546, + "hushed-peccary-504": 569597, + "accomplished-bat-449": 570322, + "combative-lobster-872": 570433, + "clever-koala-268": 570494, + "hallowed-malamute-931": 570855, + "proper-armadillo-482": 572627, + "enduring-narwhal-598": 574814, + "fabulous-anteater-264": 575557, + "vibrant-goat-919": 575809, + "incredible-chicken-198": 575810, + "woozy-woodpecker-990": 575812, + "cheery-vole-524": 576199, + "scintillating-shrimp-368": 576395, + "impartial-raccoon-868": 576406, + "outstanding-hyena-422": 576636, + "academic-albatross-759": 576710, + "perfect-pelican-106": 576819, + "quiet-reindeer-258": 577429, + "judicious-wolf-699": 577746, + "rugged-narwhal-846": 578449, + "scintillating-canary-523": 578862, + "healthy-seal-306": 579697, + "optimistic-corgi-674": 580251, + "avid-giraffe-163": 580667, + "qualified-panther-589": 580754, + "precious-goat-859": 580759, + "pastel-duck-960": 581255, + "nautical-hamster-784": 581306, + "helpful-meadowlark-211": 581964, + "agreeable-scorpion-715": 582286, + "dependable-roadrunner-173": 582310, + "strong-chicken-51": 583623, + "handsome-bulldog-369": 584001, + "tame-boar-766": 584067, + "dependable-akita-64": 584510, + "avid-alligator-968": 585250, + "adorable-chinchilla-219": 585823, + "necessary-firefly-188": 585893, + "sleek-malamute-435": 585914, + "laudable-squid-326": 585958, + "limitless-rhinoceros-951": 586089, + "honorable-mastiff-934": 586116, + "keen-pigeon-344": 586121, + "fantastic-schnauzer-396": 586167, + "disciplined-rhinoceros-996": 586170, + "cautious-sheep-985": 586179, + "neat-ermine-194": 586891, + "neighborly-dragon-820": 586893, + "third-avocet-65": 586897, + "quaint-chipmunk-597": 586930, + "bright-tortoise-249": 587062, + "usable-beagle-789": 587168, + "basic-bee-184": 587198, + "dapper-mandrill-13": 587203, + "brainy-trout-461": 587264, + "academic-seal-369": 587278, + "resilient-grasshopper-177": 587314, + "fortunate-goat-480": 587590, + "exciting-mockingbird-305": 587614, + "utmost-bloodhound-166": 587652, + "brilliant-malamute-403": 587680, + "spotted-swordfish-311": 587701, + "tough-bloodhound-713": 587718, + "utmost-mockingbird-42": 587743, + "benevolent-antelope-326": 589091, + "kindly-moose-682": 589626, + "compassionate-bison-611": 589758, + "perfect-mastiff-337": 589905, + "ardent-tapir-630": 589961, + "accurate-bison-775": 590041, + "amiable-kiwi-146": 590255, + "hidden-grasshopper-896": 590343, + "healthy-flamingo-962": 590351, + "kindly-spoonbill-817": 590383, + "handsome-gull-729": 590401, + "fleet-cricket-135": 590595, + "exuberant-ant-364": 590713, + "expert-frog-899": 591407, + "wandering-anteater-932": 592956, + "amiable-herring-743": 592964, + "cheerful-shepherd-981": 592969, + "fabulous-cheetah-193": 593094, + "brilliant-pony-103": 593342, + "quixotic-rat-768": 593428, + "outstanding-wildebeest-309": 593434, + "cheery-malamute-577": 593435, + "incredible-gnat-823": 593479, + "formal-leopard-856": 593486, + "kindly-rooster-109": 593621, + "compassionate-raven-413": 593645, + "aromatic-hippopotamus-695": 595818, + "determined-bulldog-886": 596045, + "zany-sparrow-761": 596577, + "ideal-buffalo-796": 596587, + "aware-albatross-431": 596629, + "nautical-caribou-806": 596666, + "abundant-okapi-130": 596713, + "affable-giraffe-770": 596743, + "beloved-kiwi-648": 596763, + "vibrant-leopard-153": 596769, + "third-capybara-1": 596775, + "beloved-koala-748": 596904, + "majestic-elephant-406": 596949, + "little-emu-483": 596974, + "precious-shark-805": 597001, + "fabulous-lark-392": 598350, + "astute-armadillo-468": 598986, + "animated-octopus-452": 599072, + "shocking-dog-529": 599196, + "artful-otter-289": 599257, + "youthful-ibis-879": 599746, + "pleasant-newt-758": 600014, + "honorable-skunk-892": 600202, + "cool-condor-403": 600293, + "aware-crow-947": 600326, + "industrious-sturgeon-866": 600385, + "zealous-puma-452": 600407, + "flexible-kiwi-8": 600414, + "festive-labrador-649": 600523, + "lovable-seal-890": 600612, + "mellow-parakeet-227": 600782, + "polite-pika-695": 600793, + "sleek-wren-993": 600801, + "scintillating-moose-770": 600841, + "superb-blackbird-866": 600851, + "standing-ibis-745": 601132, + "famous-deer-588": 601172, + "modest-hyena-281": 601482, + "accomplished-alligator-403": 603069, + "giant-pelican-421": 603100, + "greedy-mule-200": 603304, + "adamant-snail-203": 603372, + "tremendous-cormorant-421": 603485, + "zealous-pheasant-124": 603514, + "clever-gazelle-416": 603519, + "adamant-heron-56": 603758, + "gregarious-goose-566": 603802, + "incredible-gecko-635": 603806, + "bright-ermine-490": 603898, + "dashing-salmon-885": 603898, + "beloved-lark-981": 603983, + "clear-eel-676": 604049, + "striped-sturgeon-795": 604182, + "enchanted-spoonbill-994": 604189, + "usable-monitor-72": 604209, + "exuberant-anteater-207": 604211, + "elegant-elk-154": 604251, + "flippant-platypus-399": 604347, + "agile-roadrunner-73": 604351, + "admired-mongoose-568": 604359, + "glorious-malamute-342": 604437, + "prestigious-owl-219": 604452, + "optimistic-goshawk-808": 604474, + "shocking-cat-634": 604480, + "marvelous-ptarmigan-535": 604500, + "different-dotterel-513": 604532, + "expert-bird-407": 604758, + "vivid-parakeet-934": 604758, + "original-peccary-478": 604787, + "fiery-wolf-558": 604845, + "doting-bird-288": 605151, + "patient-civet-381": 605181, + "colorless-hawk-605": 605610, + "cheerful-civet-208": 606157, + "brilliant-skunk-69": 606159, + "different-cheetah-608": 606175, + "rugged-eagle-568": 606449, + "tangible-salmon-371": 606545, + "accurate-bobcat-280": 606717, + "healthy-gopher-810": 607044, + "clean-condor-536": 607120, + "majestic-bandicoot-214": 607126, + "fantastic-llama-465": 607829, + "courteous-jackal-645": 607932, + "expert-cod-40": 607940, + "woozy-peccary-862": 607958, + "prestigious-terrier-195": 608276, + "fiery-echidna-762": 608449, + "fast-dove-814": 608452, + "shocking-rooster-769": 608500, + "merry-starling-389": 609488, + "adjoining-dalmatian-400": 610131, + "hip-gnat-159": 610204, + "deafening-kiwi-93": 610208, + "artful-hound-645": 610223, + "beaming-gnat-498": 610446, + "hallowed-ptarmigan-900": 610483, + "cautious-gopher-731": 610734, + "quiet-ibis-914": 610734, + "secret-swordfish-903": 610907, + "rosy-vulture-423": 610949, + "adamant-chameleon-4": 611093, + "wry-alligator-971": 611123, + "adorable-fly-130": 611160, + "steady-finch-233": 611228, + "dashing-ram-190": 611228, + "affable-weasel-937": 611240, + "oceanic-bulldog-591": 611255, + "scintillating-avocet-334": 611255, + "moonlit-duck-905": 611256, + "stoic-perch-872": 611259, + "stoic-alligator-795": 611259, + "zany-panda-350": 611273, + "adorable-dog-690": 611273, + "kindly-bird-210": 611276, + "famous-panther-453": 611276, + "optimistic-gnu-321": 611619, + "successful-lemming-40": 611659, + "precise-goshawk-990": 611676, + "good-wolf-571": 612134, + "uncommon-bee-730": 612200, + "adorable-turtle-158": 612717, + "sincere-fox-373": 613474, + "striped-chicken-837": 613681, + "scintillating-tiger-55": 613806, + "handsome-ladybug-723": 614486, + "rightful-perch-831": 614544, + "disciplined-oyster-566": 614878, + "sleek-bee-210": 615023, + "upbeat-hawk-498": 615023, + "pastel-gnu-411": 615059, + "beloved-meerkat-348": 615081, + "youthful-sardine-823": 615081, + "precise-snake-67": 618110, + "grateful-impala-211": 618211, + "industrious-pig-842": 618744, + "posh-spider-96": 618753, + "cheerful-magpie-701": 619298, + "cautious-caribou-220": 619360, + "disciplined-dove-664": 619363, + "lovable-ram-309": 619516, + "accurate-meerkat-384": 619697, + "acoustic-bandicoot-347": 619722, + "hip-partridge-84": 621295, + "scintillating-caterpillar-489": 621295, + "successful-caterpillar-482": 622024, + "beloved-kookabura-968": 622222, + "academic-snake-938": 622222, + "nautical-schnauzer-675": 622260, + "reminiscent-peacock-801": 622260, + "sleek-orca-648": 622338, + "different-ferret-633": 624577, + "rare-parakeet-777": 624829, + "loyal-okapi-87": 625268, + "woozy-bass-376": 625426, + "agreeable-civet-12": 625755, + "quixotic-mockingbird-576": 625901, + "usable-newt-444": 625901, + "capable-wolverine-565": 626063, + "fastidious-narwhal-814": 626174, + "opulent-chinchilla-435": 626291, + "honorable-possum-452": 626457, + "flippant-mallard-176": 626564, + "sensible-gerbil-841": 626564, + "valiant-giraffe-966": 626596, + "rare-moose-21": 627012, + "agreeable-seal-217": 627077, + "exuberant-wildebeest-880": 627100, + "exciting-toucan-653": 627100, + "greedy-setter-589": 627129, + "oceanic-whale-225": 627363, + "moonlit-tern-302": 627363, + "neighborly-wolverine-738": 627491, + "elated-dragon-679": 627530, + "doting-jay-944": 627561, + "proficient-cat-811": 627652, + "cheerful-warbler-19": 627658, + "hushed-pony-85": 627672, + "deafening-peccary-660": 627755, + "kindhearted-starfish-576": 627938, + "good-dragon-981": 627938, + "brainy-cricket-757": 627952, + "clean-frog-186": 628082, + "brainy-fennec-76": 628083, + "successful-oyster-546": 628088, + "vivid-cow-573": 628120, + "resilient-warthog-232": 628137, + "woozy-chihuahua-221": 628185, + "enchanted-goshawk-148": 628197, + "brazen-spider-901": 628330, + "brainy-goose-433": 628353, + "opulent-sheep-193": 628405, + "merry-mink-409": 628414, + "little-caterpillar-172": 628433, + "marvelous-gnat-53": 628496, + "descriptive-toucan-381": 628517, + "quirky-mandrill-755": 628564, + "adjoining-hornet-738": 628608, + "cheery-marlin-903": 628621, + "unique-chihuahua-846": 628645, + "resolute-otter-491": 628660, + "clean-chameleon-566": 628712, + "peaceful-skunk-987": 628720, + "frugal-grouse-109": 628752, + "aromatic-gopher-819": 628763, + "tangible-manatee-743": 628804, + "dependable-ram-278": 628849, + "fiery-dragon-38": 628874, + "efficient-meerkat-525": 628875, + "gallant-roadrunner-304": 628894, + "incredible-shepherd-341": 628961, + "graceful-pig-850": 628961, + "impressive-sandpiper-683": 628974, + "valiant-fox-366": 628974, + "rare-newt-577": 628992, + "flexible-blackbird-238": 629011, + "flippant-reindeer-32": 629103, + "rapid-labrador-814": 629116, + "acoustic-jaguar-512": 629157, + "confident-jaguar-344": 629163, + "brave-whale-906": 629190, + "rugged-pika-895": 629356, + "hearty-donkey-159": 629804, + "veracious-albatross-554": 629901, + "agile-horse-735": 630149, + "abundant-eel-104": 630149, + "pleasant-ibex-585": 630692, + "intent-wombat-931": 630745, + "mild-greyhound-632": 630773, + "merry-albatross-204": 630907, + "precious-sheep-977": 631008, + "adorable-stork-779": 631250, + "effervescent-rat-401": 631302, + "wooden-crow-843": 631491, + "colorless-buzzard-562": 631491, + "fantastic-clam-284": 631513, + "aromatic-narwhal-514": 631515, + "groovy-goshawk-310": 631608, + "rosy-salmon-318": 631724, + "honorable-marmot-465": 631797, + "spotted-coyote-946": 631809, + "trustworthy-setter-408": 631832, + "curious-condor-467": 631838, + "majestic-opossum-654": 631908, + "dutiful-setter-831": 632079, + "healthy-parrot-72": 632079, + "tame-goat-440": 632126, + "careful-barracuda-837": 632214, + "unique-kiwi-955": 632229, + "sincere-rook-421": 632245, + "doting-platypus-585": 632304, + "little-oyster-819": 632305, + "greedy-chicken-915": 632325, + "calculating-mockingbird-185": 632413, + "wry-crocodile-52": 632511, + "first-caiman-784": 632696, + "majestic-parrot-610": 632766, + "basic-ferret-70": 633288, + "secret-chicken-614": 633364, + "colorless-ant-993": 633585, + "blissful-kingfisher-216": 633733, + "usable-bass-242": 634006, + "astute-cricket-16": 634008, + "lovely-buzzard-454": 634195, + "brazen-akita-178": 634237, + "cool-goose-710": 634362, + "graceful-lynx-559": 634362, + "handsome-opossum-184": 634626, + "scintillating-hamster-710": 634701, + "quirky-greyhound-662": 634731, + "rugged-moose-154": 634731, + "acrobatic-iguana-686": 634757, + "proficient-grasshopper-242": 634773, + "affable-ladybug-320": 634881, + "animated-skunk-752": 634953, + "befitting-dove-62": 635037, + "wary-starling-871": 635128, + "watchful-newt-18": 635272, + "flexible-bee-117": 635275, + "adept-chameleon-888": 635429, + "accurate-ibis-572": 635435, + "enchanted-jay-971": 635438, + "courteous-deer-89": 635439, + "enduring-puffin-717": 635519, + "colorless-kangaroo-905": 635591, + "outstanding-fish-302": 635608, + "useful-hyena-490": 635622, + "sleek-fennec-861": 635623, + "limitless-cormorant-988": 635631, + "superb-gerbil-569": 635647, + "quick-schnauzer-587": 635759, + "formal-fly-135": 635804, + "grand-llama-928": 635861, + "fabulous-flamingo-233": 635861, + "precise-zebra-507": 636001, + "rare-mosquito-273": 636057, + "optimistic-kudu-153": 636057, + "sincere-seahorse-625": 636064, + "kindhearted-newt-115": 636122, + "upbeat-platypus-187": 636125, + "courteous-lapwing-495": 636198, + "moonlit-oriole-121": 636236, + "adept-dodo-382": 636269, + "basic-tapir-121": 636310, + "energetic-lapwing-57": 636310, + "beloved-fly-583": 636342, + "glad-cormorant-421": 636416, + "laudable-marten-436": 636468, + "ardent-barracuda-265": 636671, + "tough-lapwing-251": 636750, + "robust-otter-114": 636790, + "resilient-leopard-305": 636866, + "deafening-wolf-786": 637623, + "outstanding-partridge-269": 637782, + "academic-labrador-655": 638055, + "wary-bass-462": 638112, + "fastidious-swordfish-806": 638214, + "nautical-peccary-921": 638276, + "next-albatross-58": 638331, + "useful-scorpion-98": 638617, + "vivid-panther-6": 638679, + "cheery-goldfinch-156": 639494, + "diligent-tern-865": 639557, + "befitting-platypus-127": 639558, + "incredible-mandrill-389": 639558, + "adventurous-lapwing-491": 639688, + "abundant-gnat-58": 639709, + "colorless-mandrill-676": 639731, + "acoustic-orca-706": 639734, + "third-crab-488": 639762, + "sincere-mouse-571": 639802, + "wonderful-kingfisher-502": 639802, + "kindhearted-tapir-2": 639834, + "dusty-reindeer-808": 639851, + "quaint-cod-753": 639890, + "merry-warthog-423": 639895, + "exciting-panda-659": 639924, + "adjoining-swordfish-509": 639926, + "combative-axolotl-58": 639931, + "steady-meadowlark-635": 639935, + "hushed-parakeet-107": 639939, + "modest-chinchilla-440": 639944, + "decisive-anteater-418": 640029, + "accurate-spider-642": 640208, + "formal-okapi-465": 640229, + "next-bass-69": 640247, + "scrupulous-reindeer-666": 640248, + "elated-badger-207": 640248, + "cheery-ostrich-299": 640276, + "scrupulous-rooster-75": 640276, + "enchanted-chinchilla-11": 640287, + "brazen-hedgehog-886": 640359, + "adorable-civet-534": 640399, + "exciting-weasel-422": 640399, + "accomplished-firefly-82": 640478, + "marvelous-raccoon-217": 640507, + "wonderful-scorpion-327": 640522, + "moonlit-alpaca-361": 640579, + "neat-rooster-729": 640630, + "tacit-starfish-453": 640714, + "pleasant-cricket-573": 640795, + "adept-giraffe-413": 640852, + "quaint-cuttlefish-377": 640889, + "prestigious-shepherd-78": 640893, + "knowing-lark-585": 640893, + "greedy-echidna-947": 640959, + "reliable-duck-975": 641054, + "neighborly-spoonbill-449": 641492, + "agreeable-penguin-917": 641493, + "tacit-ibex-330": 641493, + "colorless-mandrill-970": 641516, + "tangible-okapi-521": 641519, + "cheerful-orca-49": 641526, + "tangible-nightingale-168": 641526, + "notable-gnat-427": 641744, + "impartial-shepherd-660": 641745, + "benevolent-ferret-300": 641821, + "mellow-roadrunner-932": 641899, + "quick-mammoth-149": 642070, + "spotted-walrus-220": 642125, + "watchful-guineapig-924": 642196, + "whimsical-jellyfish-594": 642296, + "small-herring-719": 642362, + "vivid-eagle-811": 642378, + "mellow-ptarmigan-501": 642404, + "formal-boar-275": 642425, + "artful-kingfisher-452": 642641, + "lovely-bulldog-312": 642684, + "grandiose-sturgeon-628": 642689, + "festive-crab-10": 642697, + "wary-salamander-45": 642699, + "avid-mongoose-638": 642699, + "fabulous-lion-288": 642747, + "utmost-salmon-96": 642784, + "shocking-lemur-265": 642830, + "youthful-labrador-577": 642965, + "perceptive-peccary-341": 642998, + "quick-barracuda-934": 643063, + "merry-tern-377": 643158, + "befitting-dog-404": 643230, + "cool-firefly-643": 643330, + "wonderful-okapi-434": 643342, + "exuberant-ferret-860": 643396, + "exuberant-eel-806": 643396, + "marvelous-elk-96": 643803, + "handsome-firefly-261": 644125, + "glad-jaguar-725": 644296, + "hip-mink-255": 644296, + "harmless-dragon-763": 644334, + "quick-alligator-462": 644497, + "gregarious-dove-473": 644841, + "dutiful-porpoise-695": 644964, + "steady-pheasant-7": 644964, + "steady-condor-627": 645008, + "festive-vole-684": 645374, + "terrific-monitor-742": 645395, + "optimistic-basilisk-966": 645479, + "hip-cod-703": 645622, + "notable-ladybug-680": 645666, + "resilient-lapwing-734": 645708, + "efficient-corgi-388": 645708, + "tame-swordfish-816": 645984, + "affable-pelican-726": 646243, + "secret-labrador-757": 646243, + "healthy-tapir-319": 646243, + "industrious-anaconda-242": 646312, + "dependable-gecko-339": 646403, + "savory-mole-741": 646411, + "utmost-flamingo-407": 646470, + "enduring-scorpion-270": 646473, + "prestigious-camel-156": 646673, + "spotted-elephant-551": 646707, + "perfect-porpoise-778": 647049, + "abundant-chicken-736": 647497, + "qualified-stork-934": 647536, + "sleek-manatee-332": 647563, + "tame-panda-293": 647570, + "spotted-finch-852": 647570, + "brave-pika-281": 647737, + "keen-tapir-626": 647776, + "stoic-bison-286": 647827, + "efficient-gerbil-867": 647921, + "compassionate-lyrebird-729": 648022, + "posh-marlin-82": 648032, + "zealous-parrot-984": 648038, + "giddy-lyrebird-211": 648045, + "prestigious-puma-816": 648092, + "dutiful-husky-990": 648099, + "uncommon-pheasant-353": 648717, + "tough-marlin-187": 648762, + "original-cassowary-625": 648890, + "famous-hyena-166": 648936, + "brazen-tortoise-204": 648972, + "curious-sandpiper-981": 649431, + "fortunate-seahorse-531": 649605, + "amicable-dogfish-400": 649646, + "clear-greyhound-736": 649646, + "useful-warthog-744": 649650, + "zealous-firefly-156": 649658, + "curious-bat-639": 649701, + "careful-lobster-891": 649880, + "dutiful-wren-145": 649940, + "limitless-bandicoot-946": 649977, + "youthful-bullfrog-698": 649991, + "zealous-ibex-374": 650046, + "dashing-fly-817": 650088, + "festive-rabbit-405": 650164, + "dutiful-quail-474": 650164, + "effervescent-goldfinch-682": 650178, + "opulent-shepherd-783": 650197, + "befitting-ptarmigan-672": 650484, + "courteous-dotterel-444": 650652, + "merry-marlin-107": 650770, + "pleasant-seahorse-8": 650779, + "gallant-roadrunner-508": 650779, + "befitting-tortoise-641": 650785, + "canny-wildebeest-932": 650804, + "earnest-blackbird-856": 650977, + "calculating-viper-450": 650978, + "fast-porcupine-621": 651256, + "tremendous-fly-340": 651346, + "disciplined-lobster-588": 651872, + "giddy-dinosaur-706": 651995, + "insightful-hamster-326": 651999, + "wooden-flamingo-331": 652007, + "aromatic-bass-950": 652034, + "festive-raven-801": 652051, + "hushed-ptarmigan-443": 652072, + "courteous-corgi-576": 652179, + "incredible-duck-112": 652200, + "steady-elk-980": 652519, + "steady-grasshopper-680": 652733, + "enchanted-swan-362": 652781, + "determined-giraffe-875": 652809, + "utmost-starling-647": 652834, + "proficient-marten-252": 652851, + "savory-bloodhound-61": 652923, + "befitting-setter-210": 652984, + "kindly-tern-180": 652987, + "good-bear-520": 652987, + "blissful-cuttlefish-255": 653243, + "quiet-hippopotamus-712": 653295, + "precious-mammoth-19": 653359, + "gallant-kangaroo-687": 653420, + "curious-shark-293": 653466, + "terrific-shrimp-712": 653636, + "adjoining-scorpion-359": 653716, + "terrific-ibis-207": 654091, + "rare-sparrow-837": 654189, + "loyal-marmot-904": 654401, + "stoic-orca-356": 654436, + "hip-curlew-592": 654444, + "trustworthy-marmot-669": 654540, + "pastel-eel-876": 654746, + "capable-sardine-359": 654782, + "fearless-greyhound-544": 654812, + "utmost-kookabura-944": 654812, + "useful-alpaca-652": 654826, + "uncommon-koala-182": 654832, + "cheery-skunk-174": 654873, + "deafening-porpoise-463": 654928, + "marvelous-porcupine-417": 654928, + "confident-toucan-626": 656222, + "beloved-mosquito-752": 656785, + "watchful-stoat-242": 656909, + "adorable-herring-281": 656930, + "small-finch-909": 657953, + "dusty-alpaca-698": 658136, + "agile-mockingbird-207": 658394, + "abundant-cow-990": 658394, + "opulent-vulture-734": 658502, + "savory-ostrich-15": 658702, + "befitting-finch-679": 658983, + "reminiscent-bulldog-406": 659100, + "beaming-snail-570": 659100, + "dynamic-snake-424": 659154, + "lovely-ferret-575": 659909, + "adept-anteater-816": 660759, + "quiet-herring-133": 660770, + "effervescent-panda-646": 660833, + "beloved-impala-982": 660946, + "first-beagle-810": 660946, + "resolute-stork-548": 660961, + "curious-starling-377": 661019, + "acrobatic-sheep-750": 661154, + "resolute-ram-858": 661469, + "kindly-walrus-600": 661469, + "wary-impala-217": 661687, + "good-spider-48": 661687, + "optimistic-caiman-762": 663720, + "lovable-peacock-490": 664236, + "reliable-herring-986": 664236, + "brazen-mink-951": 664243, + "careful-malamute-684": 664327, + "honorable-camel-123": 664327, + "striped-lynx-673": 664352, + "colorless-trout-687": 664408, + "shocking-dog-490": 664432, + "tame-cat-466": 664802, + "joyous-dogfish-565": 664860, + "tremendous-alpaca-842": 665003, + "patient-malamute-441": 665094, + "whimsical-chickadee-368": 665242, + "trustworthy-possum-255": 665326, + "robust-pika-2": 665326, + "acoustic-lapwing-73": 665331, + "brazen-gecko-610": 665338, + "energetic-eagle-411": 665343, + "scintillating-lapwing-192": 665451, + "polished-kangaroo-839": 665455, + "prestigious-frog-472": 665665, + "healthy-blackbird-348": 665718, + "modest-gerbil-591": 665723, + "superb-mallard-678": 665859, + "different-turtle-473": 666230, + "vivid-husky-713": 666249, + "proper-stingray-417": 666249, + "rugged-mockingbird-8": 666441, + "rapid-mallard-181": 666618, + "brilliant-capybara-765": 666738, + "canny-kudu-973": 666738, + "hardy-stingray-920": 666767, + "cautious-wren-433": 666792, + "doting-robin-745": 666857, + "neat-malamute-129": 666868, + "loyal-condor-580": 666878, + "beaming-aardvark-180": 666890, + "dusty-walrus-966": 666897, + "grand-meerkat-115": 666897, + "enduring-dodo-723": 666898, + "silent-woodpecker-259": 666967, + "blissful-salmon-997": 666991, + "secret-crab-173": 666992, + "tame-hamster-898": 667020, + "keen-civet-961": 667021, + "calculating-partridge-50": 667038, + "benevolent-stoat-505": 667071, + "chatty-jackal-804": 667119, + "wandering-possum-127": 667131, + "small-hummingbird-749": 667131, + "notable-peacock-850": 667234, + "determined-hornet-825": 667294, + "combative-aardvark-974": 668052, + "dutiful-dove-22": 668052, + "combative-lemur-468": 668161, + "quirky-buzzard-69": 668243, + "fastidious-opossum-372": 668252, + "tangible-pika-753": 668252, + "woozy-stoat-998": 668257, + "doting-woodpecker-485": 668257, + "chatty-emu-927": 668379, + "hidden-nightingale-876": 668389, + "hushed-bear-581": 668425, + "admired-puffin-162": 668474, + "glad-firefly-576": 668563, + "woozy-mosquito-151": 668569, + "uncommon-marlin-866": 668579, + "scintillating-mandrill-894": 668625, + "tough-moose-615": 668637, + "first-reindeer-277": 668667, + "calculating-anaconda-935": 668694, + "hip-swan-230": 668696, + "effervescent-narwhal-174": 668732, + "stoic-giraffe-141": 668735, + "tidy-raven-10": 668752, + "valuable-ptarmigan-870": 669107, + "courteous-woodpecker-433": 669226, + "tough-impala-509": 669607, + "elegant-narwhal-422": 669630, + "clean-wren-511": 669630, + "intent-dolphin-895": 669696, + "proper-lark-302": 669730, + "bold-hawk-549": 669762, + "healthy-sockeye-614": 670172, + "stoic-dotterel-21": 670437, + "combative-fox-905": 670447, + "neat-peccary-751": 670457, + "aware-guanaco-774": 670457, + "upbeat-sardine-91": 670527, + "grand-elk-970": 670589, + "successful-crab-512": 670594, + "aware-lion-981": 670596, + "ceaseless-mink-995": 670597, + "formal-rhinoceros-574": 670684, + "kindly-rat-11": 670684, + "shocking-barracuda-342": 670984, + "wry-crab-238": 670984, + "third-grouse-359": 671227, + "kindred-lark-811": 671227, + "chatty-civet-592": 671228, + "cheery-chicken-161": 671385, + "oceanic-tiger-448": 671410, + "laudable-orca-192": 671420, + "majestic-chipmunk-845": 671500, + "fiery-yak-800": 671544, + "dependable-trout-165": 671604, + "kindhearted-mouse-382": 671632, + "amicable-cobra-461": 671851, + "small-ant-393": 671986, + "rugged-wildebeest-347": 671986, + "stoic-okapi-553": 672026, + "earnest-grouse-304": 672227, + "necessary-guanaco-850": 672283, + "terrific-giraffe-77": 672376, + "brazen-raven-265": 672527, + "outstanding-mongoose-29": 672531, + "polite-chicken-835": 673533, + "healthy-skunk-901": 673647, + "beaming-octopus-710": 674322, + "hearty-lemur-410": 674324, + "academic-mole-376": 674345, + "loyal-basilisk-591": 674345, + "frugal-goldfish-414": 674348, + "bright-nightingale-994": 674348, + "kindhearted-dodo-779": 674361, + "vivid-ibis-818": 674384, + "giant-cod-772": 675330, + "fabulous-leopard-914": 675352, + "compassionate-pigeon-588": 675369, + "cautious-minnow-495": 675371, + "amiable-bear-694": 675379, + "descriptive-cow-816": 675381, + "glorious-husky-570": 675401, + "enduring-magpie-205": 675404, + "amicable-greyhound-70": 675569, + "uncommon-oyster-465": 675589, + "dapper-stingray-521": 675597, + "joyous-labrador-699": 675607, + "oceanic-axolotl-54": 675614, + "trustworthy-badger-880": 675620, + "peaceful-mongoose-477": 675634, + "academic-chameleon-700": 675722, + "pleasant-heron-795": 675738, + "energized-cassowary-287": 675740, + "chatty-elk-254": 675743, + "knowing-narwhal-229": 675780, + "rugged-basilisk-34": 675782, + "neat-axolotl-910": 675810, + "mellow-platypus-513": 675816, + "coordinated-stork-831": 675825, + "groovy-pigeon-233": 675849, + "robust-bee-245": 675870, + "quixotic-dalmatian-611": 676420, + "fantastic-mastiff-179": 676443, + "joyous-husky-421": 676455, + "intent-camel-732": 676459, + "bold-panther-645": 676476, + "steady-vulture-555": 676816, + "gallant-caribou-758": 676819, + "striped-finch-404": 676820, + "little-husky-267": 676845, + "sensible-swan-3": 676862, + "necessary-cod-506": 676863, + "insightful-anteater-301": 677037, + "majestic-horse-205": 677046, + "fortunate-zebra-743": 677048, + "warmhearted-avocet-529": 677116, + "tidy-sardine-895": 677127, + "tame-echidna-919": 677265, + "neat-warbler-819": 677266, + "fantastic-jellyfish-604": 677270, + "fortunate-lobster-114": 677420, + "tame-lynx-889": 677424, + "rightful-gnu-218": 677427, + "focused-octopus-599": 677624, + "optimistic-bear-830": 677627, + "reminiscent-guineapig-95": 677630, + "valuable-salmon-852": 677642, + "accomplished-basilisk-95": 677658, + "third-tern-18": 678101, + "exuberant-canary-837": 678138, + "lovely-zebra-967": 678142, + "insightful-bobcat-279": 678176, + "grand-octopus-840": 678182, + "savory-impala-507": 678191, + "bright-anaconda-293": 678193, + "gallant-sturgeon-907": 678195, + "peaceful-civet-619": 678218, + "dutiful-dogfish-716": 678218, + "oceanic-labrador-471": 678219, + "youthful-aardvark-118": 678220, + "wooden-gerbil-895": 678220, + "fine-gopher-519": 678221, + "acoustic-sandpiper-147": 678240, + "giddy-camel-792": 678261, + "kindly-otter-503": 678472, + "polished-pheasant-750": 678614, + "prestigious-goat-191": 678690, + "fast-barracuda-708": 678693, + "polite-ladybug-65": 678767, + "unique-hamster-632": 678976, + "prestigious-donkey-511": 678977, + "adept-panther-569": 678979, + "disciplined-rhinoceros-40": 678979, + "utmost-canary-375": 678980, + "quick-ladybug-405": 679022, + "disciplined-mockingbird-618": 679039, + "dazzling-blackbird-264": 679039, + "uncommon-ptarmigan-375": 679040, + "tangible-rook-908": 679299, + "astute-mandrill-172": 679306, + "amiable-husky-898": 679582, + "brainy-bloodhound-652": 679714, + "amiable-bass-222": 680037, + "good-sparrow-131": 680049, + "pleasant-chihuahua-225": 680050, + "shiny-fly-576": 680054, + "shocking-meadowlark-626": 680099, + "limitless-oyster-397": 680099, + "basic-viper-129": 680153, + "frugal-elk-401": 680200, + "capable-cow-406": 680200, + "brainy-gnat-847": 680309, + "artful-squirrel-713": 680464, + "helpful-jackal-203": 680919, + "careful-falcon-931": 681212, + "grandiose-wildcat-240": 681212, + "pastel-jellyfish-855": 681827, + "courteous-ladybug-752": 681827, + "vibrant-capybara-31": 681999, + "decisive-toad-598": 682004, + "exciting-eel-279": 682004, + "giddy-cheetah-83": 682089, + "adventurous-kiwi-778": 682394, + "wry-whale-264": 682394, + "strong-trout-64": 682546, + "deafening-pika-326": 682557, + "quixotic-vulture-186": 682794, + "outstanding-cow-391": 683001, + "kindred-bulldog-903": 683004, + "warmhearted-sturgeon-442": 683046, + "hushed-kookabura-544": 683225, + "loyal-deer-291": 683225, + "fearless-lynx-906": 683330, + "beloved-rhinoceros-217": 683479, + "tremendous-duck-830": 683479, + "focused-goshawk-280": 683481, + "enchanted-mongoose-330": 683481, + "blessed-cod-964": 683787, + "aware-gnat-45": 683788, + "festive-shark-261": 683789, + "hearty-lynx-631": 683790, + "uncommon-robin-209": 683791, + "shocking-newt-889": 683792, + "grateful-perch-287": 683992, + "befitting-sheep-513": 684024, + "reliable-meerkat-9": 684052, + "precise-duck-972": 684124, + "deafening-bee-219": 684236, + "fearless-echidna-350": 684395, + "bright-dove-359": 684395, + "sensible-lynx-375": 684409, + "grand-peccary-300": 684426, + "friendly-alligator-654": 684438, + "decisive-rooster-428": 684521, + "neat-curlew-412": 684552, + "fantastic-jaguar-183": 684576, + "elegant-mink-915": 684619, + "abundant-mouse-80": 684683, + "kindred-cricket-196": 684706, + "cheerful-cod-984": 684720, + "content-gnu-462": 684816, + "adorable-emu-133": 684816, + "resolute-peccary-412": 684858, + "good-squid-891": 684858, + "lovable-okapi-766": 684865, + "enchanted-hare-874": 684872, + "grand-fish-312": 684898, + "efficient-walrus-116": 684904, + "precious-narwhal-489": 684930, + "adjoining-condor-37": 684965, + "incredible-egret-880": 684970, + "outgoing-eagle-49": 685016, + "coordinated-grouse-692": 685016, + "graceful-shrimp-325": 685043, + "outstanding-pheasant-373": 685071, + "outgoing-llama-890": 685149, + "giant-gopher-201": 685202, + "striped-kingfisher-839": 685298, + "perfect-dodo-579": 685298, + "gregarious-bandicoot-680": 685327, + "posh-tortoise-351": 685450, + "rare-schnauzer-89": 685518, + "aware-octopus-558": 685530, + "wonderful-salmon-392": 685544, + "fastidious-iguana-501": 685550, + "terrific-partridge-95": 685568, + "beloved-giraffe-909": 685579, + "utmost-swan-484": 685582, + "diligent-eagle-48": 685619, + "careful-peacock-680": 685721, + "amiable-ladybug-708": 685878, + "majestic-goldfinch-512": 686024, + "giant-loris-81": 686024, + "wary-crab-283": 686212, + "basic-tortoise-472": 686228, + "fiery-llama-552": 686281, + "aware-hippopotamus-100": 686431, + "energetic-dachshund-725": 686561, + "lovable-antelope-662": 686599, + "nautical-terrier-217": 686648, + "academic-rhinoceros-855": 686649, + "trustworthy-porpoise-942": 686662, + "harmless-gecko-539": 686664, + "fine-yak-778": 686666, + "sincere-spider-477": 686675, + "helpful-wildebeest-890": 686675, + "deafening-wolf-732": 686679, + "wooden-oriole-318": 686685, + "content-egret-545": 686685, + "whimsical-salmon-749": 686953, + "dazzling-poodle-929": 687008, + "first-eagle-286": 687014, + "gregarious-mosquito-563": 687014, + "incredible-falcon-382": 687171, + "sincere-schnauzer-786": 687171, + "handsome-mongoose-223": 687241, + "useful-lark-592": 687266, + "clean-hedgehog-423": 687358, + "hearty-weasel-517": 687700, + "brave-stork-97": 687803, + "cool-vulture-245": 687809, + "tacit-gull-940": 687840, + "acoustic-minnow-715": 687867, + "zealous-robin-115": 687944, + "dependable-pigeon-26": 688030, + "judicious-ostrich-355": 688207, + "majestic-guanaco-307": 688207, + "fantastic-ant-788": 688314, + "kindly-pigeon-161": 688350, + "woozy-dragon-749": 688438, + "fiery-hamster-405": 688438, + "combative-wolverine-184": 688507, + "clean-capybara-479": 688523, + "coordinated-grouse-871": 688551, + "utmost-meadowlark-8": 688553, + "glorious-echidna-985": 688553, + "polished-badger-367": 688583, + "rugged-warthog-308": 688615, + "bright-llama-314": 688646, + "dashing-vulture-944": 688685, + "harmless-iguana-474": 688841, + "first-chameleon-773": 688869, + "diligent-seal-119": 688873, + "robust-whale-958": 689022, + "valuable-husky-316": 689022, + "adamant-iguana-138": 689059, + "scintillating-sheep-94": 689674, + "graceful-chicken-531": 689675, + "energized-marmot-676": 689675, + "ceaseless-goshawk-194": 689676, + "wooden-minnow-119": 689676, + "fine-goldfinch-252": 689677, + "adept-magpie-817": 689775, + "wandering-newt-971": 689830, + "wonderful-clam-156": 689862, + "compassionate-pigeon-511": 690005, + "beloved-blackbird-552": 690025, + "perfect-pigeon-994": 690028, + "acoustic-setter-410": 690080, + "dapper-mole-624": 690107, + "tidy-hippopotamus-270": 690109, + "focused-greyhound-255": 690112, + "next-hyena-709": 690120, + "useful-mastiff-63": 690131, + "woozy-dogfish-593": 690135, + "combative-magpie-69": 690140, + "handsome-mastiff-406": 690193, + "incredible-stingray-555": 690204, + "quaint-okapi-260": 690206, + "pleasant-wolf-759": 690237, + "rugged-gull-307": 690266, + "dapper-squid-502": 690285, + "reminiscent-butterfly-973": 690454, + "efficient-wildebeest-939": 690455, + "deafening-jaguar-805": 690506, + "brilliant-cheetah-710": 690506, + "clear-chinchilla-135": 690511, + "gallant-goat-162": 690511, + "shocking-dogfish-188": 690801, + "joyous-mule-257": 691060, + "steady-chinchilla-633": 691060, + "artful-bison-540": 691757, + "jovial-kangaroo-885": 691945, + "avid-rooster-326": 692048, + "fantastic-kiwi-193": 692048, + "intent-sheep-476": 692277, + "wary-poodle-707": 692302, + "spotted-blackbird-938": 692527, + "dusty-civet-719": 692613, + "ceaseless-avocet-50": 692678, + "benevolent-whale-647": 692825, + "coordinated-ladybug-836": 692825, + "proper-monitor-755": 693650, + "steady-bandicoot-664": 693650, + "upbeat-alpaca-214": 693862, + "expert-sparrow-458": 693868, + "kindly-cormorant-936": 693874, + "outstanding-dinosaur-8": 693876, + "basic-caterpillar-775": 693881, + "robust-gecko-686": 693943, + "resolute-ferret-250": 693943, + "incredible-rooster-518": 694061, + "sleek-wren-673": 694134, + "successful-sockeye-715": 694134, + "pastel-dodo-689": 694475, + "zealous-aardvark-628": 694483, + "reliable-iguana-61": 694820, + "resilient-coyote-765": 694927, + "tame-pigeon-362": 695167, + "optimistic-cardinal-365": 696009, + "steady-tapir-508": 696674, + "decisive-fox-622": 696687, + "rightful-aardvark-264": 696722, + "beloved-chinchilla-189": 696745, + "silent-capybara-456": 696863, + "notable-raven-381": 697009, + "coordinated-condor-290": 697023, + "usable-peccary-281": 697100, + "brainy-raccoon-541": 697212, + "basic-magpie-49": 697214, + "third-goldfinch-729": 697273, + "rosy-shark-756": 698026, + "brilliant-ptarmigan-362": 698026, + "tame-bulldog-831": 698595, + "groovy-bee-858": 698602, + "festive-antelope-438": 698602, + "bold-bee-942": 698622, + "perceptive-gecko-309": 698735, + "fleet-corgi-433": 698862, + "resolute-oriole-624": 698999, + "fastidious-opossum-588": 699128, + "content-spoonbill-722": 699152, + "greedy-elephant-837": 699697, + "deafening-snail-939": 699739, + "good-chicken-247": 699739, + "woozy-leopard-239": 699775, + "effervescent-lyrebird-911": 699842, + "acoustic-zebra-463": 699890, + "woozy-anteater-364": 699890, + "amicable-mockingbird-49": 699893, + "fast-manatee-963": 700026, + "little-orca-914": 700026, + "nautical-shrimp-797": 700054, + "clean-grasshopper-479": 700081, + "accurate-mammoth-867": 700399, + "cautious-seal-801": 700668, + "friendly-ptarmigan-735": 700743, + "brainy-curlew-271": 701354, + "hearty-panther-493": 701597, + "calculating-ox-524": 701597, + "descriptive-magpie-223": 701620, + "reminiscent-spoonbill-126": 701620, + "opulent-ox-327": 701638, + "peaceful-spoonbill-759": 701730, + "enchanted-tapir-911": 701779, + "adjoining-cricket-422": 702682, + "majestic-grasshopper-380": 702899, + "descriptive-moose-462": 702941, + "scrupulous-narwhal-15": 702941, + "capable-ibex-385": 702946, + "shiny-ibex-867": 702946, + "little-squirrel-362": 703379, + "adorable-dotterel-923": 703523, + "outstanding-civet-203": 703631, + "utmost-peccary-895": 703631, + "secret-pony-678": 703658, + "pleasant-husky-992": 703658, + "canny-mouse-387": 703778, + "valiant-mule-354": 704167, + "keen-sparrow-858": 704167, + "dynamic-sparrow-214": 704735, + "jovial-deer-316": 704956, + "tacit-chihuahua-794": 706095, + "original-squirrel-197": 706095, + "dashing-ladybug-887": 706171, + "accurate-elk-311": 706257, + "admired-ox-206": 706310, + "glad-capybara-808": 706310, + "good-eel-649": 706567, + "calm-octopus-699": 706581, + "helpful-avocet-215": 707169, + "precious-turtle-882": 707243, + "brave-chinchilla-840": 707243, + "moonlit-stork-813": 707245, + "frugal-lemur-351": 707268, + "dynamic-dodo-916": 707293, + "amicable-toad-311": 707396, + "beloved-turtle-846": 707734, + "exuberant-salamander-612": 707960, + "glad-wildebeest-377": 707960, + "adjoining-bulldog-862": 707964, + "qualified-ermine-54": 710107, + "polished-fly-75": 710108, + "handsome-condor-725": 710110, + "helpful-dolphin-585": 710110, + "kindhearted-marmot-442": 710513, + "wary-hummingbird-644": 710652, + "calculating-skunk-472": 710849, + "useful-clownfish-322": 710957, + "tough-cat-477": 711071, + "brave-vole-651": 711689, + "beaming-donkey-73": 712172, + "courteous-pony-381": 712360, + "astute-gopher-397": 712360, + "compassionate-tern-400": 712449, + "mild-quail-178": 712842, + "posh-chipmunk-951": 713058, + "hardy-hawk-635": 713178, + "reliable-cardinal-110": 713197, + "polite-pony-483": 713467, + "majestic-skunk-89": 713568, + "rugged-bee-643": 713696, + "dusty-lark-292": 713750, + "savory-ladybug-665": 713821, + "earnest-hawk-887": 713844, + "compassionate-hedgehog-128": 713896, + "vibrant-jellyfish-920": 713955, + "silent-salmon-775": 713995, + "colorless-sardine-136": 714072, + "academic-mole-685": 714109, + "formal-frog-513": 714198, + "chatty-mammoth-153": 714198, + "decisive-octopus-952": 714298, + "rightful-jackal-415": 714343, + "charming-pheasant-0": 714407, + "scrupulous-hippopotamus-582": 714444, + "charming-jackal-959": 714444, + "brilliant-shrimp-611": 714467, + "adorable-boar-69": 714496, + "aromatic-wildcat-784": 714604, + "standing-rhinoceros-505": 714650, + "ceaseless-shrimp-10": 714679, + "careful-oriole-334": 714750, + "basic-camel-139": 715128, + "friendly-parakeet-136": 715191, + "amiable-eagle-265": 715406, + "astute-ladybug-603": 715613, + "amiable-parakeet-902": 715614, + "stoic-skunk-882": 715817, + "focused-husky-969": 715817, + "exuberant-rhinoceros-712": 715844, + "ceaseless-monitor-955": 715844, + "tough-fish-53": 715876, + "fantastic-stingray-160": 715895, + "tame-hornet-925": 715982, + "animated-loris-697": 715988, + "industrious-vole-437": 716060, + "tame-hare-162": 716076, + "zany-partridge-558": 716195, + "earnest-rooster-19": 716213, + "cool-tern-992": 716323, + "shiny-buffalo-535": 716323, + "merry-badger-350": 716360, + "precise-bear-79": 716397, + "confident-cassowary-345": 716441, + "graceful-puma-247": 716555, + "flexible-dachshund-562": 716556, + "usable-wombat-402": 716556, + "adamant-ferret-391": 716562, + "hidden-beagle-696": 716580, + "brave-fish-415": 716580, + "famous-falcon-257": 716582, + "joyous-dolphin-427": 716701, + "brave-leopard-199": 716722, + "ceaseless-dinosaur-697": 716722, + "grateful-finch-412": 716913, + "judicious-eagle-84": 716947, + "notable-canary-144": 717143, + "hallowed-clam-572": 717143, + "fearless-nightingale-312": 717207, + "ceaseless-starfish-991": 717296, + "acoustic-firefly-54": 717296, + "friendly-mongoose-903": 717312, + "reminiscent-pheasant-577": 717312, + "judicious-buffalo-434": 717514, + "academic-trout-768": 717541, + "aromatic-shrimp-176": 717763, + "clear-goose-858": 717763, + "limitless-eagle-876": 718702, + "affable-bullfrog-460": 718815, + "academic-puma-128": 719249, + "precious-pheasant-124": 719303, + "qualified-squirrel-592": 719367, + "fearless-impala-176": 719406, + "sincere-lemming-164": 719669, + "ardent-bulldog-644": 719684, + "fearless-cuttlefish-830": 719728, + "fast-hornet-988": 720092, + "rapid-meerkat-571": 720284, + "marvelous-civet-192": 720286, + "qualified-tortoise-455": 720406, + "veracious-duck-382": 720488, + "sleek-lynx-476": 720649, + "cheery-squid-945": 720746, + "stoic-akita-466": 720794, + "formal-marten-122": 720896, + "intent-toucan-344": 720924, + "dutiful-weasel-767": 720925, + "enduring-curlew-821": 720926, + "aware-gazelle-334": 720942, + "cheery-octopus-449": 720943, + "intent-lemming-383": 720944, + "quixotic-cormorant-724": 720946, + "dazzling-hummingbird-585": 720983, + "lovable-marmot-168": 721025, + "first-cuttlefish-896": 721025, + "utmost-civet-825": 721041, + "perfect-eagle-211": 721042, + "polite-echidna-797": 721044, + "marvelous-parrot-723": 721045, + "rare-greyhound-954": 721046, + "doting-gazelle-595": 721047, + "rugged-caribou-507": 724747, + "rightful-bullfrog-330": 724796, + "quiet-curlew-942": 724797, + "healthy-swordfish-55": 724799, + "loyal-reindeer-471": 724800, + "insightful-goose-990": 724803, + "mild-fish-360": 724804, + "impartial-hound-117": 724852, + "quick-cormorant-367": 724858, + "acoustic-fennec-286": 724858, + "cautious-gnat-74": 725142, + "calm-husky-543": 725208, + "impressive-armadillo-310": 725209, + "rare-stingray-899": 725210, + "tacit-lapwing-128": 726060, + "silent-tapir-372": 731784, + "careful-coyote-975": 731785, + "academic-ibis-18": 731786, + "helpful-shark-28": 731787, + "neat-chihuahua-754": 731789, + "secret-mule-486": 731790, + "first-iguana-807": 732632, + "festive-avocet-894": 732635, + "ardent-kingfisher-757": 732636, + "spotted-lark-243": 741766, + "ceaseless-okapi-769": 741768, + "brainy-cod-836": 741771, + "neighborly-possum-205": 748192, + "woozy-vole-845": 748192, + "tough-barracuda-3": 750975, + "striped-monitor-160": 751034, + "ceaseless-platypus-473": 751034, + "kindred-vulture-800": 752143, + "greedy-owl-932": 752144, + "artful-gopher-329": 752145, + "earnest-fly-73": 752148, + "marvelous-loris-76": 752149, + "adept-meerkat-891": 752150, + "fabulous-setter-800": 752476, + "fiery-egret-853": 752477, + "savory-mole-353": 752478, + "moonlit-lion-657": 752542, + "adventurous-mandrill-980": 754919, + "zealous-pelican-878": 754921, + "woozy-vulture-919": 754922, + "acoustic-sheep-460": 754926, + "patient-alpaca-109": 754927, + "fearless-lobster-164": 754930, + "brainy-mosquito-294": 754931, + "valiant-gull-21": 754932, + "next-goat-526": 754934, + "admired-fly-223": 755226, + "kindhearted-ram-277": 755272, + "capable-monitor-526": 755274, + "grand-herring-731": 755281, + "secret-echidna-45": 755283, + "veracious-cat-939": 755285, + "wooden-gazelle-383": 755401, + "utmost-lyrebird-596": 755402, + "quirky-giraffe-691": 755403, + "resilient-bloodhound-483": 755450, + "sensible-reindeer-795": 755452, + "robust-robin-715": 755456, + "famous-grouse-836": 755724, + "ceaseless-schnauzer-501": 755862, + "dashing-parrot-117": 755863, + "polite-tern-889": 755865, + "healthy-gerbil-792": 755892, + "grand-blackbird-734": 755893, + "fearless-albatross-324": 755909, + "efficient-tapir-181": 755910, + "groovy-spider-835": 755911, + "kindly-walrus-567": 755912, + "expert-ostrich-489": 755913, + "joyous-hornet-664": 755915, + "adorable-platypus-575": 755929, + "tacit-clam-548": 755930, + "pastel-partridge-141": 755932, + "brainy-anaconda-811": 755962, + "valiant-jackal-246": 755967, + "majestic-penguin-462": 755968, + "insightful-snail-392": 755969, + "fortunate-mule-579": 755971, + "flippant-gnu-96": 755972, + "impressive-chicken-765": 755973, + "neat-rabbit-442": 755978, + "nautical-goldfinch-491": 755979, + "spotted-cuttlefish-270": 755980, + "standing-ocelot-576": 755982, + "tangible-husky-482": 755983, + "steady-wildebeest-965": 755984, + "quick-pigeon-769": 755984, + "famous-anaconda-534": 755986, + "peaceful-manatee-898": 755989, + "fast-hummingbird-846": 755994, + "hidden-badger-605": 755995, + "kindhearted-ant-750": 756000, + "elated-heron-211": 756001, + "hallowed-lynx-665": 756001, + "savory-ibex-182": 756003, + "gregarious-gerbil-756": 756004, + "intent-cat-852": 756007, + "acoustic-dachshund-451": 756008, + "good-snail-234": 756008, + "reminiscent-otter-204": 756010, + "oceanic-moose-842": 756011, + "neighborly-meerkat-416": 756023, + "content-porpoise-520": 756027, + "valiant-narwhal-302": 756045, + "steady-yak-869": 756047, + "sensible-salmon-820": 756054, + "determined-stingray-213": 756055, + "merry-spaniel-86": 756056, + "next-bat-666": 756057, + "proper-dachshund-674": 756071, + "hushed-fennec-776": 756103, + "amiable-crow-599": 756104, + "stoic-porcupine-821": 756106, + "disciplined-lemming-835": 756107, + "canny-ant-948": 756110, + "helpful-fox-400": 756111, + "wandering-badger-936": 756128, + "amiable-gerbil-995": 756129, + "amicable-coyote-800": 756131, + "sensible-jaguar-815": 756138, + "woozy-armadillo-341": 756139, + "dutiful-elephant-504": 756145, + "necessary-spaniel-423": 756166, + "scintillating-cow-568": 756167, + "trustworthy-mouse-136": 756168, + "dependable-bulldog-814": 756169, + "lovable-crocodile-105": 756170, + "abundant-bulldog-464": 756171, + "fast-wren-262": 756196, + "efficient-snake-360": 756197, + "keen-mole-831": 756198, + "upbeat-jaguar-470": 756199, + "calm-squid-271": 756201, + "dynamic-puma-160": 756202, + "harmless-snail-397": 756211, + "useful-raven-681": 756213, + "cautious-kangaroo-861": 756214, + "giddy-rooster-893": 756215, + "modest-flamingo-331": 756216, + "accurate-sheep-96": 756218, + "cautious-crane-435": 756219, + "peaceful-mammoth-482": 756224, + "adjoining-dachshund-367": 756232, + "marvelous-ibis-221": 756233, + "peaceful-wren-736": 756234, + "sleek-salmon-212": 756236, + "fearless-robin-317": 756236, + "judicious-partridge-511": 756237, + "handsome-marmot-606": 756237, + "decisive-loris-964": 756238, + "successful-porcupine-239": 756259, + "steady-toad-172": 756260, + "intent-giraffe-264": 756262, + "exuberant-narwhal-673": 756263, + "fearless-barracuda-155": 756301, + "perceptive-vole-863": 756301, + "dependable-wolverine-105": 756333, + "knowing-snail-631": 756343, + "tremendous-leopard-348": 756661, + "amiable-axolotl-251": 756661, + "fabulous-robin-925": 756718, + "abundant-fly-641": 757761, + "reminiscent-bullfrog-811": 759508, + "flippant-magpie-633": 759720, + "tangible-whale-522": 759722, + "fast-guineapig-724": 759726, + "admired-ocelot-540": 760077, + "savory-condor-342": 760079, + "dependable-iguana-483": 760083, + "outgoing-gecko-374": 760115, + "woozy-bird-371": 760115, + "confident-penguin-812": 760119, + "astute-mouse-725": 760120, + "doting-salmon-141": 760124, + "earnest-parrot-966": 760125, + "colorful-impala-487": 760320, + "beaming-avocet-443": 760425, + "content-trout-60": 760427, + "amiable-dalmatian-557": 760431, + "pleasant-mammoth-999": 760440, + "fiery-giraffe-578": 760593, + "cheerful-woodpecker-395": 760614, + "decisive-bass-803": 760615, + "beaming-starfish-1": 760617, + "merry-hedgehog-636": 760825, + "proficient-ibex-148": 760827, + "energetic-marten-8": 761247, + "small-sardine-266": 761249, + "ceaseless-bison-940": 761250, + "canny-narwhal-199": 761290, + "valiant-giraffe-285": 761291, + "healthy-cassowary-432": 761296, + "wandering-marlin-974": 761297, + "hip-cow-670": 761729, + "harmless-possum-401": 761887, + "utmost-peccary-785": 761963, + "energetic-bullfrog-902": 762213, + "nautical-puma-978": 762428, + "focused-trout-33": 763474, + "silent-setter-345": 763789, + "blissful-panther-352": 763789, + "blissful-fly-608": 763809, + "fiery-finch-742": 763809, + "industrious-magpie-492": 763815, + "calm-cuttlefish-802": 763815, + "trustworthy-mouse-443": 763858, + "insightful-kangaroo-259": 763860, + "aromatic-lemur-179": 763861, + "adept-robin-877": 763862, + "elegant-rhinoceros-272": 763863, + "cool-dalmatian-115": 763866, + "wooden-bobcat-795": 763867, + "perfect-nightingale-889": 763871, + "rugged-mockingbird-406": 763874, + "unique-loris-681": 763879, + "peaceful-badger-957": 763883, + "uncommon-pigeon-250": 763884, + "handsome-sardine-4": 763889, + "fabulous-panda-223": 763893, + "focused-partridge-74": 763898, + "wry-partridge-225": 763901, + "proficient-goshawk-433": 763903, + "flexible-stingray-179": 763904, + "deafening-impala-745": 763917, + "dashing-tiger-770": 763918, + "diligent-dinosaur-273": 763924, + "next-lemur-624": 763924, + "hushed-herring-944": 763962, + "adventurous-rhinoceros-245": 763962, + "quixotic-clownfish-197": 764095, + "admired-hound-598": 764095, + "sensible-yak-608": 764100, + "adjoining-dragon-354": 764113, + "spotted-condor-18": 764114, + "different-boar-709": 764118, + "friendly-jellyfish-290": 764132, + "moonlit-jay-942": 764136, + "earnest-wren-644": 764151, + "graceful-sockeye-275": 764152, + "expert-newt-404": 764178, + "combative-crocodile-344": 764196, + "warmhearted-ram-125": 764197, + "perfect-bat-393": 764247, + "zealous-mosquito-242": 764250, + "laudable-reindeer-714": 764258, + "fabulous-swan-428": 764259, + "gregarious-parrot-943": 764279, + "vibrant-tapir-346": 764280, + "charming-impala-339": 764324, + "laudable-goldfinch-852": 764474, + "clean-bird-339": 764498, + "handsome-alligator-102": 764501, + "ceaseless-stoat-475": 764517, + "abundant-ermine-976": 764696, + "fabulous-owl-447": 764697, + "adventurous-woodpecker-457": 764714, + "basic-crocodile-687": 764717, + "grandiose-rooster-65": 764724, + "dynamic-akita-296": 764754, + "prestigious-setter-757": 764755, + "silent-greyhound-3": 764789, + "original-lapwing-928": 764833, + "exuberant-bat-297": 764834, + "adamant-clam-613": 764836, + "ideal-rooster-307": 764837, + "pleasant-fennec-33": 764845, + "upbeat-emu-412": 764846, + "useful-penguin-699": 764925, + "accurate-parrot-67": 764926, + "successful-starling-955": 764928, + "usable-cheetah-449": 764929, + "hidden-sparrow-279": 764930, + "colorful-ibex-970": 764993, + "laudable-cobra-620": 764994, + "resolute-swan-830": 765002, + "festive-jaguar-478": 765003, + "moonlit-deer-326": 765009, + "helpful-bison-604": 765010, + "wooden-alpaca-896": 765013, + "courteous-mongoose-348": 765014, + "doting-newt-433": 765037, + "grateful-starling-137": 765038, + "knowing-shepherd-779": 765040, + "brazen-shepherd-358": 765065, + "disciplined-mouse-445": 765130, + "abundant-mockingbird-335": 765131, + "fleet-dolphin-995": 765141, + "unique-cassowary-160": 765142, + "ideal-toad-810": 765155, + "accurate-herring-373": 765156, + "giant-grasshopper-396": 765180, + "affable-bison-973": 765185, + "resolute-cat-546": 765188, + "grand-wildcat-459": 765216, + "dutiful-frog-778": 765227, + "lovely-lemming-933": 765228, + "compassionate-hippopotamus-489": 765229, + "notable-kangaroo-742": 765364, + "content-weasel-706": 765860, + "original-crow-910": 765860, + "fortunate-gull-193": 765864, + "blissful-ox-786": 766176, + "successful-cow-540": 766219, + "academic-mole-469": 766220, + "sleek-spider-923": 766238, + "earnest-badger-930": 766238, + "rugged-lobster-224": 766311, + "handsome-emu-3": 768586, + "dutiful-squid-627": 769383, + "clever-oriole-930": 769503, + "hushed-lion-452": 769695, + "decisive-oriole-155": 769754, + "greedy-labrador-847": 769779, + "glorious-walrus-474": 769828, + "hallowed-porcupine-17": 769972, + "dapper-warbler-538": 769973, + "moonlit-guanaco-899": 769987, + "steady-wolverine-506": 769988, + "flippant-starling-744": 770178, + "harmless-cassowary-933": 770179, + "flippant-cricket-893": 770180, + "striped-bee-213": 770189, + "energetic-loris-429": 770191, + "tame-penguin-258": 770198, + "neighborly-mongoose-450": 770200, + "peaceful-buzzard-149": 770201, + "exuberant-jellyfish-522": 770236, + "tacit-sheep-547": 770249, + "efficient-scorpion-231": 770276, + "tidy-cormorant-347": 770276, + "clean-cod-619": 770768, + "energized-lark-492": 770823, + "careful-echidna-929": 770824, + "wandering-fly-82": 771153, + "friendly-wolverine-750": 771153, + "dutiful-whale-295": 771210, + "pleasant-ferret-8": 771210, + "kindred-wolf-925": 771464, + "cool-zebra-254": 771464, + "cheerful-mammoth-208": 771711, + "rapid-stingray-658": 771888, + "zealous-roadrunner-803": 771888, + "helpful-swan-420": 772463, + "glorious-pheasant-323": 772466, + "grand-mongoose-356": 772469, + "enchanted-kiwi-431": 772470, + "dapper-mole-214": 772475, + "acrobatic-hare-31": 772476, + "silent-guineapig-235": 773145, + "dazzling-squirrel-770": 773505, + "wonderful-cricket-914": 773822, + "honorable-lion-138": 773823, + "prestigious-chickadee-842": 773824, + "loyal-wombat-218": 773825, + "curious-chameleon-839": 774224, + "clean-ram-757": 774224, + "posh-albatross-372": 774322, + "tidy-rhinoceros-914": 774323, + "standing-vulture-211": 774800, + "dependable-rook-326": 775149, + "robust-dinosaur-261": 775642, + "giddy-meadowlark-558": 775645, + "utmost-narwhal-754": 775645, + "adorable-stork-99": 775680, + "doting-tortoise-285": 775823, + "outstanding-finch-861": 775846, + "wry-ibis-741": 775977, + "aware-toad-561": 776154, + "content-lemur-779": 776155, + "cautious-alpaca-837": 776170, + "mellow-dodo-526": 776171, + "terrific-starling-382": 776171, + "adamant-wolf-13": 776194, + "harmless-cobra-686": 776195, + "combative-vole-441": 776384, + "rosy-mongoose-33": 776411, + "vibrant-civet-409": 776455, + "aware-woodpecker-982": 776456, + "silent-wren-928": 776461, + "curious-sheep-978": 776462, + "harmless-oyster-204": 776485, + "affable-ram-624": 776486, + "whimsical-rabbit-647": 776541, + "utmost-wildebeest-428": 776543, + "astute-axolotl-712": 776556, + "adept-dog-199": 776557, + "colorful-hummingbird-1": 776578, + "tidy-sandpiper-825": 776579, + "veracious-pheasant-163": 776585, + "chatty-leopard-344": 776586, + "utmost-gnu-395": 776590, + "fleet-shepherd-861": 776592, + "combative-bear-127": 776593, + "laudable-cuttlefish-818": 776600, + "incredible-chameleon-937": 776602, + "veracious-ladybug-90": 776606, + "outstanding-starling-97": 776613, + "basic-echidna-75": 776614, + "capable-spaniel-680": 776616, + "chatty-ox-695": 776617, + "brazen-kangaroo-554": 776629, + "combative-mongoose-907": 776630, + "vibrant-spoonbill-713": 776632, + "groovy-wildebeest-355": 776633, + "wary-badger-586": 776635, + "expert-goshawk-541": 776636, + "quaint-bandicoot-657": 776754, + "rightful-snail-76": 776771, + "majestic-platypus-145": 776979, + "reliable-possum-29": 776980, + "tacit-jaguar-777": 776982, + "dashing-newt-747": 776988, + "abundant-chipmunk-130": 776989, + "canny-caiman-617": 776990, + "tough-guineapig-131": 776993, + "woozy-ibis-784": 776994, + "prestigious-loris-739": 776995, + "dutiful-lapwing-271": 777089, + "valuable-butterfly-678": 777090, + "tacit-newt-525": 777253, + "calculating-monitor-853": 777255, + "dapper-meadowlark-195": 777258, + "laudable-llama-349": 777259, + "fantastic-mockingbird-384": 777261, + "little-salmon-309": 777262, + "avid-wildcat-975": 777265, + "prestigious-gerbil-648": 777266, + "ideal-deer-662": 777269, + "shocking-trout-376": 777270, + "merry-zebra-142": 777271, + "valiant-whale-883": 777273, + "flippant-mandrill-849": 777274, + "knowing-partridge-914": 777275, + "wandering-ptarmigan-841": 777276, + "aware-chihuahua-468": 777278, + "dependable-retriever-607": 777280, + "lovable-cheetah-587": 777281, + "dapper-ladybug-825": 777282, + "loyal-condor-463": 777283, + "resolute-mosquito-132": 777284, + "dependable-aardvark-404": 777289, + "knowing-chickadee-675": 777315, + "brazen-moose-938": 777316, + "famous-albatross-361": 777351, + "polite-pigeon-438": 777352, + "earnest-lemming-667": 777392, + "warmhearted-stingray-955": 777393, + "perfect-whale-179": 777393, + "vivid-chickadee-371": 777500, + "shocking-avocet-405": 777501, + "oceanic-bird-851": 777502, + "adamant-marmot-894": 777503, + "vivid-alligator-197": 777504, + "ideal-horse-551": 777505, + "reliable-butterfly-472": 777512, + "whimsical-ox-826": 777513, + "disciplined-chickadee-3": 777513, + "unique-raccoon-675": 777515, + "posh-avocet-116": 777516, + "peaceful-chihuahua-931": 777523, + "disciplined-kangaroo-641": 777524, + "outstanding-grasshopper-541": 777544, + "impartial-panda-860": 777545, + "aware-manatee-228": 777548, + "bright-fox-115": 777549, + "hallowed-bird-376": 777719, + "calm-snail-548": 777720, + "fiery-swordfish-27": 777729, + "calculating-grouse-737": 777730, + "calculating-gazelle-483": 777750, + "dynamic-donkey-756": 777751, + "lovable-jaguar-276": 777752, + "combative-mastiff-538": 777753, + "elated-eel-820": 777756, + "resolute-avocet-557": 777757, + "fastidious-wren-758": 777764, + "limitless-opossum-752": 777765, + "flexible-dotterel-190": 777781, + "dashing-bulldog-859": 777782, + "aromatic-ox-381": 777786, + "uncommon-hedgehog-113": 777787, + "brazen-pigeon-435": 777823, + "strong-mastiff-914": 777824, + "limitless-goldfish-518": 777837, + "exciting-squid-899": 777838, + "shiny-shrimp-354": 777868, + "first-bandicoot-996": 777869, + "industrious-seal-207": 777872, + "enchanted-gopher-990": 777873, + "adventurous-newt-866": 777903, + "zany-boar-975": 777905, + "hearty-guineapig-808": 777933, + "amicable-wolf-807": 777934, + "brainy-rabbit-787": 777936, + "gregarious-chinchilla-753": 777937, + "proper-akita-768": 777941, + "friendly-dogfish-711": 777942, + "lovely-puma-708": 777942, + "dusty-chihuahua-170": 777947, + "resilient-bird-162": 777948, + "valiant-weasel-153": 777951, + "hip-snail-806": 777955, + "brave-pigeon-954": 777956, + "reliable-monitor-593": 777957, + "glad-elephant-53": 777964, + "shocking-swan-523": 777965, + "giddy-crow-599": 777967, + "efficient-platypus-929": 777968, + "lovable-platypus-277": 777978, + "superb-ferret-696": 777979, + "ceaseless-orca-832": 777999, + "marvelous-stingray-567": 778000, + "wary-schnauzer-311": 778013, + "coordinated-cuttlefish-130": 778014, + "accomplished-ibex-314": 778023, + "elegant-owl-609": 778024, + "rightful-chickadee-440": 778059, + "majestic-viper-784": 778061, + "confident-basilisk-869": 778071, + "earnest-mastiff-629": 778073, + "fortunate-goshawk-685": 778073, + "earnest-snail-135": 778084, + "limitless-mammoth-335": 778085, + "kindhearted-perch-173": 778194, + "superb-seal-807": 778195, + "admired-coyote-567": 778220, + "modest-seal-183": 778221, + "polished-robin-400": 778508, + "confident-weasel-597": 778509, + "content-gazelle-117": 779217, + "affable-rooster-493": 779218, + "third-skunk-594": 779421, + "efficient-poodle-249": 779422, + "aromatic-dog-273": 779921, + "clear-hound-143": 779923, + "kindhearted-hyena-316": 779991, + "standing-crab-112": 779992, + "scintillating-gopher-231": 780372, + "fiery-chicken-445": 780373, + "grateful-rooster-713": 780938, + "wandering-anteater-982": 780939, + "terrific-parakeet-5": 780939, + "mild-hornet-18": 780960, + "gregarious-gull-394": 780961, + "fine-kingfisher-774": 780987, + "deafening-leopard-326": 780988, + "frugal-manatee-547": 780991, + "steady-wolf-933": 780992, + "kindred-kookabura-959": 780993, + "decisive-mouse-359": 780994, + "veracious-fish-770": 780995, + "nautical-octopus-933": 780996, + "academic-swan-344": 780997, + "polished-starling-174": 780999, + "hidden-dogfish-559": 781000, + "brazen-mammoth-769": 781001, + "bright-cat-101": 781009, + "original-koala-782": 781010, + "earnest-bulldog-189": 781012, + "rapid-hedgehog-912": 781019, + "fortunate-meadowlark-508": 781020, + "fortunate-husky-571": 781021, + "resilient-bloodhound-913": 781022, + "patient-husky-985": 781030, + "lovely-stingray-421": 781031, + "savory-dodo-581": 781039, + "judicious-grouse-362": 781040, + "industrious-jellyfish-305": 781048, + "neat-mouse-525": 781049, + "reminiscent-stork-341": 781049, + "knowing-koala-903": 781050, + "determined-fennec-576": 781051, + "qualified-shrimp-712": 781055, + "strong-trout-69": 781056, + "quick-sandpiper-833": 781057, + "fantastic-gnu-613": 781059, + "quixotic-squid-954": 781060, + "expert-spider-875": 781061, + "quixotic-woodpecker-762": 781062, + "clever-aardvark-511": 781065, + "doting-peccary-125": 781066, + "wooden-flamingo-994": 781067, + "polished-bear-544": 781068, + "dutiful-greyhound-668": 781069, + "outgoing-squirrel-407": 781070, + "affable-eel-458": 781071, + "giant-bullfrog-394": 781072, + "rugged-swan-599": 781074, + "determined-platypus-980": 781076, + "hallowed-basilisk-956": 781077, + "impartial-warbler-648": 781092, + "groovy-kingfisher-282": 781093, + "calm-otter-42": 781094, + "joyous-parakeet-487": 781095, + "resilient-egret-452": 781096, + "courteous-lapwing-621": 781097, + "sensible-kookabura-155": 781098, + "fleet-gnu-502": 781101, + "accomplished-capybara-956": 781102, + "amicable-mallard-91": 781103, + "precious-possum-864": 781104, + "ardent-koala-866": 781105, + "jovial-chinchilla-682": 781106, + "cool-hamster-738": 781107, + "oceanic-iguana-193": 781108, + "curious-buzzard-128": 781109, + "efficient-llama-186": 781110, + "rugged-grouse-787": 781111, + "coordinated-camel-535": 781112, + "fiery-fly-40": 781113, + "valuable-shrimp-529": 781115, + "amicable-lyrebird-753": 781116, + "qualified-kingfisher-904": 781117, + "fortunate-meerkat-782": 781118, + "shocking-antelope-344": 781121, + "amiable-goldfish-664": 781122, + "pastel-ermine-637": 781123, + "quaint-salmon-898": 781124, + "rightful-coyote-268": 781136, + "valiant-puffin-796": 781138, + "prestigious-lion-51": 781141, + "third-wolverine-504": 781145, + "savory-cricket-516": 781146, + "hidden-guineapig-5": 781151, + "cool-caribou-981": 781154, + "dutiful-bat-74": 781155, + "resilient-raccoon-893": 781156, + "curious-chinchilla-972": 781157, + "next-mule-841": 781158, + "astute-chicken-755": 781160, + "amiable-meadowlark-319": 781161, + "abundant-manatee-618": 781209, + "avid-kiwi-787": 781210, + "curious-llama-343": 781211, + "avid-seahorse-937": 781212, + "fiery-kangaroo-814": 781213, + "acrobatic-nightingale-837": 781214, + "first-poodle-312": 781215, + "third-leopard-384": 781219, + "good-bobcat-857": 781220, + "hidden-marten-86": 781225, + "kindhearted-eagle-22": 781226, + "warmhearted-hamster-717": 781234, + "focused-newt-233": 781235, + "accurate-bat-96": 781236, + "coordinated-toucan-991": 781242, + "small-parakeet-76": 781243, + "hearty-cardinal-546": 781244, + "watchful-eagle-993": 781248, + "fiery-fox-814": 781252, + "reminiscent-crocodile-283": 781253, + "flexible-leopard-890": 781253, + "elegant-chipmunk-540": 781255, + "famous-chicken-799": 781256, + "opulent-herring-883": 781258, + "quirky-dotterel-802": 781259, + "polite-clownfish-397": 781281, + "aromatic-dove-319": 781282, + "trustworthy-finch-767": 781284, + "grandiose-canary-83": 781289, + "rightful-beagle-852": 781290, + "tangible-lemming-584": 781291, + "youthful-gnu-750": 781293, + "ceaseless-spider-511": 781296, + "prestigious-stingray-900": 781297, + "kindly-eel-978": 781300, + "trustworthy-snake-607": 781302, + "dashing-woodpecker-554": 781304, + "artful-lyrebird-632": 781305, + "quirky-kangaroo-954": 781335, + "careful-tapir-422": 781336, + "sincere-gull-286": 781338, + "polished-duck-116": 781339, + "resilient-penguin-795": 781340, + "oceanic-starfish-834": 781341, + "quiet-owl-291": 781342, + "hallowed-sparrow-61": 781352, + "gregarious-mule-975": 781353, + "limitless-raccoon-914": 781354, + "outstanding-aardvark-738": 781355, + "tangible-wolf-147": 781356, + "striped-cheetah-346": 781357, + "elegant-fennec-432": 781364, + "benevolent-mosquito-576": 781365, + "fabulous-bloodhound-594": 781374, + "proficient-caterpillar-550": 781375, + "fabulous-kookabura-968": 781377, + "cheery-viper-658": 781378, + "fine-stork-784": 781393, + "gregarious-gull-519": 781396, + "fearless-pheasant-80": 781397, + "effervescent-fish-26": 781398, + "compassionate-ox-426": 781399, + "quaint-kookabura-911": 781400, + "steady-caiman-209": 781401, + "agreeable-jackal-157": 781467, + "tangible-pony-469": 781470, + "calm-cormorant-631": 781704, + "impartial-dove-877": 781705, + "calculating-anteater-181": 781721, + "quiet-wolverine-238": 781722, + "loyal-buffalo-204": 781723, + "strong-alpaca-841": 781724, + "brave-sheep-571": 782145, + "formal-swordfish-624": 782146, + "resolute-jackal-427": 782313, + "loyal-dodo-330": 782314, + "gregarious-herring-828": 782524, + "grateful-partridge-946": 782525, + "aromatic-goldfish-654": 782587, + "courteous-porpoise-819": 782588, + "dusty-bear-693": 782588, + "festive-monitor-680": 782720, + "grateful-aardvark-959": 782722, + "brilliant-whale-175": 782729, + "ardent-reindeer-98": 782730, + "little-deer-82": 783208, + "sensible-buzzard-829": 783209, + "ardent-curlew-653": 783968, + "little-wildebeest-200": 783969, + "beloved-fennec-424": 783977, + "cool-cormorant-669": 783978, + "quixotic-skunk-946": 783978, + "strong-raccoon-701": 784079, + "doting-bear-237": 784080, + "calm-mallard-810": 784461, + "stoic-capybara-864": 784462, + "hearty-mongoose-628": 784462, + "optimistic-poodle-113": 784555, + "cautious-emu-66": 784556, + "clever-cobra-306": 784691, + "hidden-avocet-23": 784693, + "frugal-shepherd-66": 784735, + "quiet-crow-38": 784736, + "incredible-kiwi-166": 784737, + "avid-stoat-751": 784740, + "efficient-chameleon-518": 784741, + "marvelous-impala-176": 784742, + "clever-cardinal-960": 784743, + "dutiful-parakeet-562": 784776, + "chatty-bird-818": 784777, + "admired-quail-428": 784782, + "loyal-cuttlefish-960": 784784, + "knowing-jackal-549": 784787, + "canny-spider-557": 784788, + "animated-shepherd-258": 784790, + "tidy-quail-139": 784805, + "prestigious-chinchilla-920": 784806, + "shiny-lemur-95": 784807, + "diligent-dragon-229": 784810, + "resolute-dogfish-576": 784811, + "industrious-bee-529": 784812, + "astute-llama-965": 784816, + "standing-chinchilla-586": 784823, + "compassionate-armadillo-724": 784824, + "perfect-kiwi-755": 784825, + "mild-chinchilla-602": 784831, + "clear-seal-851": 784832, + "keen-goose-660": 784834, + "reminiscent-malamute-360": 784835, + "astute-cricket-793": 784837, + "flexible-pig-261": 784838, + "striped-marmot-599": 784839, + "zany-grouse-155": 784844, + "scintillating-curlew-310": 784845, + "hardy-woodpecker-606": 784846, + "avid-mink-865": 784850, + "dazzling-starling-926": 784851, + "aromatic-perch-805": 784853, + "dapper-panda-443": 784856, + "glad-robin-307": 785090, + "agreeable-sandpiper-66": 785092, + "lovable-pony-499": 785092, + "blissful-sandpiper-903": 785106, + "secret-clownfish-140": 785107, + "utmost-goat-62": 785125, + "grateful-meerkat-907": 785126, + "resolute-rabbit-352": 785128, + "perfect-clam-739": 785131, + "grand-dove-791": 785132, + "giddy-ocelot-438": 785133, + "brainy-pig-470": 785136, + "valuable-chihuahua-250": 785137, + "youthful-horse-744": 785138, + "trustworthy-echidna-776": 785139, + "harmless-greyhound-866": 785140, + "tangible-squirrel-67": 785148, + "intent-lemming-118": 785149, + "agreeable-caribou-118": 785152, + "uncommon-crow-681": 785158, + "sensible-hound-391": 785159, + "marvelous-gazelle-29": 785161, + "tame-jackal-333": 785167, + "glad-ox-392": 785168, + "standing-hound-957": 785169, + "fantastic-pika-245": 785171, + "confident-ostrich-460": 785173, + "impartial-dachshund-559": 785189, + "hallowed-retriever-562": 785190, + "adjoining-marlin-997": 785191, + "wandering-lapwing-726": 785192, + "savory-setter-925": 785201, + "reliable-boar-915": 785202, + "superb-meadowlark-817": 785203, + "keen-walrus-350": 785204, + "calm-caterpillar-700": 785205, + "enduring-jackal-779": 785206, + "agreeable-wren-65": 785207, + "enduring-elk-485": 785208, + "quick-peccary-742": 785209, + "quirky-fox-208": 785210, + "ardent-buffalo-279": 785211, + "quirky-fox-583": 785298, + "incredible-mallard-68": 785373, + "trustworthy-pheasant-156": 785376, + "nautical-swan-741": 785378, + "usable-chameleon-240": 785380, + "resilient-emu-662": 785381, + "helpful-lemming-407": 785383, + "strong-avocet-758": 785387, + "elegant-crow-326": 785388, + "courteous-penguin-421": 785389, + "resilient-oriole-961": 785390, + "valuable-jaguar-999": 785391, + "artful-egret-112": 785409, + "adept-akita-317": 785410, + "wonderful-mallard-507": 785737, + "wry-sardine-3": 785741, + "fortunate-ant-118": 785847, + "canny-mouse-277": 785848, + "impartial-viper-557": 785849, + "acoustic-starling-909": 785850, + "amiable-finch-544": 785851, + "industrious-snake-919": 785852, + "affable-dachshund-52": 785854, + "nautical-lemming-434": 785855, + "industrious-toad-672": 785856, + "neighborly-chipmunk-729": 785857, + "sensible-buzzard-945": 785858, + "reliable-cat-444": 785859, + "famous-ladybug-562": 785867, + "tidy-nightingale-815": 785868, + "content-tapir-517": 785869, + "small-ladybug-487": 785870, + "wry-ibis-608": 785872, + "impartial-caiman-146": 785874, + "groovy-mockingbird-541": 785876, + "valiant-clam-346": 785877, + "honorable-manatee-193": 785878, + "energized-quail-831": 785881, + "disciplined-armadillo-28": 785882, + "modest-gecko-758": 785883, + "oceanic-emu-712": 785911, + "lovable-mongoose-629": 785912, + "tangible-corgi-996": 785913, + "fearless-ox-263": 785914, + "adept-dalmatian-412": 785917, + "agreeable-dogfish-366": 785918, + "precise-chihuahua-96": 785919, + "combative-ferret-90": 785921, + "courteous-hyena-763": 785923, + "outgoing-chicken-898": 785924, + "tacit-marten-547": 785925, + "tremendous-marmot-558": 785926, + "animated-rat-938": 786153, + "accomplished-roadrunner-73": 786154, + "hidden-raccoon-346": 786483, + "quirky-roadrunner-549": 786484, + "original-horse-42": 786835, + "amiable-bear-20": 786836, + "majestic-gecko-538": 786855, + "kindhearted-fox-579": 786856, + "uncommon-spaniel-627": 787597, + "lovable-dachshund-976": 787598, + "superb-skunk-338": 787598, + "knowing-barracuda-677": 787612, + "fantastic-shepherd-816": 787613, + "enchanted-husky-287": 787613, + "striped-emu-295": 788105, + "keen-gopher-673": 788106, + "laudable-crab-17": 788815, + "knowing-walrus-702": 788816, + "compassionate-starfish-457": 788816, + "glorious-zebra-424": 788951, + "admired-dog-760": 788952, + "exuberant-dalmatian-39": 789195, + "oceanic-elephant-17": 789196, + "outstanding-starling-575": 789539, + "upbeat-buffalo-495": 789540, + "doting-kangaroo-177": 789740, + "giddy-lapwing-779": 789741, + "warmhearted-robin-399": 790242, + "enchanted-ant-93": 790243, + "posh-pelican-320": 790459, + "reminiscent-mongoose-700": 790460, + "elegant-bass-501": 791818, + "small-mockingbird-558": 791819, + "quixotic-possum-630": 791819, + "acoustic-hyena-495": 792362, + "successful-leopard-908": 792363, + "greedy-ocelot-561": 792578, + "modest-chickadee-450": 792579, + "uncommon-manatee-852": 792669, + "whimsical-firefly-654": 792670, + "animated-sockeye-251": 793232, + "acrobatic-lemur-511": 793233, + "energized-gerbil-958": 793233, + "shiny-goldfish-12": 793237, + "neighborly-chicken-882": 793238, + "hallowed-parakeet-274": 793238, + "mild-retriever-266": 793357, + "shocking-jaguar-624": 793358, + "mild-wren-396": 793999, + "energetic-ptarmigan-997": 794000, + "jovial-wildebeest-358": 794196, + "fine-setter-763": 794197, + "dashing-ferret-797": 794197, + "ceaseless-cardinal-638": 794385, + "adept-ostrich-744": 794386, + "intent-swan-830": 794651, + "grandiose-kangaroo-710": 794652, + "adept-crab-903": 795209, + "focused-reindeer-948": 795210, + "grandiose-mastiff-278": 795224, + "resolute-seal-284": 795225, + "perceptive-finch-634": 795699, + "reminiscent-lion-364": 795700, + "glorious-iguana-606": 795954, + "acoustic-dalmatian-633": 795955, + "healthy-lynx-945": 796541, + "dapper-cat-377": 796542, + "vibrant-goat-447": 798106, + "steady-eagle-967": 798471, + "academic-shrimp-226": 798473, + "aromatic-emu-910": 798729, + "affable-squid-793": 798730, + "expert-puma-437": 799528, + "tame-dove-481": 799529, + "mild-sockeye-997": 799779, + "courteous-egret-698": 799780, + "superb-mandrill-819": 800179, + "sincere-caribou-142": 800180, + "agreeable-retriever-43": 800800, + "fine-shark-219": 800801, + "precious-turtle-81": 800801, + "superb-horse-107": 801516, + "rare-corgi-375": 801630, + "good-ocelot-259": 801631, + "original-squirrel-644": 801730, + "trustworthy-sandpiper-563": 801731, + "modest-iguana-51": 801731, + "capable-sparrow-665": 801813, + "grandiose-finch-145": 801814, + "diligent-mallard-606": 802391, + "rare-dove-471": 802392, + "energetic-magpie-115": 802400, + "fortunate-sockeye-685": 802401, + "pastel-wolf-799": 802402, + "unique-alpaca-735": 802450, + "fine-spider-684": 802562, + "moonlit-bear-90": 802563, + "tame-shrimp-697": 802575, + "adjoining-ibis-719": 802577, + "unique-clam-909": 802694, + "warmhearted-lynx-372": 802695, + "quixotic-reindeer-588": 802726, + "hidden-gazelle-851": 802727, + "hushed-wren-706": 802860, + "groovy-ram-45": 802861, + "perfect-crane-677": 802861, + "calculating-dolphin-128": 803083, + "exciting-ostrich-416": 803084, + "scrupulous-crab-595": 803102, + "agreeable-husky-375": 803103, + "famous-ferret-855": 803270, + "giant-koala-268": 803271, + "formal-sockeye-355": 803271, + "fearless-mockingbird-198": 803501, + "adorable-bloodhound-839": 803502, + "academic-kingfisher-246": 803643, + "agreeable-deer-985": 803644, + "zealous-fennec-314": 803998, + "judicious-woodpecker-388": 803999, + "aromatic-malamute-439": 804008, + "kindly-ibex-780": 804009, + "benevolent-penguin-943": 804010, + "posh-puffin-832": 804011, + "efficient-lion-316": 804012, + "precious-snail-521": 804013, + "wary-armadillo-188": 804017, + "determined-flamingo-982": 804020, + "dashing-rhinoceros-840": 804022, + "cheerful-falcon-790": 804023, + "upbeat-kingfisher-663": 804025, + "impressive-bee-591": 804026, + "fantastic-kookabura-183": 804027, + "jovial-chipmunk-928": 804030, + "scrupulous-squid-873": 804035, + "useful-spaniel-138": 804036, + "vivid-clam-634": 804036, + "brazen-boar-764": 804038, + "calculating-cormorant-185": 804040, + "keen-pigeon-899": 804040, + "unique-mammoth-383": 804087, + "grandiose-sardine-165": 804088, + "prestigious-ferret-52": 804092, + "sleek-egret-399": 804093, + "prestigious-terrier-952": 804109, + "acrobatic-camel-283": 804111, + "opulent-giraffe-871": 804113, + "ardent-malamute-896": 804115, + "qualified-camel-326": 804118, + "tidy-labrador-527": 804122, + "adventurous-antelope-608": 804128, + "amicable-chinchilla-96": 804129, + "reminiscent-ostrich-565": 804130, + "dutiful-oyster-247": 804131, + "steady-husky-814": 804132, + "reliable-aardvark-408": 804136, + "adamant-goose-542": 804137, + "quaint-beagle-739": 804228, + "proficient-dalmatian-863": 804229, + "brave-mouse-586": 804338, + "hidden-alpaca-423": 804339, + "dynamic-meadowlark-125": 804428, + "capable-basilisk-620": 804429, + "intent-goshawk-851": 804510, + "rapid-albatross-586": 804511, + "neighborly-eel-908": 804526, + "adjoining-armadillo-45": 804528, + "benevolent-retriever-428": 804560, + "quixotic-bison-816": 804561, + "next-eagle-785": 804569, + "neat-hornet-626": 804570, + "trustworthy-gnat-277": 804598, + "confident-magpie-598": 804599, + "amicable-crow-751": 804610, + "moonlit-gnat-211": 804611, + "usable-gazelle-784": 804611, + "curious-quail-919": 804637, + "veracious-ptarmigan-655": 804638, + "exciting-alpaca-96": 804747, + "fabulous-caiman-264": 804748, + "quaint-pig-380": 804748, + "dusty-seahorse-813": 804780, + "harmless-toucan-963": 804781, + "clever-dog-612": 804873, + "qualified-dotterel-466": 804874, + "quiet-penguin-463": 804897, + "fine-marmot-237": 804898, + "hearty-crane-966": 805152, + "clean-buzzard-540": 805153, + "fortunate-minnow-55": 805189, + "joyous-schnauzer-98": 805190, + "canny-moose-548": 805209, + "utmost-seal-324": 805210, + "healthy-malamute-498": 805870, + "qualified-basilisk-237": 805871, + "tough-wildebeest-884": 806024, + "loyal-parakeet-288": 806025, + "scintillating-jellyfish-779": 806174, + "courteous-leopard-623": 806175, + "jovial-cod-74": 806398, + "trustworthy-gnu-970": 806399, + "jovial-cheetah-866": 806438, + "hip-deer-930": 806439, + "vivid-wolf-386": 806439, + "animated-starling-651": 806673, + "insightful-bird-54": 806675, + "grateful-gopher-110": 806934, + "spotted-condor-472": 806976, + "brazen-marten-292": 806978, + "tame-porcupine-882": 806991, + "wary-hippopotamus-333": 806993, + "coordinated-squid-510": 807312, + "rare-tortoise-282": 807313, + "whimsical-crane-680": 807408, + "resilient-cardinal-450": 807410, + "steady-swordfish-544": 807855, + "outstanding-poodle-318": 807857, + "courteous-crow-69": 807858, + "elegant-dolphin-638": 807860, + "mellow-albatross-630": 807861, + "benevolent-condor-252": 807862, + "robust-whale-322": 807864, + "silent-parakeet-85": 807880, + "terrific-bobcat-487": 807882, + "hallowed-mouse-655": 807890, + "warmhearted-chameleon-596": 807891, + "dusty-caterpillar-665": 807893, + "greedy-sandpiper-361": 807894, + "hidden-jellyfish-411": 807897, + "standing-goose-311": 807898, + "woozy-bullfrog-363": 807930, + "flexible-hummingbird-249": 807931, + "lovely-falcon-508": 807932, + "successful-trout-327": 807933, + "doting-cobra-737": 807934, + "flippant-goldfish-307": 807936, + "grateful-kookabura-367": 807937, + "cool-quail-256": 807939, + "ideal-roadrunner-554": 807940, + "jovial-eel-839": 807943, + "zany-kudu-182": 807944, + "vibrant-snail-359": 807958, + "rightful-partridge-669": 807959, + "quaint-loris-479": 807961, + "silent-bloodhound-227": 807963, + "groovy-gull-883": 807964, + "prestigious-spoonbill-326": 807965, + "nautical-hummingbird-76": 807966, + "cheerful-manatee-376": 807970, + "quick-goat-856": 807971, + "adorable-goat-453": 807975, + "cheery-deer-414": 807976, + "modest-coyote-137": 807983, + "reliable-ladybug-832": 807984, + "artful-otter-910": 808118, + "sensible-llama-687": 808119, + "laudable-buffalo-7": 808120, + "rugged-rat-745": 808121, + "harmless-roadrunner-728": 808123, + "hip-ram-640": 808125, + "impartial-camel-69": 808125, + "reminiscent-kookabura-418": 808379, + "calm-chihuahua-313": 808380, + "benevolent-hare-864": 808446, + "elegant-vole-276": 808447, + "amicable-wildebeest-845": 808447, + "trustworthy-clownfish-638": 808455, + "pleasant-fox-950": 808456, + "cheery-platypus-456": 808475, + "dynamic-swan-752": 808476, + "disciplined-perch-631": 808484, + "quiet-rat-929": 808485, + "laudable-ram-633": 808588, + "decisive-possum-553": 808589, + "dynamic-coyote-623": 809243, + "silent-goat-129": 809245, + "dapper-poodle-171": 810104, + "fleet-camel-873": 810105, + "amiable-cricket-267": 810130, + "dapper-mole-361": 810131, + "hushed-emu-309": 810223, + "handsome-herring-947": 810491, + "wandering-duck-766": 810492, + "colorful-bass-776": 810824, + "astute-crow-9": 810825, + "reminiscent-mastiff-576": 810939, + "healthy-lapwing-987": 810940, + "flexible-oriole-565": 811401, + "superb-echidna-861": 811402, + "mild-viper-787": 811435, + "doting-horse-629": 811437, + "colorful-sheep-824": 811445, + "sleek-ladybug-798": 811446, + "amicable-cuttlefish-302": 811454, + "uncommon-dog-785": 811455, + "wandering-vulture-785": 812030, + "harmless-fennec-821": 812031, + "third-capybara-719": 812414, + "energized-vole-205": 812449, + "youthful-heron-315": 812450, + "careful-pheasant-106": 812507, + "uncommon-kookabura-140": 812509, + "veracious-pony-79": 812513, + "limitless-clownfish-652": 812514, + "tangible-mammoth-578": 812631, + "ceaseless-wildcat-586": 812632, + "blessed-sardine-849": 812865, + "fortunate-echidna-677": 812866, + "confident-malamute-942": 813012, + "festive-chinchilla-293": 813013, + "tidy-gnu-563": 813284, + "warmhearted-badger-474": 813285, + "cool-tortoise-867": 814503, + "hearty-trout-464": 814504, + "cautious-dodo-769": 814811, + "helpful-lion-18": 814812, + "adjoining-schnauzer-499": 814881, + "adjoining-stingray-203": 814882, + "vivid-giraffe-546": 815363, + "knowing-koala-318": 815364, + "youthful-toucan-158": 815364, + "grand-raven-483": 815810, + "quaint-manatee-802": 815811, + "enduring-zebra-465": 816482, + "handsome-stoat-790": 816483, + "dusty-seal-131": 816588, + "standing-aardvark-615": 816589, + "impartial-elephant-149": 817069, + "outgoing-sandpiper-548": 817071, + "greedy-condor-0": 818166, + "necessary-gull-41": 818167, + "scrupulous-kookabura-286": 818167, + "ideal-anteater-337": 818296, + "avid-echidna-772": 818297, + "proper-coyote-62": 818393, + "hidden-goat-6": 818394, + "polished-goshawk-784": 818394, + "brave-weasel-76": 818431, + "laudable-shepherd-736": 818432, + "opulent-sturgeon-992": 818506, + "beloved-guanaco-76": 818507, + "joyous-parakeet-863": 818560, + "industrious-squirrel-139": 818561, + "tacit-caiman-196": 819027, + "opulent-elephant-838": 819028, + "effervescent-hummingbird-843": 819028, + "charming-marten-166": 819114, + "rugged-bass-927": 819115, + "next-turtle-8": 819115, + "blissful-dragon-691": 819698, + "secret-viper-979": 819699, + "effervescent-civet-655": 819752, + "hidden-armadillo-270": 819753, + "chatty-shrimp-765": 819797, + "groovy-pig-209": 819798, + "kindhearted-boar-388": 819898, + "ardent-cat-591": 819901, + "resolute-ostrich-123": 819902, + "amiable-cassowary-275": 819905, + "sincere-avocet-937": 819907, + "majestic-sparrow-301": 819908, + "utmost-bulldog-420": 819910, + "hearty-cassowary-321": 819921, + "clean-beagle-222": 819922, + "opulent-gopher-694": 819922, + "good-magpie-456": 819924, + "silent-hamster-960": 819925, + "abundant-roadrunner-146": 819957, + "knowing-tern-302": 819958, + "terrific-seahorse-386": 819960, + "precious-crow-623": 819962, + "knowing-spider-617": 819964, + "aware-kiwi-653": 819966, + "grand-skunk-614": 819968, + "intent-marlin-80": 819979, + "quixotic-rabbit-550": 819980, + "judicious-squid-485": 819980, + "handsome-coyote-55": 819988, + "prestigious-bass-358": 819990, + "tremendous-hamster-346": 820026, + "careful-wolverine-704": 820027, + "industrious-ox-15": 820028, + "decisive-blackbird-669": 820030, + "whimsical-bloodhound-58": 820031, + "watchful-wren-277": 820033, + "combative-wombat-196": 820036, + "rugged-marlin-840": 820079, + "original-aardvark-372": 820080, + "fleet-robin-193": 820086, + "resilient-beagle-500": 820088, + "moonlit-grouse-976": 820094, + "artful-bulldog-222": 820095, + "acoustic-mandrill-675": 820100, + "shiny-bear-590": 820101, + "fearless-shark-740": 820102, + "vivid-caribou-43": 820103, + "proper-rabbit-609": 820104, + "striped-toucan-716": 820107, + "bright-hound-631": 820108, + "posh-civet-943": 820113, + "laudable-walrus-493": 820114, + "warmhearted-frog-577": 820139, + "earnest-sockeye-322": 820140, + "blessed-hedgehog-108": 820141, + "moonlit-jaguar-50": 820142, + "utmost-crocodile-1": 820143, + "confident-stoat-699": 820144, + "kindred-otter-982": 820145, + "kindred-ladybug-423": 820147, + "judicious-kangaroo-168": 820149, + "quixotic-horse-864": 820149, + "acrobatic-hedgehog-509": 820152, + "strong-hawk-200": 820153, + "tacit-grasshopper-79": 820620, + "agreeable-clam-971": 820621, + "optimistic-monitor-511": 820621, + "original-rabbit-204": 820762, + "acoustic-boar-621": 820763, + "animated-crab-409": 820838, + "precious-fish-356": 820839, + "confident-rooster-452": 821000, + "valiant-salmon-384": 821001, + "brazen-gnu-686": 821001, + "trustworthy-labrador-959": 821199, + "striped-puffin-472": 821200, + "brilliant-frog-716": 821305, + "accurate-viper-414": 821307, + "adorable-ram-512": 821307, + "successful-porcupine-303": 821312, + "calculating-dogfish-103": 821313, + "bold-sturgeon-142": 821313, + "colorful-curlew-170": 821353, + "pastel-squirrel-922": 821354, + "ideal-koala-363": 821455, + "majestic-ermine-638": 821457, + "vibrant-koala-733": 821514, + "accomplished-caribou-265": 821515, + "hushed-boar-90": 821817, + "hearty-grouse-779": 821818, + "outgoing-terrier-486": 821818, + "precise-alligator-482": 822031, + "vibrant-crane-74": 822032, + "effervescent-falcon-816": 822636, + "robust-wren-32": 822637, + "brilliant-robin-98": 822654, + "hushed-buffalo-44": 822655, + "judicious-kingfisher-26": 822798, + "efficient-sandpiper-425": 822799, + "resolute-elephant-746": 822799, + "astute-alligator-128": 822852, + "friendly-chipmunk-968": 822854, + "first-butterfly-511": 822854, + "quixotic-antelope-949": 823221, + "pastel-bulldog-564": 823223, + "marvelous-fox-750": 823224, + "qualified-tapir-178": 823225, + "quirky-caribou-949": 823225, + "fearless-hare-782": 823232, + "tremendous-caterpillar-95": 823233, + "graceful-bandicoot-448": 823478, + "stoic-hawk-149": 823479, + "spotted-viper-517": 823772, + "outstanding-vole-819": 823773, + "neighborly-vole-203": 823853, + "loyal-lobster-668": 823854, + "coordinated-herring-181": 824188, + "silent-bullfrog-599": 824189, + "posh-bear-959": 824189, + "dusty-fly-462": 824864, + "nautical-whale-880": 824865, + "modest-akita-840": 825187, + "cheerful-possum-211": 825188, + "sincere-canary-813": 825355, + "quiet-gull-962": 825356, + "aware-magpie-938": 825360, + "outstanding-shepherd-369": 825361, + "rosy-emu-622": 825391, + "silent-eel-175": 825392, + "perceptive-starling-148": 825395, + "posh-pig-499": 825396, + "mild-mallard-656": 825405, + "woozy-jaguar-11": 825407, + "fortunate-ptarmigan-4": 825409, + "expert-cuttlefish-954": 825410, + "acoustic-spaniel-454": 825414, + "keen-beagle-404": 825415, + "lovely-impala-670": 825418, + "intent-fish-961": 825419, + "woozy-weasel-273": 825422, + "doting-seal-905": 825423, + "astute-bee-883": 825426, + "tame-terrier-641": 825428, + "silent-vulture-915": 825434, + "exciting-cow-448": 825435, + "usable-eagle-725": 825438, + "grandiose-quail-616": 825439, + "affable-caterpillar-706": 825443, + "energetic-meerkat-219": 825444, + "neat-mouse-258": 825448, + "different-goldfinch-631": 825449, + "accomplished-vole-239": 825452, + "secret-bobcat-71": 825453, + "grandiose-weasel-305": 825458, + "opulent-emu-185": 825460, + "coordinated-axolotl-682": 825466, + "lovely-jaguar-451": 825467, + "graceful-ram-331": 825475, + "greedy-marlin-602": 825476, + "confident-puffin-192": 825481, + "brave-antelope-87": 825482, + "polished-crane-239": 825483, + "peaceful-skunk-529": 825484, + "fine-cod-695": 825486, + "effervescent-kookabura-12": 825488, + "careful-starling-324": 825491, + "keen-alligator-525": 825494, + "brilliant-crow-262": 825497, + "utmost-chinchilla-296": 825498, + "different-kangaroo-898": 825505, + "quiet-mole-991": 825506, + "mellow-wildcat-318": 825509, + "cautious-parrot-702": 825510, + "greedy-yak-76": 825517, + "tangible-starfish-708": 825518, + "wary-panther-405": 825520, + "laudable-partridge-999": 825521, + "fleet-bear-538": 825523, + "aware-pelican-494": 825525, + "resilient-oyster-728": 825526, + "fastidious-gnat-956": 825528, + "ideal-tortoise-779": 825531, + "modest-cormorant-357": 825532, + "clean-parrot-594": 825535, + "greedy-cow-872": 825536, + "wary-firefly-882": 825537, + "hushed-guanaco-449": 825539, + "oceanic-cheetah-102": 825541, + "effervescent-swordfish-507": 825542, + "glorious-ptarmigan-826": 825544, + "neighborly-loris-817": 825546, + "diligent-ptarmigan-482": 825548, + "posh-snake-751": 825549, + "beloved-rabbit-843": 825554, + "healthy-bloodhound-367": 825555, + "adamant-cassowary-480": 825558, + "grateful-newt-441": 825559, + "vivid-perch-768": 825563, + "dynamic-anaconda-637": 825564, + "incredible-cassowary-277": 825565, + "formal-sardine-167": 825567, + "neat-ferret-793": 825568, + "third-otter-302": 825570, + "giant-jellyfish-278": 825572, + "scrupulous-owl-829": 825573, + "silent-partridge-155": 825575, + "glad-bulldog-361": 825576, + "cheery-possum-784": 825577, + "honorable-coyote-956": 825578, + "pastel-whale-720": 825579, + "tough-ptarmigan-445": 825581, + "ideal-civet-567": 825585, + "resilient-kiwi-564": 825586, + "diligent-pigeon-44": 825587, + "tough-butterfly-626": 825588, + "spotted-pony-942": 825589, + "wry-wildcat-337": 825590, + "dapper-kookabura-882": 825593, + "oceanic-bear-668": 825594, + "moonlit-aardvark-273": 825595, + "cheery-kudu-519": 825596, + "watchful-ox-983": 825598, + "affable-squid-847": 825599, + "agile-emu-851": 825600, + "flexible-alligator-175": 825601, + "hallowed-loris-314": 825603, + "necessary-herring-553": 825604, + "optimistic-hornet-148": 825608, + "quirky-clownfish-910": 825610, + "charming-pigeon-677": 825611, + "nautical-spider-237": 825612, + "majestic-parrot-921": 825613, + "nautical-kudu-81": 825614, + "modest-bird-864": 825617, + "valuable-pigeon-154": 825618, + "sleek-snake-132": 825621, + "prestigious-tortoise-144": 825622, + "resolute-ladybug-202": 825624, + "stoic-goose-583": 825625, + "keen-avocet-104": 825625, + "upbeat-llama-439": 825628, + "shocking-wren-681": 825629, + "valuable-shrimp-114": 825635, + "robust-bass-967": 825636, + "proper-koala-65": 825820, + "jovial-herring-49": 825822, + "determined-peccary-723": 825890, + "glorious-platypus-227": 825892, + "admired-okapi-341": 825920, + "kindly-meerkat-246": 825921, + "famous-mallard-41": 825937, + "resolute-manatee-553": 825938, + "zealous-deer-107": 826012, + "cheery-otter-618": 826013, + "wary-ostrich-425": 826121, + "giant-crab-94": 826122, + "qualified-clam-210": 826122, + "quick-bass-586": 827305, + "tremendous-stingray-33": 827306, + "rugged-wolf-274": 827338, + "unique-goat-611": 827339, + "enduring-salamander-281": 827886, + "quiet-tern-519": 827887, + "hushed-penguin-614": 828131, + "polished-loris-55": 828134, + "affable-barracuda-596": 828669, + "accurate-ladybug-101": 828670, + "moonlit-barracuda-752": 828719, + "lovable-kudu-46": 828720, + "spotted-canary-54": 828787, + "jovial-malamute-876": 828788, + "hardy-marten-771": 828933, + "merry-whale-253": 828934, + "reliable-mockingbird-216": 829015, + "secret-squirrel-889": 829016, + "rare-barracuda-752": 829836, + "insightful-gnat-739": 829837, + "modest-meerkat-798": 830009, + "striped-scorpion-786": 830010, + "lovely-lobster-401": 830011, + "fantastic-bullfrog-441": 830013, + "vivid-lynx-325": 830014, + "laudable-wolverine-605": 830016, + "rightful-lyrebird-972": 830017, + "modest-starling-595": 830018, + "insightful-scorpion-58": 830019, + "greedy-seal-254": 830021, + "exuberant-marmot-199": 830022, + "ceaseless-dolphin-456": 830023, + "optimistic-panther-266": 830024, + "bold-sandpiper-668": 830052, + "silent-badger-825": 830053, + "healthy-wildebeest-362": 830109, + "majestic-fox-797": 830110, + "zealous-narwhal-420": 830162, + "small-mockingbird-512": 830163, + "wooden-herring-116": 830200, + "glad-starling-453": 830201, + "tame-mink-513": 830269, + "clever-cassowary-580": 830271, + "dusty-terrier-984": 830271, + "valiant-whale-55": 830330, + "fortunate-camel-225": 830331, + "confident-squid-295": 830334, + "bright-alligator-20": 830335, + "affable-scorpion-740": 830372, + "adjoining-beagle-942": 830373, + "glorious-finch-812": 830375, + "little-lapwing-299": 830378, + "pleasant-civet-717": 830379, + "determined-oriole-682": 830380, + "quick-oriole-166": 830382, + "wooden-hawk-258": 830407, + "dazzling-hedgehog-418": 830408, + "watchful-bandicoot-711": 830603, + "sincere-snake-581": 830604, + "fastidious-finch-866": 830630, + "groovy-monitor-679": 830632, + "loyal-vulture-117": 830634, + "quaint-terrier-757": 830636, + "grateful-rook-0": 830678, + "terrific-sparrow-417": 830679, + "quick-snail-552": 830680, + "warmhearted-okapi-872": 830681, + "wonderful-grasshopper-476": 830683, + "uncommon-buffalo-220": 830686, + "adventurous-stoat-916": 830687, + "tangible-anaconda-114": 830688, + "chatty-impala-192": 830689, + "loyal-jackal-728": 830692, + "beloved-seahorse-327": 830693, + "usable-pig-462": 830694, + "adorable-sheep-853": 830695, + "notable-grouse-671": 830696, + "decisive-lapwing-46": 830697, + "tacit-cobra-335": 830699, + "incredible-kingfisher-67": 830700, + "calm-butterfly-593": 830703, + "ardent-bandicoot-724": 830704, + "agile-lapwing-539": 830704, + "rightful-spider-215": 830718, + "impressive-anaconda-971": 830719, + "clear-akita-833": 830720, + "quick-deer-177": 830721, + "expert-goshawk-753": 830723, + "mellow-gopher-505": 830724, + "ardent-antelope-686": 830729, + "reminiscent-gazelle-304": 830730, + "ardent-lark-395": 830731, + "silent-tortoise-632": 830732, + "pastel-mule-296": 830733, + "flexible-goat-625": 830734, + "polite-stoat-886": 830769, + "rare-goshawk-285": 830770, + "little-chipmunk-832": 830960, + "flexible-iguana-827": 830961, + "agreeable-dove-301": 830963, + "shiny-fox-134": 831794, + "valiant-orca-355": 831795, + "wooden-gnat-608": 831969, + "glad-mammoth-431": 831970, + "cool-quail-797": 832178, + "tremendous-scorpion-324": 832179, + "spotted-zebra-85": 832560, + "beloved-pheasant-163": 832561, + "prestigious-herring-198": 832591, + "coordinated-spaniel-518": 832593, + "elated-crow-986": 832593, + "reliable-jackal-245": 832922, + "sensible-starling-549": 832924, + "earnest-cormorant-197": 833021, + "wooden-mallard-989": 833022, + "giddy-caterpillar-21": 833330, + "wry-buffalo-923": 833331, + "exuberant-oyster-623": 833335, + "grand-setter-198": 833336, + "flippant-ibis-251": 833340, + "dependable-orca-882": 833341, + "fleet-spider-598": 833368, + "industrious-kangaroo-745": 833369, + "merry-buffalo-911": 833544, + "earnest-squid-927": 833545, + "secret-ibex-517": 833545, + "stoic-aardvark-613": 833547, + "impartial-roadrunner-453": 833548, + "keen-toucan-482": 833784, + "dynamic-iguana-315": 833785, + "fabulous-ox-364": 833866, + "energetic-sheep-424": 833867, + "accomplished-frog-311": 833967, + "cool-trout-29": 833968, + "flippant-perch-311": 833971, + "knowing-oriole-183": 833972, + "energized-lion-885": 833986, + "exuberant-eagle-483": 833987, + "zany-ibex-166": 834448, + "ceaseless-warbler-881": 834449, + "original-poodle-838": 834689, + "bright-meerkat-112": 834690, + "impartial-guineapig-472": 834887, + "elegant-malamute-330": 834888, + "joyous-gnat-770": 834928, + "perfect-herring-973": 834929, + "impartial-pheasant-719": 834941, + "affable-leopard-378": 834942, + "opulent-chameleon-938": 834942, + "exciting-axolotl-883": 834954, + "upbeat-yak-685": 834955, + "glorious-cardinal-277": 835011, + "bright-goose-47": 835012, + "graceful-weasel-352": 835015, + "shocking-aardvark-468": 835016, + "first-llama-706": 835017, + "next-blackbird-535": 835018, + "colorless-elk-144": 835023, + "terrific-spider-368": 835024, + "little-bloodhound-124": 835034, + "harmless-heron-275": 835035, + "incredible-chameleon-907": 835040, + "peaceful-wren-956": 835041, + "fortunate-ferret-485": 835045, + "joyous-marmot-910": 835046, + "moonlit-coyote-435": 835060, + "handsome-chinchilla-765": 835061, + "amicable-warbler-60": 835074, + "astute-cormorant-477": 835075, + "perceptive-setter-511": 835089, + "qualified-gecko-460": 835090, + "uncommon-goldfish-712": 835095, + "scintillating-lobster-10": 835096, + "incredible-rook-767": 835098, + "basic-elk-583": 835099, + "disciplined-egret-579": 835108, + "savory-parakeet-571": 835109, + "cautious-shark-208": 835110, + "neighborly-squirrel-929": 835111, + "glorious-alligator-229": 835111, + "fleet-eel-786": 835112, + "agile-peacock-393": 835113, + "neat-minnow-527": 835117, + "acrobatic-lobster-469": 835118, + "calm-whale-283": 835119, + "rapid-ox-871": 835120, + "precise-ox-564": 835129, + "adjoining-lapwing-492": 835130, + "sensible-jaguar-790": 835134, + "tidy-rat-51": 835135, + "clean-caiman-690": 835135, + "tough-lyrebird-93": 835142, + "animated-lapwing-215": 835143, + "reliable-wolverine-384": 835143, + "content-basilisk-272": 835164, + "acrobatic-impala-724": 835165, + "charming-sturgeon-896": 835165, + "avid-bloodhound-348": 835169, + "graceful-sockeye-1": 835170, + "watchful-barracuda-413": 835175, + "shocking-horse-744": 835177, + "opulent-chicken-0": 835177, + "modest-rabbit-957": 835198, + "quaint-whale-323": 835199, + "good-partridge-946": 835220, + "doting-skunk-127": 835233, + "dapper-clam-429": 835235, + "friendly-dove-333": 835239, + "oceanic-goldfinch-358": 835240, + "glorious-antelope-260": 835292, + "admired-lemur-976": 835293, + "cheerful-bobcat-128": 835293, + "shocking-llama-768": 835326, + "utmost-emu-84": 835327, + "incredible-retriever-281": 835327, + "first-ferret-256": 835328, + "determined-cassowary-212": 835329, + "outgoing-pigeon-69": 835329, + "helpful-heron-554": 835341, + "incredible-camel-739": 835342, + "glorious-wolverine-332": 835345, + "kindhearted-axolotl-393": 835346, + "fiery-capybara-619": 835346, + "exuberant-grasshopper-789": 835355, + "prestigious-buffalo-727": 835356, + "colorful-wildebeest-868": 835356, + "tremendous-warthog-886": 835362, + "artful-corgi-939": 835363, + "wandering-swordfish-619": 835363, + "dynamic-possum-945": 835397, + "qualified-bandicoot-407": 835398, + "wry-poodle-535": 835402, + "rosy-chicken-411": 835403, + "deafening-starfish-803": 835404, + "woozy-mandrill-644": 835405, + "avid-flamingo-968": 835410, + "aromatic-cat-386": 835411, + "wandering-monitor-196": 835413, + "efficient-bison-593": 835417, + "clever-chameleon-887": 835419, + "knowing-marten-83": 835454, + "colorless-tern-528": 835455, + "shocking-salamander-480": 835457, + "disciplined-woodpecker-572": 835458, + "posh-barracuda-939": 835460, + "necessary-cheetah-92": 835461, + "wonderful-monitor-803": 835472, + "wry-snail-740": 835473, + "famous-dog-69": 835473, + "rugged-wolverine-30": 835478, + "cool-crocodile-422": 835480, + "fabulous-pig-793": 835486, + "clean-loris-627": 835487, + "exuberant-elk-168": 835510, + "groovy-scorpion-995": 835512, + "insightful-kiwi-854": 835512, + "astute-gazelle-993": 835556, + "coordinated-dolphin-108": 835558, + "fearless-echidna-319": 835558, + "hardy-hyena-809": 835565, + "first-flamingo-37": 835566, + "tremendous-herring-31": 835568, + "dynamic-crow-195": 835571, + "honorable-bear-64": 835571, + "hushed-impala-216": 835596, + "loyal-axolotl-27": 835597, + "limitless-gazelle-917": 835599, + "dapper-egret-539": 835600, + "affable-hummingbird-888": 835613, + "impressive-peccary-464": 835615, + "energetic-salmon-482": 835616, + "industrious-weasel-360": 835617, + "upbeat-hyena-841": 835617, + "sleek-mongoose-465": 835630, + "incredible-squid-310": 835631, + "academic-impala-662": 835639, + "small-duck-642": 835640, + "vivid-crab-343": 835663, + "robust-gnu-192": 835665, + "merry-malamute-474": 835670, + "canny-hedgehog-891": 835672, + "energetic-narwhal-139": 835675, + "small-dogfish-966": 835677, + "clever-rook-809": 835677, + "lovely-mockingbird-227": 835679, + "rosy-antelope-645": 835680, + "grateful-mule-353": 835684, + "gallant-jackal-584": 835685, + "colorless-quail-900": 835749, + "fleet-barracuda-961": 835750, + "outstanding-walrus-138": 835753, + "formal-cricket-851": 835754, + "clear-octopus-153": 835755, + "incredible-goose-376": 835757, + "rugged-cheetah-837": 835760, + "dynamic-bear-408": 835761, + "glorious-minnow-852": 835762, + "aromatic-parakeet-979": 835763, + "glorious-chipmunk-37": 835764, + "robust-salamander-359": 835769, + "lovable-lynx-104": 835770, + "modest-gopher-53": 835772, + "insightful-stingray-65": 835775, + "utmost-tern-497": 835776, + "blissful-mongoose-691": 835778, + "modest-egret-723": 835779, + "vibrant-hedgehog-685": 835780, + "quaint-blackbird-552": 835781, + "uncommon-cricket-357": 835782, + "curious-malamute-766": 835783, + "deafening-canary-639": 835788, + "fast-okapi-754": 835789, + "secret-salamander-408": 836246, + "clever-dotterel-505": 836248, + "cheery-shrimp-345": 836537, + "festive-basilisk-523": 836538, + "ardent-lemming-7": 836578, + "glorious-sockeye-308": 836579, + "adventurous-kudu-457": 836609, + "marvelous-poodle-814": 836610, + "brilliant-fennec-161": 836650, + "industrious-dove-555": 836651, + "polite-pelican-174": 836665, + "watchful-kiwi-364": 836666, + "helpful-viper-320": 836759, + "trustworthy-flamingo-817": 836760, + "peaceful-ocelot-945": 836945, + "fine-tiger-590": 836946, + "youthful-magpie-786": 837049, + "blessed-gazelle-947": 837050, + "hardy-ram-819": 837050, + "hallowed-wildebeest-228": 837056, + "brave-capybara-878": 837057, + "unique-meerkat-847": 837060, + "admired-magpie-205": 837062, + "acoustic-hippopotamus-57": 837062, + "quaint-terrier-380": 837063, + "dependable-shepherd-209": 837065, + "quaint-goat-242": 837066, + "quixotic-impala-242": 837067, + "coordinated-newt-72": 837067, + "posh-lobster-554": 837069, + "canny-partridge-936": 837071, + "rare-toucan-428": 837073, + "judicious-ram-881": 837074, + "useful-ptarmigan-673": 837074, + "befitting-opossum-774": 837075, + "fortunate-hippopotamus-379": 837076, + "valiant-clownfish-705": 837078, + "clean-goat-399": 837081, + "disciplined-possum-477": 837082, + "different-finch-658": 837083, + "nautical-fish-574": 837083, + "ardent-crane-170": 837084, + "careful-camel-472": 837085, + "fortunate-pheasant-518": 837089, + "hushed-jellyfish-162": 837090, + "cheery-pony-498": 837090, + "hidden-rhinoceros-395": 837092, + "resilient-bison-654": 837093, + "dynamic-chinchilla-276": 837104, + "fine-horse-950": 837105, + "elegant-civet-213": 837128, + "next-bee-910": 837129, + "resolute-anaconda-587": 837130, + "hardy-turtle-389": 837131, + "chatty-whale-427": 837133, + "loyal-leopard-216": 837134, + "chatty-iguana-600": 837134, + "hushed-alpaca-227": 837143, + "gallant-crocodile-821": 837144, + "earnest-narwhal-57": 837155, + "hallowed-heron-600": 837156, + "brainy-opossum-696": 837156, + "quaint-quail-939": 837160, + "opulent-lark-248": 837161, + "colorless-rabbit-875": 837162, + "fabulous-whale-877": 837163, + "kindhearted-cobra-744": 837167, + "usable-magpie-709": 837168, + "sincere-avocet-735": 837168, + "resolute-mastiff-405": 837172, + "fleet-gopher-575": 837174, + "capable-jay-217": 837175, + "determined-snail-588": 837176, + "graceful-bass-736": 837180, + "agile-eel-4": 837181, + "beloved-wolf-555": 837189, + "cautious-eel-319": 837190, + "deafening-finch-380": 837193, + "descriptive-parrot-13": 837195, + "warmhearted-bandicoot-945": 837203, + "abundant-dog-936": 837204, + "dashing-caribou-999": 837204, + "handsome-guineapig-720": 837213, + "efficient-husky-514": 837214, + "tacit-eel-216": 837217, + "utmost-sturgeon-110": 837218, + "decisive-lyrebird-528": 837220, + "dutiful-rook-623": 837221, + "pleasant-canary-659": 837230, + "unique-rooster-48": 837231, + "proper-corgi-465": 837236, + "adjoining-sandpiper-677": 837237, + "little-wolverine-314": 837244, + "beloved-ox-173": 837246, + "fantastic-dolphin-180": 837253, + "quick-caribou-3": 837254, + "enchanted-tiger-624": 837260, + "incredible-scorpion-35": 837261, + "glorious-porcupine-300": 837261, + "accurate-raccoon-502": 837273, + "reminiscent-narwhal-73": 837274, + "quick-panda-949": 837285, + "uncommon-chicken-316": 837286, + "modest-skunk-594": 837286, + "striped-dodo-456": 837287, + "judicious-rhinoceros-498": 837288, + "superb-raven-991": 837299, + "dependable-elk-744": 837301, + "determined-canary-518": 837301, + "affable-schnauzer-562": 837329, + "brainy-crow-648": 837330, + "resolute-snail-501": 837333, + "sleek-vulture-892": 837334, + "pleasant-skunk-884": 837337, + "marvelous-wren-412": 837338, + "pleasant-snake-517": 837338, + "neat-salmon-643": 837344, + "capable-jaguar-579": 837345, + "pastel-albatross-513": 837345, + "industrious-retriever-827": 837346, + "dependable-ladybug-926": 837348, + "qualified-roadrunner-222": 837348, + "watchful-seahorse-905": 837358, + "wry-chinchilla-538": 837359, + "reminiscent-dragon-640": 837359, + "chatty-chinchilla-973": 837360, + "shiny-mosquito-231": 837361, + "quiet-llama-6": 837361, + "adept-seal-529": 837367, + "beloved-mule-716": 837368, + "savory-sparrow-681": 837369, + "impartial-dalmatian-708": 837370, + "mild-jay-682": 837376, + "courteous-badger-424": 837377, + "next-canary-824": 837378, + "fastidious-pigeon-632": 837379, + "frugal-fly-181": 837379, + "kindhearted-marlin-362": 837382, + "colorless-swan-752": 837383, + "impartial-cardinal-634": 837384, + "sleek-llama-112": 837386, + "admired-egret-206": 837395, + "tough-impala-919": 837396, + "watchful-eel-20": 837396, + "kindred-squid-334": 837411, + "upbeat-malamute-650": 837412, + "laudable-goshawk-144": 837418, + "marvelous-sheep-416": 837419, + "giddy-grouse-843": 837438, + "colorless-salmon-806": 837439, + "cheerful-wren-600": 837439, + "majestic-viper-294": 837441, + "stoic-avocet-23": 837442, + "terrific-swan-505": 837458, + "accomplished-mockingbird-44": 837459, + "quirky-caiman-506": 837463, + "whimsical-pheasant-670": 837465, + "acrobatic-chipmunk-179": 837471, + "dutiful-lion-357": 837472, + "cool-dinosaur-987": 837472, + "greedy-manatee-232": 837475, + "moonlit-falcon-4": 837476, + "valuable-gecko-761": 837489, + "mellow-heron-717": 837490, + "rightful-ibis-591": 837491, + "beaming-basilisk-1": 837492, + "amiable-hummingbird-510": 837492, + "cheery-gnu-551": 837503, + "shiny-lemming-615": 837504, + "sleek-skunk-136": 837504, + "elated-mongoose-279": 837538, + "clean-albatross-522": 837539, + "capable-lion-183": 837542, + "keen-vulture-431": 837543, + "neat-narwhal-995": 837543, + "agreeable-schnauzer-551": 837557, + "good-porcupine-338": 837558, + "loyal-reindeer-290": 837559, + "tremendous-mammoth-871": 837561, + "wonderful-magpie-426": 837561, + "cheerful-marlin-560": 837567, + "useful-chipmunk-146": 837570, + "friendly-husky-948": 837576, + "brainy-poodle-826": 837577, + "fastidious-manatee-488": 837588, + "outgoing-sandpiper-327": 837589, + "limitless-dodo-853": 837602, + "perceptive-otter-462": 837603, + "clean-boar-515": 837603, + "combative-goldfish-196": 837622, + "elated-jackal-44": 837623, + "formal-tern-803": 837625, + "zany-chipmunk-281": 837626, + "flippant-hummingbird-228": 837655, + "bright-swan-314": 837656, + "amicable-gnu-860": 837661, + "sleek-dolphin-480": 837662, + "neat-chipmunk-792": 837662, + "colorless-whale-555": 837689, + "accomplished-civet-965": 837690, + "accomplished-monitor-439": 837693, + "knowing-kiwi-519": 837694, + "quaint-ostrich-301": 837699, + "neighborly-oyster-904": 837700, + "warmhearted-greyhound-87": 837700, + "good-seahorse-725": 837711, + "admired-trout-406": 837712, + "greedy-sturgeon-816": 837719, + "glorious-spoonbill-523": 837720, + "moonlit-gecko-926": 837727, + "descriptive-dogfish-660": 837728, + "exuberant-fly-255": 837731, + "dashing-dodo-426": 837732, + "brazen-pig-68": 837732, + "vivid-ram-585": 837734, + "elegant-ferret-735": 837735, + "dependable-aardvark-372": 837769, + "bright-mule-433": 837770, + "incredible-newt-645": 837782, + "colorless-jackal-193": 837783, + "reliable-zebra-497": 837783, + "colorless-flamingo-299": 837790, + "pleasant-fennec-779": 837796, + "blessed-shepherd-185": 837837, + "resolute-antelope-979": 837839, + "silent-grouse-654": 837841, + "elated-heron-823": 837842, + "trustworthy-minnow-861": 837850, + "giant-setter-166": 837851, + "necessary-dog-840": 837851, + "vibrant-hound-202": 837889, + "clear-dachshund-781": 837890, + "rapid-bloodhound-794": 837899, + "pastel-panther-304": 837900, + "prestigious-dolphin-755": 837920, + "hidden-curlew-970": 837921, + "resilient-antelope-112": 837924, + "gallant-terrier-609": 837925, + "calm-llama-612": 837930, + "precise-corgi-198": 837931, + "combative-aardvark-743": 837937, + "spotted-koala-965": 837938, + "grateful-pika-374": 837959, + "honorable-bullfrog-529": 837960, + "fine-akita-240": 837961, + "vivid-toucan-520": 837962, + "blessed-impala-825": 837963, + "fast-chinchilla-392": 837965, + "amicable-gnu-827": 837967, + "rare-mockingbird-887": 837970, + "greedy-badger-433": 837971, + "energized-goldfinch-242": 837977, + "benevolent-yak-438": 837978, + "notable-butterfly-910": 837980, + "perceptive-otter-222": 837981, + "artful-walrus-802": 837982, + "first-badger-535": 837983, + "accomplished-tapir-321": 837984, + "canny-greyhound-980": 837985, + "pleasant-bobcat-154": 837986, + "tacit-hippopotamus-937": 837987, + "admired-nightingale-427": 837989, + "cool-dog-193": 837990, + "beloved-bee-74": 837991, + "valuable-gecko-637": 837992, + "deafening-hyena-336": 837993, + "academic-gnat-890": 838004, + "clever-retriever-764": 838006, + "fleet-dalmatian-127": 838006, + "hip-raccoon-517": 838012, + "glad-basilisk-469": 838013, + "laudable-ostrich-429": 838019, + "keen-meadowlark-381": 838020, + "tangible-ocelot-988": 838020, + "reliable-rhinoceros-523": 838030, + "brilliant-viper-535": 838031, + "brave-ostrich-958": 838037, + "charming-emu-389": 838039, + "hardy-llama-192": 838039, + "charming-echidna-506": 838058, + "dependable-rook-433": 838059, + "adventurous-lynx-174": 838068, + "hidden-marmot-405": 838069, + "animated-leopard-855": 838069, + "watchful-moose-334": 838080, + "quiet-fish-508": 838081, + "beloved-camel-812": 838084, + "honorable-wildebeest-343": 838085, + "nautical-stork-760": 838088, + "harmless-ostrich-351": 838089, + "deafening-roadrunner-372": 838093, + "brilliant-tiger-688": 838094, + "watchful-hound-188": 838105, + "effervescent-sardine-397": 838106, + "posh-bison-669": 838108, + "vibrant-marmot-472": 838110, + "proficient-wildcat-319": 838111, + "dependable-barracuda-829": 838112, + "quirky-possum-658": 838113, + "sincere-butterfly-79": 838128, + "zealous-puffin-783": 838129, + "giant-ferret-789": 838133, + "astute-boar-757": 838134, + "gregarious-pelican-348": 838137, + "outstanding-greyhound-666": 838138, + "optimistic-panther-133": 838146, + "rare-impala-332": 838147, + "moonlit-panda-296": 838149, + "stoic-minnow-576": 838150, + "blessed-deer-786": 838151, + "precious-newt-615": 838154, + "hearty-penguin-647": 838155, + "unique-cat-540": 838156, + "agile-eel-287": 838157, + "watchful-kingfisher-844": 838159, + "modest-yak-418": 838160, + "adamant-schnauzer-880": 838169, + "neat-horse-502": 838170, + "glorious-condor-389": 838170, + "trustworthy-bloodhound-655": 838212, + "keen-armadillo-510": 838213, + "precious-viper-546": 838218, + "steady-akita-538": 838219, + "abundant-grasshopper-285": 838219, + "glad-bulldog-681": 838248, + "proper-whale-562": 838249, + "graceful-spoonbill-595": 838252, + "warmhearted-pelican-909": 838253, + "polished-scorpion-514": 838253, + "posh-spoonbill-340": 838254, + "quiet-mallard-900": 838255, + "patient-sardine-752": 838270, + "descriptive-cobra-341": 838271, + "vibrant-weasel-426": 838283, + "helpful-civet-94": 838284, + "successful-mastiff-797": 838284, + "dapper-flamingo-21": 838310, + "artful-gnat-366": 838311, + "perfect-ocelot-784": 838313, + "majestic-bloodhound-808": 838314, + "rosy-robin-669": 838315, + "usable-hamster-202": 838316, + "accurate-clam-206": 838319, + "optimistic-egret-665": 838324, + "usable-snake-569": 838325, + "brave-deer-835": 838330, + "perceptive-falcon-269": 838331, + "shocking-mole-103": 838334, + "focused-greyhound-393": 838335, + "clean-koala-951": 838349, + "fiery-clam-982": 838350, + "cheery-rat-454": 838353, + "earnest-lyrebird-723": 838354, + "gallant-scorpion-496": 838356, + "notable-mammoth-551": 838357, + "incredible-gnat-816": 838363, + "colorless-tiger-538": 838364, + "striped-toucan-592": 838366, + "pleasant-hippopotamus-381": 838367, + "dusty-mosquito-849": 838376, + "loyal-snail-461": 838377, + "charming-gnu-115": 838380, + "elated-basilisk-956": 838381, + "clean-poodle-530": 838383, + "valiant-monitor-518": 838384, + "fabulous-meerkat-111": 838388, + "upbeat-pelican-108": 838391, + "enduring-bullfrog-225": 838392, + "hallowed-anteater-9": 838393, + "useful-robin-612": 838394, + "compassionate-jaguar-385": 838399, + "expert-egret-685": 838400, + "superb-aardvark-102": 838402, + "hallowed-pika-176": 838403, + "beaming-grasshopper-314": 838403, + "compassionate-fennec-375": 838413, + "marvelous-lion-714": 838415, + "giant-curlew-307": 838416, + "glorious-kangaroo-419": 838417, + "content-mammoth-503": 838418, + "agreeable-albatross-875": 838419, + "valiant-wolf-865": 838422, + "chatty-rhinoceros-91": 838422, + "vibrant-lyrebird-136": 838424, + "earnest-loris-149": 838427, + "proper-caterpillar-890": 838433, + "grand-shepherd-701": 838435, + "festive-giraffe-254": 838435, + "impressive-porpoise-6": 838436, + "wooden-porcupine-973": 838437, + "rapid-buzzard-73": 838438, + "woozy-narwhal-450": 838438, + "careful-llama-306": 838439, + "basic-dog-375": 838454, + "dusty-kingfisher-895": 838455, + "dapper-bobcat-110": 838458, + "adept-roadrunner-595": 838459, + "coordinated-fish-896": 838475, + "first-lemur-583": 838476, + "small-raven-721": 838476, + "frugal-boar-464": 838477, + "giddy-chihuahua-561": 838478, + "courteous-dolphin-467": 838492, + "notable-donkey-858": 838495, + "kindred-donkey-929": 838497, + "affable-labrador-563": 838498, + "focused-walrus-924": 838498, + "fleet-lapwing-358": 838535, + "adjoining-malamute-588": 838536, + "dapper-anaconda-580": 838541, + "watchful-cardinal-251": 838542, + "dazzling-pig-826": 838542, + "robust-jellyfish-671": 838546, + "compassionate-dotterel-128": 838547, + "formal-fly-700": 838550, + "keen-porcupine-807": 838551, + "wonderful-iguana-826": 838551, + "agreeable-parakeet-262": 838560, + "grateful-jay-157": 838562, + "valiant-ibis-396": 838562, + "keen-dotterel-84": 838566, + "vibrant-newt-376": 838568, + "third-ram-308": 838579, + "giddy-goat-747": 838580, + "astute-zebra-704": 838588, + "gallant-raccoon-134": 838589, + "usable-setter-539": 838589, + "youthful-meadowlark-396": 838622, + "uncommon-armadillo-494": 838624, + "zealous-dinosaur-558": 838625, + "silent-crow-881": 838626, + "curious-mink-890": 838626, + "whimsical-ox-647": 838638, + "youthful-sturgeon-847": 838639, + "proficient-trout-983": 838639, + "perceptive-wombat-533": 838643, + "shiny-lobster-950": 838644, + "enchanted-clownfish-149": 838644, + "proficient-basilisk-872": 838651, + "expert-civet-718": 838652, + "accurate-quail-678": 838677, + "moonlit-kudu-143": 838678, + "astute-kookabura-584": 838698, + "quixotic-meadowlark-519": 838699, + "colorful-oyster-806": 838707, + "glorious-civet-996": 838708, + "wonderful-cat-911": 838719, + "exuberant-poodle-177": 838720, + "watchful-spider-813": 838720, + "adamant-buffalo-748": 838721, + "hip-goat-967": 838722, + "modest-gecko-235": 838733, + "judicious-gull-588": 838734, + "youthful-setter-30": 838742, + "enchanted-crane-122": 838743, + "tacit-marmot-302": 838770, + "colorful-bandicoot-462": 838771, + "basic-grasshopper-805": 838774, + "kindly-frog-601": 838775, + "jovial-lark-449": 838775, + "clear-fennec-744": 838776, + "honorable-puffin-94": 838777, + "nautical-warthog-448": 838786, + "quiet-fish-951": 838787, + "notable-emu-63": 838799, + "robust-ostrich-949": 838800, + "brazen-ibex-171": 838805, + "vivid-caribou-137": 838807, + "necessary-okapi-285": 838818, + "gallant-cow-554": 838819, + "amicable-wildcat-686": 838819, + "grandiose-buzzard-283": 838830, + "resolute-gerbil-687": 838831, + "glorious-avocet-625": 838848, + "kindly-trout-282": 838849, + "tidy-anteater-412": 838850, + "gallant-possum-552": 838851, + "third-cod-328": 838869, + "dapper-hawk-281": 838870, + "zany-dalmatian-157": 838876, + "wonderful-kingfisher-85": 838877, + "dynamic-narwhal-595": 838877, + "quaint-puma-68": 838879, + "affable-ladybug-374": 838880, + "qualified-tapir-241": 838892, + "groovy-cricket-430": 838893, + "nautical-akita-867": 838926, + "adorable-mosquito-384": 838927, + "judicious-nightingale-999": 838957, + "terrific-ocelot-96": 838958, + "famous-pigeon-45": 838958, + "usable-axolotl-244": 838959, + "scrupulous-kudu-915": 838960, + "coordinated-lobster-689": 838960, + "little-dogfish-232": 838963, + "disciplined-gnat-441": 838964, + "uncommon-gnu-982": 838966, + "tangible-gnat-483": 838967, + "tough-antelope-65": 838990, + "shocking-grasshopper-913": 838991, + "frugal-alpaca-669": 838998, + "canny-corgi-68": 838999, + "resilient-mule-453": 838999, + "strong-lapwing-567": 839002, + "energized-swordfish-803": 839003, + "colorful-bird-379": 839013, + "flippant-cuttlefish-261": 839014, + "careful-yak-917": 839028, + "shocking-ox-112": 839029, + "woozy-bear-174": 839093, + "adventurous-civet-124": 839094, + "brazen-armadillo-441": 839107, + "hardy-quail-59": 839108, + "kindhearted-flamingo-482": 839123, + "hardy-rhinoceros-902": 839124, + "exciting-firefly-263": 839126, + "tidy-ram-86": 839128, + "coordinated-walrus-122": 839133, + "modest-fly-354": 839134, + "insightful-magpie-676": 839134, + "frugal-aardvark-792": 839141, + "energized-malamute-179": 839142, + "grand-ibex-891": 839142, + "harmless-rat-644": 839143, + "majestic-hyena-711": 839144, + "graceful-pigeon-861": 839153, + "quiet-gazelle-150": 839154, + "terrific-curlew-525": 839159, + "clean-whale-100": 839160, + "kindred-spider-965": 839175, + "aromatic-dachshund-987": 839176, + "giddy-condor-759": 839179, + "focused-wildcat-996": 839180, + "befitting-lynx-997": 839187, + "outgoing-otter-111": 839188, + "greedy-rhinoceros-122": 839201, + "veracious-jellyfish-142": 839203, + "youthful-cricket-747": 839237, + "diligent-quail-658": 839238, + "modest-chicken-760": 839240, + "vivid-guineapig-88": 839241, + "ceaseless-hippopotamus-280": 839244, + "adorable-sturgeon-244": 839245, + "aware-crow-865": 839261, + "hardy-sturgeon-255": 839262, + "rapid-herring-482": 839266, + "lovable-mouse-624": 839267, + "marvelous-alpaca-428": 839279, + "flippant-shrimp-853": 839281, + "different-cobra-70": 839281, + "adjoining-shrimp-626": 839322, + "polite-mouse-866": 839323, + "loyal-lyrebird-954": 839339, + "elegant-monitor-352": 839340, + "harmless-coyote-850": 839388, + "first-wildcat-961": 839389, + "combative-dotterel-636": 839406, + "rosy-giraffe-489": 839407, + "valuable-alpaca-837": 839431, + "amiable-hummingbird-453": 839432, + "dazzling-nightingale-422": 839442, + "cool-donkey-615": 839444, + "warmhearted-crane-468": 839467, + "efficient-dalmatian-922": 839468, + "outstanding-tern-859": 839497, + "warmhearted-iguana-995": 839498, + "effervescent-crab-309": 839511, + "nautical-spider-243": 839512, + "reliable-duck-435": 839518, + "youthful-sparrow-434": 839519, + "agile-frog-143": 839544, + "acoustic-peacock-269": 839545, + "trustworthy-cow-540": 839545, + "astute-kingfisher-763": 839556, + "impartial-lemur-948": 839557, + "formal-ocelot-49": 839591, + "steady-raven-709": 839592, + "first-camel-680": 839595, + "bright-puffin-208": 839596, + "charming-rook-999": 839596, + "coordinated-partridge-652": 839599, + "woozy-bear-778": 839600, + "woozy-trout-124": 839602, + "quaint-gazelle-19": 839605, + "friendly-dodo-947": 839606, + "enchanted-schnauzer-55": 839607, + "ardent-rhinoceros-289": 839616, + "agreeable-llama-284": 839617, + "grateful-gnat-656": 839618, + "mild-clownfish-932": 839619, + "quiet-goat-687": 839620, + "valiant-barracuda-369": 839622, + "secret-bobcat-753": 839623, + "whimsical-parakeet-228": 839626, + "qualified-hyena-310": 839627, + "gregarious-condor-385": 839628, + "utmost-bee-380": 839629, + "mild-fennec-835": 839635, + "vibrant-emu-258": 839636, + "hidden-puma-670": 839651, + "energized-vole-502": 839652, + "canny-ocelot-211": 839652, + "neighborly-anaconda-297": 839657, + "original-herring-675": 839658, + "little-falcon-130": 839669, + "capable-pigeon-454": 839670, + "beloved-seahorse-770": 839703, + "brazen-hummingbird-246": 839704, + "clear-koala-415": 839706, + "colorful-ram-906": 839708, + "frugal-wombat-277": 839713, + "frugal-porcupine-438": 839714, + "animated-giraffe-22": 839719, + "hushed-goose-766": 839720, + "standing-malamute-908": 839732, + "capable-seahorse-228": 839733, + "hardy-walrus-135": 839740, + "bright-dolphin-600": 839741, + "moonlit-lynx-339": 839742, + "aromatic-echidna-448": 839743, + "chatty-pig-872": 839748, + "gallant-snail-427": 839749, + "sensible-wolverine-546": 839755, + "brilliant-hare-254": 839756, + "elegant-albatross-799": 839762, + "silent-seal-449": 839763, + "shocking-stingray-339": 839777, + "dynamic-whale-984": 839778, + "harmless-marmot-150": 839779, + "upbeat-marten-533": 839781, + "fine-newt-150": 839782, + "reminiscent-camel-746": 839783, + "curious-oyster-512": 839784, + "superb-corgi-446": 839785, + "accomplished-goose-824": 839786, + "small-rat-704": 839787, + "jovial-squirrel-334": 839788, + "shiny-emu-117": 839789, + "wooden-flamingo-287": 839789, + "dapper-walrus-503": 839793, + "perceptive-caribou-61": 839794, + "quick-pheasant-389": 839795, + "admired-emu-952": 839796, + "zealous-hare-882": 839797, + "quaint-clownfish-686": 839798, + "qualified-owl-454": 839799, + "brazen-ptarmigan-552": 839803, + "lovable-kudu-332": 839804, + "admired-whale-343": 839805, + "festive-owl-404": 839806, + "dashing-bee-199": 839807, + "blessed-wombat-39": 839808, + "flippant-kiwi-209": 839809, + "grateful-capybara-275": 839810, + "flippant-llama-788": 839811, + "expert-ibex-846": 839813, + "determined-oyster-674": 839856, + "usable-corgi-693": 839857, + "wary-okapi-675": 839863, + "successful-toucan-409": 839864, + "accomplished-husky-454": 839878, + "kindhearted-kangaroo-798": 839879, + "adorable-fennec-72": 839881, + "shocking-aardvark-18": 839883, + "proper-echidna-15": 839885, + "reliable-otter-383": 839886, + "grand-sheep-799": 839888, + "stoic-moose-438": 839923, + "dapper-orca-242": 839924, + "ardent-mammoth-38": 839928, + "first-mongoose-287": 839929, + "wandering-platypus-297": 839951, + "energetic-aardvark-114": 839952, + "impressive-clam-878": 839952, + "kindred-rook-137": 839966, + "whimsical-ocelot-973": 839967, + "notable-bulldog-273": 839981, + "enchanted-ermine-833": 839982, + "grateful-axolotl-676": 840032, + "resilient-monitor-645": 840033, + "pastel-perch-362": 840056, + "shiny-opossum-783": 840057, + "strong-condor-913": 840092, + "polite-viper-870": 840093, + "basic-goose-493": 840109, + "qualified-chicken-212": 840110, + "outgoing-rhinoceros-722": 840113, + "hallowed-lapwing-265": 840114, + "fiery-gnat-78": 840117, + "aromatic-condor-742": 840118, + "glad-snail-815": 840118, + "agile-dolphin-966": 840119, + "secret-tapir-394": 840120, + "bright-canary-95": 840126, + "prestigious-grouse-624": 840127, + "harmless-butterfly-872": 840127, + "calm-bison-628": 840133, + "grateful-ant-61": 840134, + "enduring-lark-479": 840134, + "rare-mongoose-408": 840165, + "unique-elk-334": 840166, + "honorable-boar-968": 840169, + "efficient-anteater-984": 840170, + "courteous-caterpillar-202": 840170, + "chatty-caterpillar-527": 840176, + "energetic-roadrunner-34": 840177, + "adorable-cuttlefish-961": 840177, + "perfect-bass-142": 840179, + "vivid-chicken-715": 840180, + "cautious-stork-953": 840196, + "colorless-dinosaur-427": 840198, + "neat-cardinal-552": 840231, + "wary-moose-442": 840232, + "glorious-minnow-783": 840235, + "polished-labrador-514": 840236, + "merry-mallard-577": 840263, + "accurate-caribou-267": 840264, + "trustworthy-squirrel-181": 840268, + "jovial-dogfish-26": 840271, + "festive-marlin-566": 840271, + "beaming-cormorant-832": 840283, + "rapid-marmot-51": 840284, + "flexible-walrus-305": 840293, + "handsome-bison-331": 840294, + "whimsical-octopus-754": 840297, + "giddy-dolphin-764": 840298, + "perceptive-elephant-162": 840308, + "upbeat-lion-40": 840309, + "effervescent-seal-146": 840338, + "wry-sturgeon-289": 840339, + "healthy-toucan-42": 840340, + "giant-otter-351": 840341, + "dutiful-corgi-113": 840345, + "content-spider-682": 840346, + "tacit-hare-74": 840354, + "terrific-wildcat-325": 840355, + "accurate-goshawk-333": 840361, + "loyal-cat-500": 840362, + "gregarious-shrimp-606": 840375, + "tame-marlin-989": 840376, + "dusty-egret-208": 840383, + "modest-retriever-251": 840384, + "first-oyster-986": 840384, + "precise-possum-9": 840386, + "careful-llama-146": 840390, + "friendly-ocelot-309": 840406, + "third-hornet-574": 840407, + "different-mule-392": 840412, + "mellow-quail-53": 840413, + "uncommon-echidna-988": 840420, + "accomplished-lapwing-711": 840421, + "pleasant-bat-75": 840424, + "striped-kingfisher-400": 840425, + "cool-yak-231": 840427, + "wooden-opossum-31": 840428, + "effervescent-husky-364": 840438, + "acoustic-bloodhound-212": 840439, + "spotted-dog-616": 840440, + "glad-dotterel-15": 840441, + "good-toad-272": 840463, + "glad-dachshund-952": 840464, + "third-warbler-19": 840464, + "expert-fish-43": 840473, + "famous-seahorse-835": 840474, + "descriptive-chipmunk-102": 840481, + "perceptive-skunk-913": 840483, + "laudable-hare-71": 840483, + "compassionate-emu-221": 840485, + "useful-firefly-530": 840486, + "fast-panther-99": 840520, + "exciting-bullfrog-72": 840521, + "quaint-sockeye-3": 840521, + "dazzling-possum-576": 840529, + "jovial-crocodile-782": 840531, + "outgoing-bison-882": 840532, + "flippant-bird-477": 840533, + "insightful-mastiff-433": 840540, + "shocking-pika-629": 840541, + "dutiful-porpoise-656": 840541, + "aromatic-hamster-600": 840544, + "honorable-kingfisher-202": 840545, + "formal-caribou-530": 840554, + "outstanding-clownfish-416": 840557, + "posh-cobra-641": 840560, + "stoic-gerbil-163": 840561, + "quirky-ibex-589": 840565, + "youthful-dotterel-86": 840566, + "efficient-salmon-347": 840574, + "fast-viper-183": 840575, + "brazen-clam-239": 840578, + "perceptive-meadowlark-885": 840579, + "lovely-impala-321": 840584, + "acoustic-okapi-181": 840585, + "ceaseless-mammoth-887": 840585, + "dynamic-bass-883": 840589, + "uncommon-hare-102": 840591, + "successful-corgi-937": 840609, + "graceful-labrador-486": 840611, + "tough-stingray-554": 840631, + "kindred-dachshund-256": 840632, + "bright-civet-422": 840639, + "formal-salamander-422": 840641, + "confident-bobcat-812": 840685, + "intent-alligator-506": 840686, + "intent-sandpiper-387": 840688, + "famous-bulldog-838": 840689, + "perfect-kookabura-646": 840732, + "neat-mastiff-754": 840733, + "precious-lyrebird-608": 840741, + "grand-marten-620": 840744, + "shiny-labrador-442": 840748, + "agreeable-bobcat-369": 840749, + "kindhearted-finch-318": 840769, + "wonderful-manatee-868": 840770, + "blissful-kiwi-466": 840773, + "limitless-fish-103": 840774, + "kindhearted-lynx-872": 840781, + "dynamic-nightingale-417": 840782, + "fiery-hedgehog-17": 840797, + "cheery-puffin-775": 840798, + "striped-poodle-207": 840798, + "knowing-pheasant-821": 840839, + "energized-magpie-531": 840840, + "gregarious-cod-919": 840844, + "avid-pika-214": 840845, + "utmost-gazelle-421": 840852, + "little-condor-522": 840853, + "prestigious-shark-107": 840853, + "good-jay-530": 840869, + "dependable-stork-191": 840870, + "exuberant-mongoose-637": 840884, + "precious-hawk-557": 840885, + "exciting-guineapig-220": 840888, + "acrobatic-fish-970": 840889, + "astute-bandicoot-185": 840891, + "merry-warthog-610": 840892, + "fine-cow-242": 840893, + "exciting-ox-125": 840894, + "brave-eel-656": 840903, + "frugal-scorpion-601": 840905, + "tough-emu-243": 840905, + "wonderful-mastiff-544": 840913, + "lovely-spaniel-403": 840914, + "kindly-ram-891": 840915, + "adventurous-hornet-657": 840916, + "majestic-pelican-277": 840916, + "lovely-hare-521": 840931, + "impartial-marmot-992": 840932, + "original-jay-368": 840932, + "calm-heron-428": 840937, + "colorful-gopher-200": 840939, + "diligent-dinosaur-42": 840939, + "quaint-goldfinch-734": 840941, + "academic-zebra-296": 840942, + "famous-skunk-996": 840947, + "youthful-scorpion-950": 840948, + "proficient-capybara-929": 840952, + "decisive-pig-406": 840953, + "tangible-ox-371": 840961, + "veracious-oriole-507": 840962, + "bright-sardine-378": 840994, + "wary-spoonbill-939": 840995, + "valuable-hippopotamus-429": 840995, + "compassionate-jackal-772": 841019, + "neat-labrador-969": 841022, + "dapper-schnauzer-952": 841030, + "bright-emu-140": 841032, + "original-salamander-937": 841032, + "grandiose-marmot-401": 841037, + "courteous-viper-226": 841038, + "brainy-opossum-993": 841044, + "different-wombat-300": 841045, + "focused-chickadee-905": 841053, + "loyal-bobcat-377": 841055, + "accurate-ferret-571": 841063, + "mellow-alpaca-308": 841064, + "savory-anaconda-382": 841064, + "wooden-camel-135": 841108, + "expert-gopher-563": 841109, + "aromatic-chihuahua-399": 841112, + "energized-jaguar-862": 841113, + "giant-mongoose-327": 841113, + "fortunate-fennec-144": 841134, + "adventurous-vole-593": 841135, + "coordinated-poodle-351": 841149, + "courteous-chicken-809": 841150, + "admired-dachshund-860": 841153, + "whimsical-swan-517": 841154, + "flexible-wildebeest-75": 841154, + "cautious-crane-644": 841157, + "lovely-opossum-323": 841158, + "brazen-ibis-898": 841160, + "rightful-coyote-783": 841161, + "jovial-heron-889": 841172, + "moonlit-peccary-375": 841173, + "gregarious-shrimp-358": 841173, + "acoustic-marmot-771": 841179, + "patient-gull-296": 841180, + "insightful-mink-216": 841189, + "elated-gecko-923": 841190, + "upbeat-pigeon-250": 841236, + "successful-gopher-869": 841237, + "strong-fox-999": 841237, + "benevolent-llama-83": 841245, + "modest-nightingale-833": 841246, + "canny-mongoose-49": 841246, + "amicable-lemming-978": 841250, + "nautical-lobster-652": 841251, + "befitting-tortoise-690": 841251, + "clever-ostrich-690": 841254, + "lovely-lapwing-220": 841255, + "lovely-alpaca-511": 841256, + "scintillating-finch-716": 841257, + "silent-butterfly-901": 841258, + "rugged-pigeon-97": 841259, + "impressive-ostrich-664": 841265, + "curious-raccoon-761": 841267, + "colorful-kingfisher-495": 841283, + "wonderful-ferret-582": 841284, + "precise-falcon-176": 841290, + "quirky-chicken-705": 841292, + "veracious-mockingbird-519": 841295, + "glorious-lynx-638": 841318, + "confident-nightingale-447": 841319, + "decisive-flamingo-960": 841320, + "uncommon-hamster-569": 841321, + "zany-cobra-414": 841323, + "first-guanaco-298": 841324, + "posh-cuttlefish-136": 841344, + "admired-stoat-60": 841346, + "rosy-eel-808": 841346, + "vivid-deer-610": 841356, + "keen-dogfish-548": 841357, + "fortunate-mosquito-844": 841363, + "fantastic-opossum-506": 841364, + "dynamic-bird-600": 841374, + "fearless-ladybug-699": 841375, + "energized-wren-571": 841377, + "kindly-elk-330": 841378, + "impartial-cat-516": 841383, + "silent-rook-385": 841384, + "reliable-stingray-412": 841384, + "wry-starling-923": 841400, + "necessary-labrador-456": 841401, + "accomplished-stingray-724": 841404, + "glad-possum-555": 841405, + "handsome-reindeer-246": 841411, + "exuberant-dotterel-725": 841412, + "necessary-poodle-830": 841414, + "flippant-lemming-442": 841415, + "neat-crab-917": 841415, + "beaming-camel-148": 841419, + "amiable-seal-641": 841420, + "zany-rhinoceros-90": 841423, + "elegant-poodle-69": 841424, + "energized-porpoise-788": 841427, + "gregarious-bobcat-160": 841428, + "brilliant-tiger-334": 841430, + "steady-platypus-859": 841431, + "precise-hippopotamus-899": 841433, + "befitting-wombat-301": 841434, + "upbeat-kookabura-759": 841434, + "honorable-bulldog-143": 841439, + "joyous-pony-441": 841440, + "honorable-camel-518": 841448, + "groovy-pigeon-28": 841450, + "wonderful-marten-898": 841459, + "small-crow-52": 841460, + "unique-mole-941": 841473, + "quiet-cod-274": 841474, + "notable-reindeer-534": 841489, + "hallowed-antelope-771": 841490, + "upbeat-hyena-61": 841492, + "rapid-frog-963": 841493, + "zany-dotterel-55": 841529, + "oceanic-mongoose-139": 841530, + "hallowed-cormorant-982": 841549, + "exciting-ermine-102": 841551, + "cheery-oyster-697": 841569, + "uncommon-mule-414": 841570, + "wary-rhinoceros-16": 841571, + "lovable-hawk-790": 841572, + "proper-buzzard-366": 841572, + "beloved-hamster-469": 841599, + "amiable-rook-919": 841600, + "rapid-snake-493": 841601, + "reminiscent-crab-613": 841602, + "agreeable-kookabura-505": 841606, + "agile-hamster-383": 841607, + "sincere-goldfish-354": 841665, + "flexible-dogfish-551": 841667, + "industrious-spaniel-104": 841679, + "aware-kingfisher-112": 841681, + "acoustic-platypus-147": 841681, + "tremendous-chameleon-857": 841702, + "elegant-falcon-233": 841703, + "sincere-camel-384": 841720, + "frugal-porpoise-521": 841721, + "dusty-turtle-644": 841740, + "dependable-albatross-231": 841741, + "veracious-octopus-81": 841752, + "diligent-oyster-685": 841754, + "brilliant-toad-853": 841763, + "hardy-oyster-910": 841764, + "kindhearted-llama-831": 841780, + "elated-dove-311": 841781, + "diligent-dalmatian-669": 841792, + "acrobatic-frog-67": 841793, + "quiet-stork-542": 841793, + "fastidious-jaguar-844": 841810, + "posh-labrador-703": 841811, + "agile-tortoise-913": 841814, + "impressive-eel-767": 841815, + "perceptive-lion-304": 841818, + "grand-raven-870": 841820, + "sensible-parakeet-450": 841849, + "incredible-roadrunner-952": 841850, + "glorious-terrier-824": 841856, + "aware-lemur-979": 841857, + "ceaseless-fennec-995": 841870, + "giddy-poodle-309": 841871, + "bold-sparrow-524": 841915, + "quaint-okapi-759": 841916, + "tame-platypus-622": 841921, + "harmless-falcon-838": 841922, + "friendly-mastiff-17": 841922, + "beloved-chameleon-353": 841927, + "dependable-stingray-307": 841928, + "outstanding-sparrow-59": 841931, + "giant-opossum-967": 841932, + "fastidious-caiman-296": 841932, + "rightful-aardvark-839": 841948, + "hardy-frog-659": 841950, + "upbeat-llama-206": 842002, + "impartial-barracuda-814": 842003, + "avid-chameleon-818": 842003, + "acrobatic-ant-863": 842006, + "doting-mallard-324": 842007, + "affable-chickadee-556": 842017, + "quaint-schnauzer-441": 842018, + "graceful-squirrel-812": 842049, + "neighborly-woodpecker-558": 842050, + "third-pika-725": 842050, + "accomplished-hornet-134": 842055, + "frugal-bulldog-434": 842056, + "whimsical-pheasant-567": 842061, + "combative-sandpiper-190": 842062, + "fiery-poodle-607": 842071, + "neat-rook-689": 842072, + "third-seahorse-903": 842108, + "blessed-marlin-950": 842109, + "content-caribou-276": 842117, + "judicious-walrus-428": 842118, + "fantastic-chinchilla-931": 842132, + "fastidious-crocodile-426": 842133, + "tidy-turtle-683": 842142, + "superb-sardine-698": 842143, + "flippant-ladybug-44": 842152, + "gallant-sheep-823": 842153, + "friendly-eel-395": 842155, + "elated-starfish-237": 842156, + "bold-partridge-904": 842159, + "valuable-owl-9": 842160, + "usable-viper-530": 842160, + "hallowed-duck-129": 842166, + "fabulous-porcupine-258": 842167, + "patient-coyote-59": 842209, + "rightful-squid-124": 842210, + "joyous-kudu-652": 842210, + "usable-narwhal-183": 842215, + "whimsical-whale-638": 842216, + "rugged-vulture-312": 842216, + "good-nightingale-112": 842223, + "beloved-chickadee-488": 842224, + "enduring-mongoose-332": 842237, + "flippant-caterpillar-942": 842238, + "friendly-crab-850": 842242, + "neighborly-elephant-858": 842243, + "astute-scorpion-853": 842277, + "perceptive-pheasant-384": 842279, + "compassionate-hornet-539": 842322, + "aware-goldfinch-22": 842323, + "dapper-parakeet-975": 842342, + "standing-weasel-893": 842343, + "ardent-vulture-281": 842396, + "tidy-mule-781": 842397, + "grandiose-ferret-774": 842400, + "dynamic-chihuahua-798": 842401, + "adamant-firefly-628": 842401, + "secret-hedgehog-472": 842434, + "greedy-snake-21": 842435, + "accomplished-yak-130": 842435, + "limitless-vole-298": 842468, + "festive-falcon-434": 842469, + "courteous-mule-466": 842471, + "polite-seahorse-161": 842472, + "wry-cheetah-334": 842472, + "clever-jackal-695": 842496, + "amicable-gecko-219": 842497, + "quick-lobster-638": 842497, + "blissful-goose-866": 842512, + "quixotic-shark-759": 842513, + "original-mandrill-478": 842513, + "joyous-wildcat-547": 842528, + "sleek-yak-721": 842529, + "focused-llama-752": 842529, + "blissful-llama-730": 842563, + "kindred-spaniel-435": 842564, + "handsome-mule-491": 842589, + "rapid-flamingo-422": 842591, + "courteous-peccary-558": 842592, + "watchful-platypus-321": 842593, + "hallowed-mongoose-118": 842594, + "superb-octopus-108": 842595, + "graceful-camel-48": 842596, + "clean-chickadee-440": 842598, + "vivid-fly-425": 842600, + "befitting-pigeon-77": 842602, + "polite-jackal-711": 842603, + "beaming-kingfisher-324": 842613, + "admired-condor-63": 842614, + "cheery-dogfish-703": 842616, + "capable-wren-850": 842617, + "charming-oriole-560": 842618, + "clear-orca-723": 842619, + "flippant-mule-862": 842620, + "wandering-tiger-600": 842621, + "exciting-gecko-151": 842624, + "pastel-finch-783": 842625, + "sensible-civet-156": 842626, + "fantastic-skunk-882": 842627, + "lovely-pony-334": 842629, + "steady-antelope-773": 842630, + "animated-owl-1": 842636, + "striped-echidna-325": 842637, + "impressive-alligator-793": 842641, + "upbeat-mammoth-517": 842644, + "quick-guineapig-523": 842645, + "accurate-grouse-605": 842646, + "sincere-labrador-549": 842647, + "focused-wildebeest-711": 842647, + "dazzling-kiwi-946": 842648, + "oceanic-elephant-681": 842650, + "fastidious-marten-84": 842652, + "harmless-rooster-388": 842654, + "incredible-wolverine-56": 842665, + "whimsical-peacock-730": 842666, + "mild-gnat-278": 842666, + "brilliant-nightingale-490": 842688, + "dutiful-beagle-6": 842689, + "handsome-ferret-163": 842724, + "tidy-nightingale-437": 842725, + "utmost-mink-892": 842725, + "hip-mandrill-560": 842732, + "rosy-squid-135": 842733, + "honorable-ladybug-775": 842778, + "wandering-cricket-307": 842779, + "necessary-pelican-153": 842809, + "shocking-spider-207": 842810, + "ideal-raven-233": 842831, + "rapid-dodo-510": 842832, + "healthy-owl-724": 842841, + "watchful-jay-305": 842842, + "quixotic-reindeer-19": 842889, + "precise-falcon-850": 842890, + "adept-civet-850": 842949, + "outgoing-caiman-171": 842950, + "kindhearted-raven-845": 842951, + "energized-bass-332": 842952, + "incredible-kingfisher-935": 842953, + "adventurous-bloodhound-25": 842954, + "joyous-gnu-977": 842956, + "polite-penguin-671": 842976, + "focused-wildcat-471": 842978, + "flexible-giraffe-68": 842996, + "rightful-eel-988": 842997, + "hearty-shrimp-526": 843251, + "posh-porcupine-731": 843252, + "frugal-deer-642": 843254, + "hidden-hare-884": 843255, + "judicious-yak-496": 843255, + "keen-butterfly-544": 843256, + "hip-wren-810": 843257, + "capable-koala-478": 843258, + "abundant-rooster-40": 843259, + "tangible-hummingbird-456": 843261, + "kindred-leopard-906": 843262, + "limitless-axolotl-332": 843263, + "silent-albatross-637": 843264, + "proper-aardvark-618": 843268, + "pastel-guineapig-494": 843269, + "chatty-ox-987": 843274, + "adorable-stork-611": 843275, + "zany-mink-847": 843276, + "valiant-butterfly-164": 843277, + "notable-zebra-56": 843277, + "abundant-warthog-620": 843288, + "industrious-lobster-967": 843289, + "ceaseless-leopard-184": 843296, + "resilient-dove-383": 843297, + "different-gnu-757": 843299, + "quixotic-mandrill-387": 843300, + "first-pheasant-323": 843302, + "tidy-starfish-172": 843303, + "terrific-owl-158": 843331, + "amicable-shark-466": 843332, + "enduring-marten-982": 843341, + "insightful-flamingo-940": 843342, + "optimistic-snail-602": 843346, + "agile-rat-144": 843347, + "rugged-fennec-216": 843351, + "befitting-avocet-277": 843352, + "aware-cod-205": 843380, + "joyous-parakeet-978": 843382, + "dapper-stingray-209": 843383, + "amicable-dogfish-493": 843384, + "reminiscent-blackbird-45": 843394, + "precise-dalmatian-511": 843396, + "coordinated-reindeer-662": 843396, + "quiet-bandicoot-539": 843401, + "brave-ram-164": 843402, + "kindly-dinosaur-957": 843407, + "cool-stingray-195": 843408, + "benevolent-vulture-767": 843412, + "rapid-avocet-329": 843413, + "acoustic-hummingbird-866": 843413, + "famous-avocet-186": 843420, + "hushed-hornet-212": 843421, + "vibrant-peccary-983": 843421, + "modest-avocet-841": 843423, + "astute-jellyfish-209": 843424, + "coordinated-bullfrog-270": 843424, + "helpful-puma-710": 843471, + "grateful-guanaco-50": 843472, + "uncommon-lark-403": 843483, + "brazen-goose-931": 843485, + "rapid-snake-715": 843498, + "merry-pony-392": 843499, + "flexible-kudu-683": 843499, + "zany-woodpecker-355": 843500, + "shocking-mockingbird-17": 843501, + "perceptive-wildebeest-413": 843502, + "deafening-vulture-854": 843503, + "amiable-weasel-171": 843533, + "energized-pika-426": 843534, + "fantastic-partridge-833": 843551, + "shocking-lemur-559": 843552, + "beloved-raccoon-73": 843589, + "glad-buzzard-938": 843591, + "doting-manatee-74": 843599, + "courteous-whale-665": 843600, + "rosy-bobcat-728": 843614, + "colorful-ferret-164": 843615, + "sincere-albatross-574": 843615, + "rosy-labrador-572": 843666, + "limitless-octopus-570": 843667, + "amiable-jay-965": 843676, + "academic-jay-434": 843677, + "groovy-hamster-818": 843684, + "canny-sturgeon-220": 843685, + "elated-gopher-338": 843698, + "graceful-buffalo-737": 843699, + "charming-ant-235": 843701, + "festive-raven-499": 843702, + "standing-mouse-372": 843703, + "useful-marlin-197": 843703, + "impressive-avocet-556": 843705, + "qualified-squirrel-166": 843712, + "grandiose-bison-562": 843713, + "marvelous-duck-648": 843717, + "basic-mandrill-127": 843718, + "beloved-bee-734": 843723, + "confident-dove-237": 843724, + "silent-bat-762": 843724, + "merry-cow-636": 843730, + "dapper-salamander-505": 843731, + "vivid-gnu-412": 843765, + "posh-alligator-894": 843766, + "hidden-swan-529": 843786, + "superb-chinchilla-306": 843787, + "impartial-lynx-286": 843790, + "colorful-raven-930": 843791, + "spotted-platypus-543": 843791, + "honorable-panda-863": 843799, + "perfect-kangaroo-214": 843800, + "benevolent-hippopotamus-628": 843809, + "unique-shrimp-817": 843810, + "wonderful-chickadee-472": 843810, + "formal-puffin-427": 843833, + "basic-bass-475": 843834, + "trustworthy-raccoon-328": 843840, + "quirky-wolf-183": 843841, + "exciting-ibis-292": 843841, + "precious-elk-501": 843846, + "tacit-porcupine-748": 843847, + "uncommon-opossum-741": 843847, + "glad-salamander-637": 843850, + "animated-fish-485": 843851, + "admired-scorpion-238": 843856, + "calm-emu-469": 843857, + "upbeat-seahorse-423": 843857, + "mild-hamster-384": 843863, + "helpful-gopher-338": 843865, + "dutiful-raccoon-117": 843870, + "robust-swan-2": 843871, + "festive-hawk-265": 843893, + "handsome-antelope-244": 843894, + "hallowed-opossum-78": 843894, + "clear-lyrebird-314": 843901, + "fantastic-jackal-959": 843902, + "incredible-meerkat-53": 843918, + "incredible-trout-120": 843919, + "notable-mastiff-141": 843919, + "oceanic-dog-589": 843924, + "lovely-woodpecker-483": 843925, + "uncommon-spoonbill-772": 843934, + "lovely-dachshund-614": 843935, + "watchful-starfish-660": 843951, + "rapid-eagle-41": 843952, + "lovely-marten-73": 843952, + "aware-ermine-403": 843964, + "limitless-mandrill-343": 843965, + "dusty-oyster-270": 843965, + "mild-coyote-476": 843968, + "elated-ibex-934": 843969, + "zealous-finch-709": 843980, + "tame-sockeye-50": 843981, + "agile-whale-199": 843983, + "striped-dragon-704": 843985, + "agile-avocet-340": 843985, + "courteous-reindeer-450": 843998, + "rugged-hound-161": 843999, + "bright-lapwing-390": 844044, + "elegant-kingfisher-856": 844045, + "useful-firefly-995": 844052, + "dynamic-orca-797": 844054, + "rare-jay-994": 844058, + "resilient-mosquito-638": 844060, + "keen-ostrich-51": 844071, + "reminiscent-guanaco-397": 844072, + "dapper-lyrebird-331": 844075, + "descriptive-manatee-237": 844076, + "outgoing-giraffe-762": 844096, + "calm-kiwi-889": 844097, + "posh-labrador-95": 844097, + "ardent-loris-617": 844101, + "standing-wildcat-720": 844102, + "amiable-caterpillar-145": 844116, + "first-blackbird-686": 844118, + "colorful-kingfisher-110": 844131, + "stoic-armadillo-78": 844132, + "elegant-orca-627": 844132, + "astute-vulture-180": 844166, + "grateful-dove-247": 844167, + "ardent-tern-959": 844171, + "sleek-basilisk-591": 844172, + "wonderful-jaguar-704": 844181, + "effervescent-dotterel-511": 844182, + "knowing-swan-197": 844182, + "benevolent-swan-345": 844185, + "determined-avocet-631": 844186, + "mellow-finch-449": 844194, + "incredible-perch-425": 844195, + "artful-ladybug-595": 844196, + "proficient-bat-217": 844197, + "moonlit-gnat-448": 844197, + "quiet-bobcat-816": 844204, + "accomplished-lyrebird-45": 844205, + "fleet-manatee-842": 844205, + "adept-panther-667": 844245, + "canny-bat-475": 844246, + "scrupulous-goose-967": 844260, + "agile-deer-301": 844261, + "loyal-wolf-507": 844261, + "beaming-ocelot-723": 844294, + "accurate-leopard-92": 844295, + "kindly-rooster-785": 844314, + "kindly-pig-802": 844317, + "youthful-cormorant-720": 844328, + "valuable-mongoose-366": 844329, + "wary-ostrich-99": 844341, + "effervescent-skunk-483": 844342, + "acrobatic-dolphin-999": 844343, + "fortunate-gerbil-609": 844343, + "deafening-possum-206": 844344, + "ardent-pika-197": 844350, + "festive-peccary-159": 844351, + "proper-emu-525": 844351, + "tangible-robin-641": 844385, + "keen-shrimp-703": 844387, + "cool-boar-531": 844399, + "usable-opossum-560": 844400, + "adventurous-kangaroo-252": 844409, + "graceful-oriole-591": 844410, + "proper-trout-965": 844444, + "admired-wildebeest-838": 844445, + "famous-boar-233": 844456, + "dapper-sheep-981": 844457, + "adorable-toucan-903": 844457, + "accurate-toucan-716": 844466, + "loyal-basilisk-584": 844467, + "polite-donkey-427": 844467, + "mellow-squid-146": 844484, + "lovely-perch-464": 844485, + "amicable-curlew-994": 844490, + "optimistic-raven-966": 844491, + "tame-moose-454": 844508, + "gallant-goldfish-149": 844509, + "flippant-caiman-537": 844517, + "acoustic-barracuda-913": 844518, + "next-goldfinch-956": 844521, + "focused-jellyfish-478": 844522, + "adamant-shrimp-428": 844522, + "decisive-stork-493": 844525, + "resolute-bat-247": 844526, + "doting-dachshund-603": 844531, + "robust-cow-809": 844532, + "mild-jay-71": 844533, + "efficient-goshawk-798": 844534, + "rare-seahorse-890": 844538, + "greedy-dodo-290": 844539, + "honorable-ibis-726": 844550, + "unique-cormorant-878": 844551, + "colorful-lemur-312": 844551, + "benevolent-egret-816": 844555, + "befitting-goose-183": 844556, + "perceptive-hyena-44": 844556, + "first-clownfish-612": 844568, + "grandiose-ibis-671": 844570, + "woozy-sheep-671": 844577, + "giddy-bee-972": 844578, + "vivid-civet-145": 844591, + "necessary-caterpillar-701": 844592, + "focused-crane-253": 844621, + "chatty-yak-561": 844622, + "energetic-albatross-29": 844630, + "greedy-peccary-579": 844631, + "loyal-cobra-236": 844656, + "brainy-llama-84": 844657, + "compassionate-gnat-563": 844658, + "ideal-akita-742": 844659, + "perceptive-impala-832": 844666, + "hallowed-platypus-146": 844667, + "clean-spaniel-218": 844667, + "different-goldfish-565": 844680, + "pleasant-mink-33": 844681, + "patient-starfish-329": 844686, + "famous-canary-475": 844687, + "dusty-parakeet-964": 844688, + "lovely-clam-214": 844689, + "proper-bird-391": 844716, + "dapper-clownfish-670": 844717, + "zany-boar-454": 844717, + "wonderful-rabbit-784": 844724, + "chatty-iguana-763": 844725, + "vibrant-dogfish-223": 844740, + "acrobatic-gull-129": 844741, + "hallowed-squirrel-51": 844762, + "hip-warbler-313": 844763, + "insightful-coyote-798": 844769, + "intent-elephant-827": 844770, + "confident-ladybug-908": 844779, + "diligent-penguin-808": 844780, + "opulent-lapwing-945": 844780, + "youthful-kangaroo-580": 844782, + "fiery-crab-757": 844784, + "rugged-mammoth-728": 844794, + "clear-quail-736": 844795, + "fortunate-ladybug-779": 844805, + "graceful-squirrel-341": 844807, + "zealous-gopher-655": 844827, + "acoustic-tern-957": 844828, + "frugal-gopher-138": 844844, + "accomplished-snail-390": 844845, + "basic-pheasant-722": 844845, + "resolute-quail-33": 844846, + "youthful-yak-418": 844847, + "academic-lion-933": 844866, + "mellow-sardine-604": 844867, + "artful-quail-341": 844867, + "fiery-grouse-475": 844880, + "cautious-swan-859": 844881, + "good-magpie-772": 844882, + "pastel-bloodhound-697": 844883, + "perfect-kiwi-400": 844883, + "clean-kookabura-646": 844884, + "energetic-penguin-544": 844885, + "exuberant-lobster-341": 844895, + "hallowed-lynx-133": 844896, + "elegant-ladybug-188": 844907, + "animated-axolotl-682": 844908, + "secret-cardinal-437": 844915, + "dusty-coyote-150": 844916, + "patient-lobster-696": 844916, + "giant-roadrunner-856": 844933, + "wooden-caiman-411": 844934, + "mellow-tern-730": 844962, + "glorious-porcupine-676": 844963, + "small-jay-562": 845000, + "honorable-gull-553": 845001, + "amiable-lapwing-711": 845001, + "kindred-parrot-749": 845008, + "artful-gecko-987": 845010, + "grand-weasel-7": 845053, + "dusty-robin-29": 845054, + "blessed-albatross-370": 845054, + "mild-shark-642": 845063, + "healthy-flamingo-148": 845064, + "knowing-ram-34": 845076, + "proper-warthog-803": 845077, + "decisive-vulture-92": 845099, + "superb-elk-504": 845100, + "clever-herring-182": 845100, + "dutiful-kookabura-130": 845104, + "impressive-sheep-309": 845105, + "clean-dogfish-419": 845124, + "silent-heron-229": 845125, + "fiery-gazelle-298": 845125, + "merry-viper-816": 845141, + "valuable-woodpecker-335": 845143, + "accurate-stork-381": 845144, + "insightful-fennec-748": 845145, + "bold-wren-372": 845146, + "canny-crab-300": 845148, + "whimsical-marten-34": 845151, + "cheery-toucan-782": 845152, + "clear-tapir-312": 845174, + "modest-goldfish-593": 845175, + "befitting-bullfrog-590": 845179, + "intent-goose-628": 845180, + "nautical-ocelot-187": 845180, + "notable-mastiff-913": 845199, + "bright-pelican-656": 845200, + "fiery-boar-456": 845200, + "greedy-walrus-709": 845242, + "scintillating-ptarmigan-263": 845243, + "bold-hamster-510": 845247, + "ideal-cod-798": 845248, + "dutiful-cow-167": 845251, + "careful-marmot-668": 845252, + "formal-woodpecker-622": 845261, + "frugal-woodpecker-182": 845262, + "wooden-husky-609": 845262, + "wonderful-stingray-925": 845283, + "outstanding-anaconda-18": 845284, + "whimsical-oyster-848": 845295, + "brave-flamingo-793": 845297, + "pastel-manatee-958": 845298, + "superb-swan-479": 845299, + "glorious-labrador-837": 845346, + "superb-dotterel-564": 845347, + "mellow-chicken-834": 845366, + "nautical-tapir-695": 845367, + "kindred-snake-585": 845375, + "perceptive-canary-959": 845376, + "uncommon-loris-633": 845388, + "sensible-mule-940": 845389, + "judicious-boar-285": 845408, + "charming-buffalo-149": 845410, + "agreeable-retriever-529": 845410, + "abundant-tiger-864": 845455, + "fine-mammoth-346": 845456, + "spotted-yak-103": 845477, + "befitting-loris-238": 845478, + "ardent-mandrill-915": 845478, + "nautical-whale-75": 845483, + "lovable-sockeye-299": 845484, + "acrobatic-cheetah-656": 845505, + "colorless-marmot-179": 845506, + "lovable-eagle-536": 845525, + "dutiful-egret-226": 845526, + "animated-mosquito-516": 845565, + "flippant-skunk-140": 845566, + "spotted-mosquito-12": 845566, + "next-anaconda-496": 845579, + "sincere-ocelot-165": 845580, + "kindred-seahorse-327": 845613, + "patient-boar-301": 845614, + "adamant-ptarmigan-575": 845616, + "youthful-dotterel-650": 845617, + "fortunate-giraffe-944": 845617, + "neighborly-emu-360": 845670, + "greedy-opossum-521": 845671, + "deafening-marlin-919": 845671, + "colorful-mouse-109": 845712, + "dashing-cat-844": 845713, + "fast-porcupine-308": 845715, + "focused-bat-120": 845716, + "little-panther-938": 845743, + "aware-tiger-426": 845744, + "original-chinchilla-561": 845785, + "confident-condor-994": 845786, + "sleek-raccoon-984": 845810, + "disciplined-panda-328": 845811, + "majestic-oyster-255": 845829, + "optimistic-hedgehog-346": 845830, + "enchanted-emu-897": 845830, + "youthful-bobcat-329": 845842, + "adjoining-chickadee-553": 845844, + "rare-crane-681": 845844, + "quixotic-alligator-268": 845850, + "youthful-raccoon-122": 845851, + "tame-greyhound-908": 845917, + "grand-kiwi-783": 845918, + "gregarious-porcupine-649": 845936, + "wry-bulldog-435": 845937, + "reliable-gnat-666": 845937, + "grateful-ibis-981": 845950, + "modest-axolotl-563": 845951, + "capable-axolotl-53": 845971, + "artful-akita-688": 845972, + "watchful-vulture-285": 845998, + "rosy-shepherd-630": 845999, + "canny-possum-315": 846020, + "successful-dotterel-562": 846021, + "academic-cormorant-187": 846037, + "fleet-chameleon-24": 846038, + "clear-starfish-668": 846038, + "dusty-cassowary-84": 846050, + "deafening-hummingbird-148": 846051, + "beaming-dinosaur-65": 846054, + "wooden-vole-239": 846055, + "original-akita-641": 846080, + "cheerful-dove-848": 846081, + "good-mammoth-466": 846092, + "posh-hyena-756": 846093, + "savory-ox-751": 846098, + "academic-ladybug-720": 846099, + "aware-fly-828": 846101, + "compassionate-badger-411": 846102, + "quixotic-giraffe-62": 846102, + "calm-giraffe-117": 846137, + "flippant-spider-315": 846138, + "knowing-coyote-721": 846182, + "oceanic-impala-329": 846184, + "vibrant-lynx-497": 846187, + "unique-akita-487": 846188, + "flexible-iguana-20": 846225, + "veracious-ibex-124": 846226, + "watchful-sandpiper-946": 846253, + "determined-cassowary-626": 846254, + "shocking-jay-896": 846282, + "industrious-clownfish-4": 846283, + "standing-swordfish-344": 846293, + "elegant-warthog-196": 846294, + "necessary-snake-297": 846303, + "kindhearted-barracuda-962": 846304, + "dependable-kangaroo-564": 846310, + "cool-chickadee-131": 846311, + "dutiful-viper-280": 846330, + "superb-llama-787": 846331, + "usable-ox-833": 846349, + "intent-shark-952": 846350, + "healthy-deer-214": 846384, + "fiery-toad-270": 846385, + "prestigious-bird-845": 846397, + "jovial-panda-887": 846398, + "marvelous-sturgeon-275": 846412, + "clear-caribou-683": 846413, + "reminiscent-civet-755": 846415, + "acrobatic-perch-522": 846416, + "oceanic-frog-643": 846416, + "dynamic-mammoth-373": 846431, + "valiant-ant-335": 846433, + "loyal-sardine-524": 846433, + "cheerful-sardine-423": 846443, + "perceptive-spoonbill-483": 846444, + "kindred-basilisk-170": 846452, + "patient-boar-153": 846453, + "festive-salamander-519": 846453, + "hearty-sheep-193": 846457, + "cheery-chipmunk-814": 846458, + "deafening-kangaroo-452": 846496, + "charming-seal-187": 846498, + "pleasant-civet-132": 846498, + "neat-hyena-299": 846544, + "majestic-fish-572": 846545, + "content-cow-629": 846545, + "strong-nightingale-466": 846552, + "energetic-dalmatian-552": 846553, + "harmless-dove-480": 846589, + "valuable-alpaca-711": 846590, + "colorless-parrot-68": 846596, + "tame-sheep-587": 846597, + "clever-husky-152": 846598, + "steady-ocelot-108": 846599, + "tidy-hippopotamus-369": 846606, + "kindred-ant-905": 846607, + "compassionate-gopher-101": 846615, + "kindly-camel-166": 846616, + "original-badger-933": 846637, + "efficient-mastiff-793": 846638, + "handsome-fox-417": 846642, + "greedy-pheasant-989": 846643, + "tough-tapir-437": 846689, + "rapid-monitor-953": 846690, + "clean-alpaca-879": 846690, + "resilient-gnat-692": 846714, + "careful-dragon-974": 846715, + "kindred-beagle-90": 846715, + "fastidious-pony-388": 846738, + "adjoining-whale-202": 846739, + "ideal-spaniel-958": 846748, + "groovy-tiger-225": 846749, + "rapid-opossum-736": 846769, + "modest-meadowlark-731": 846770, + "first-cormorant-659": 846771, + "industrious-peacock-849": 846772, + "rare-tern-236": 846791, + "decisive-seal-768": 846792, + "bold-warbler-752": 846792, + "terrific-greyhound-282": 846809, + "accomplished-fox-782": 846810, + "fastidious-swordfish-12": 846810, + "adept-clownfish-103": 846816, + "valiant-jay-814": 846817, + "acrobatic-hare-86": 846817, + "colorful-lark-393": 846829, + "neat-goose-461": 846830, + "dashing-boar-391": 846830, + "fortunate-crocodile-218": 846845, + "tidy-mosquito-92": 846847, + "giddy-sardine-190": 846849, + "compassionate-raccoon-526": 846850, + "upbeat-panther-162": 846855, + "enduring-pelican-140": 846856, + "patient-bullfrog-307": 846865, + "clean-bandicoot-268": 846866, + "wooden-panther-80": 846900, + "exuberant-skunk-508": 846901, + "elegant-toucan-511": 846907, + "savory-axolotl-189": 846908, + "silent-stoat-44": 846913, + "proficient-crow-228": 846914, + "utmost-ram-239": 846922, + "watchful-jellyfish-130": 846923, + "optimistic-basilisk-985": 846960, + "zealous-shrimp-104": 846961, + "befitting-kudu-405": 846977, + "admired-roadrunner-823": 846978, + "dusty-setter-702": 846980, + "outstanding-coyote-875": 846981, + "original-turtle-507": 846993, + "beaming-albatross-776": 846994, + "basic-kudu-764": 846994, + "astute-marlin-378": 846997, + "woozy-cat-120": 846998, + "lovely-narwhal-271": 847000, + "decisive-mosquito-289": 847001, + "impartial-octopus-153": 847003, + "peaceful-meerkat-12": 847004, + "shocking-giraffe-888": 847011, + "honorable-anteater-153": 847012, + "strong-deer-534": 847027, + "pleasant-ermine-741": 847027, + "uncommon-alpaca-146": 847028, + "oceanic-butterfly-778": 847037, + "animated-guineapig-298": 847038, + "agile-ostrich-317": 847041, + "basic-buzzard-640": 847042, + "kindred-sparrow-208": 847044, + "quirky-tern-527": 847045, + "disciplined-orca-810": 847045, + "fearless-stork-305": 847048, + "agile-firefly-974": 847049, + "ideal-elephant-386": 847066, + "aromatic-owl-903": 847068, + "stoic-mandrill-238": 847068, + "secret-grasshopper-758": 847069, + "effervescent-hamster-805": 847070, + "precious-spoonbill-431": 847074, + "robust-shrimp-952": 847075, + "standing-pelican-95": 847087, + "acrobatic-jaguar-89": 847088, + "hushed-nightingale-793": 847092, + "precise-mammoth-693": 847093, + "majestic-leopard-750": 847098, + "wonderful-toad-14": 847099, + "warmhearted-gnat-670": 847103, + "little-raven-338": 847104, + "blissful-fennec-823": 847108, + "tacit-cat-935": 847109, + "brainy-gnat-201": 847110, + "proper-puma-45": 847111, + "giant-puma-69": 847118, + "third-shrimp-969": 847119, + "joyous-otter-382": 847119, + "rapid-rhinoceros-190": 847127, + "valuable-snake-306": 847129, + "brave-bass-917": 847139, + "shocking-fox-298": 847140, + "cheery-orca-478": 847140, + "useful-ox-764": 847142, + "laudable-cow-46": 847143, + "cheery-opossum-317": 847153, + "usable-pika-871": 847154, + "third-civet-712": 847155, + "jovial-parrot-212": 847156, + "aromatic-jackal-275": 847166, + "calm-manatee-141": 847167, + "peaceful-narwhal-462": 847174, + "brilliant-spoonbill-897": 847175, + "good-warthog-722": 847184, + "jovial-goshawk-951": 847185, + "affable-flamingo-545": 847202, + "loyal-goldfinch-866": 847203, + "agile-bass-474": 847203, + "beloved-caiman-143": 847218, + "agreeable-monitor-956": 847219, + "kindly-hippopotamus-37": 847226, + "formal-bandicoot-417": 847229, + "combative-firefly-564": 847229, + "glad-dolphin-159": 847247, + "hidden-poodle-143": 847248, + "adamant-ant-859": 847260, + "merry-grouse-739": 847261, + "fortunate-sturgeon-340": 847272, + "intent-falcon-896": 847273, + "shiny-kookabura-265": 847283, + "hardy-okapi-494": 847284, + "earnest-guineapig-143": 847285, + "veracious-lemur-974": 847285, + "friendly-shepherd-156": 847286, + "rugged-whale-395": 847301, + "necessary-tern-402": 847302, + "coordinated-gopher-987": 847302, + "shiny-cuttlefish-184": 847318, + "oceanic-mouse-469": 847319, + "insightful-moose-992": 847319, + "efficient-otter-567": 847321, + "glorious-caterpillar-512": 847322, + "efficient-cuttlefish-606": 847327, + "capable-jellyfish-387": 847328, + "efficient-dotterel-256": 847333, + "colorful-stingray-112": 847334, + "uncommon-ladybug-560": 847351, + "wonderful-narwhal-62": 847352, + "zealous-egret-986": 847369, + "famous-setter-127": 847370, + "mellow-starfish-29": 847370, + "abundant-setter-870": 847371, + "dusty-bat-877": 847372, + "lovable-bear-303": 847372, + "superb-rat-70": 847397, + "descriptive-mammoth-484": 847398, + "quaint-scorpion-558": 847400, + "reliable-starling-557": 847401, + "scintillating-rhinoceros-985": 847406, + "patient-chipmunk-208": 847407, + "blessed-hippopotamus-448": 847407, + "colorful-gerbil-883": 847411, + "polished-badger-969": 847412, + "terrific-hare-344": 847437, + "adventurous-gecko-866": 847439, + "focused-finch-774": 847454, + "prestigious-shrimp-403": 847455, + "calculating-condor-435": 847455, + "greedy-ermine-795": 847520, + "fast-okapi-607": 847521, + "grandiose-kudu-826": 847548, + "calm-setter-746": 847549, + "polished-snail-830": 847549, + "affable-rooster-531": 847600, + "precise-scorpion-573": 847601, + "agile-dolphin-579": 847603, + "adamant-gecko-876": 847605, + "striped-kangaroo-609": 847607, + "earnest-kiwi-318": 847608, + "prestigious-bass-490": 847627, + "wooden-cormorant-440": 847628, + "wary-lemming-121": 847642, + "quick-corgi-787": 847643, + "amiable-poodle-555": 847684, + "mellow-scorpion-420": 847685, + "vivid-sheep-997": 847691, + "reminiscent-meadowlark-198": 847693, + "superb-warbler-879": 847695, + "prestigious-cobra-950": 847696, + "notable-gerbil-344": 847696, + "animated-seahorse-963": 847697, + "striped-retriever-286": 847698, + "harmless-pelican-554": 847701, + "limitless-malamute-72": 847702, + "honorable-dog-715": 847719, + "wooden-bloodhound-43": 847721, + "loyal-rabbit-594": 847721, + "cheerful-cuttlefish-46": 847737, + "tidy-lemur-632": 847738, + "dusty-mockingbird-125": 847739, + "dusty-finch-163": 847740, + "reliable-goldfish-757": 847740, + "rapid-buffalo-546": 847744, + "useful-salamander-541": 847745, + "qualified-crane-463": 847771, + "utmost-duck-889": 847773, + "tame-mosquito-428": 847779, + "fast-frog-905": 847780, + "earnest-dotterel-167": 847780, + "dapper-mink-28": 847782, + "efficient-rhinoceros-478": 847784, + "determined-stingray-870": 847813, + "canny-pelican-460": 847814, + "grand-zebra-833": 847822, + "mellow-sheep-7": 847823, + "efficient-rabbit-527": 847823, + "descriptive-warthog-478": 847835, + "resilient-bloodhound-706": 847836, + "rightful-cricket-376": 847836, + "careful-dinosaur-672": 847838, + "neat-spaniel-768": 847840, + "cool-rat-183": 847840, + "basic-lion-443": 847862, + "enchanted-lapwing-878": 847863, + "agreeable-dotterel-627": 847863, + "quixotic-capybara-965": 847868, + "agreeable-llama-741": 847871, + "keen-mule-79": 847873, + "affable-hummingbird-989": 847874, + "enduring-blackbird-461": 847874, + "friendly-cod-482": 847878, + "terrific-coyote-464": 847879, + "fiery-mule-729": 847892, + "formal-jackal-803": 847893, + "lovely-dragon-153": 847894, + "bold-mule-196": 847895, + "peaceful-minnow-288": 847900, + "adorable-impala-592": 847901, + "fiery-dinosaur-687": 847920, + "rightful-ermine-492": 847921, + "tame-dachshund-420": 847924, + "neighborly-chihuahua-293": 847925, + "academic-crab-861": 847926, + "canny-rhinoceros-446": 847927, + "accurate-shepherd-525": 847932, + "harmless-clownfish-74": 847933, + "spotted-canary-762": 847933, + "rare-jaguar-866": 847944, + "quaint-tiger-882": 847945, + "flexible-ram-752": 847965, + "dynamic-civet-420": 847966, + "doting-skunk-747": 847995, + "whimsical-rooster-342": 847996, + "clear-gerbil-552": 847996, + "befitting-cassowary-96": 848023, + "quiet-raven-256": 848024, + "curious-coyote-545": 848055, + "chatty-stoat-789": 848056, + "trustworthy-stingray-395": 848056, + "canny-lyrebird-75": 848073, + "efficient-kiwi-801": 848074, + "uncommon-seal-859": 848078, + "graceful-starling-997": 848079, + "giant-hawk-969": 848083, + "calculating-octopus-497": 848084, + "energetic-ox-257": 848123, + "steady-weasel-842": 848124, + "compassionate-moose-430": 848147, + "polished-shark-614": 848148, + "astute-caterpillar-13": 848199, + "aware-koala-960": 848200, + "tough-crane-828": 848210, + "wandering-civet-824": 848211, + "dynamic-antelope-267": 848222, + "veracious-oyster-392": 848223, + "uncommon-oriole-222": 848223, + "clever-mink-134": 848247, + "clean-anteater-137": 848248, + "strong-lyrebird-315": 848250, + "resolute-bass-929": 848252, + "rare-lemming-611": 848257, + "abundant-ibex-212": 848258, + "efficient-fennec-404": 848266, + "compassionate-bloodhound-418": 848267, + "glorious-partridge-786": 848283, + "wary-tortoise-83": 848284, + "artful-woodpecker-874": 848295, + "fine-shrimp-870": 848297, + "brilliant-dragon-378": 848302, + "blissful-oyster-756": 848303, + "aware-spider-103": 848308, + "dapper-goose-909": 848309, + "youthful-leopard-881": 848318, + "determined-weasel-501": 848320, + "steady-alligator-405": 848321, + "dusty-shrimp-433": 848322, + "quaint-gerbil-883": 848323, + "patient-gopher-545": 848324, + "honorable-fly-649": 848329, + "outgoing-grasshopper-906": 848330, + "friendly-lynx-969": 848331, + "accomplished-bear-390": 848333, + "hearty-chickadee-279": 848362, + "precious-sparrow-625": 848363, + "formal-starling-901": 848363, + "jovial-cow-944": 848393, + "hardy-viper-426": 848394, + "festive-meadowlark-477": 848460, + "bold-peacock-289": 848461, + "mild-fox-748": 848486, + "wonderful-panda-805": 848487, + "judicious-ermine-962": 848504, + "brainy-corgi-1": 848505, + "pleasant-gnu-987": 848507, + "terrific-spider-276": 848510, + "hardy-goat-150": 848511, + "disciplined-sparrow-770": 848512, + "effervescent-camel-356": 848518, + "opulent-bison-161": 848520, + "reliable-tiger-31": 848522, + "courteous-dolphin-459": 848523, + "moonlit-camel-107": 848526, + "giant-stingray-518": 848527, + "small-bandicoot-34": 848528, + "fantastic-hornet-597": 848529, + "kindhearted-rooster-727": 848529, + "pastel-labrador-131": 848549, + "intent-chihuahua-286": 848551, + "animated-shepherd-99": 848554, + "strong-warthog-672": 848555, + "gregarious-cassowary-890": 848563, + "perfect-seal-74": 848564, + "focused-caiman-118": 848617, + "amicable-bulldog-889": 848619, + "pleasant-pheasant-779": 848620, + "insightful-deer-309": 848621, + "useful-tern-285": 848623, + "affable-blackbird-458": 848624, + "shiny-greyhound-346": 848626, + "elegant-jellyfish-4": 848627, + "adjoining-octopus-794": 848632, + "shiny-ermine-491": 848635, + "adorable-cricket-544": 848636, + "perceptive-llama-624": 848637, + "giddy-rabbit-525": 848637, + "hip-heron-49": 848646, + "moonlit-wildcat-104": 848647, + "artful-condor-685": 848653, + "tremendous-pelican-102": 848654, + "dynamic-ostrich-35": 848666, + "proper-marmot-659": 848667, + "brave-gopher-220": 848679, + "acrobatic-panther-720": 848680, + "intent-salmon-503": 848688, + "deafening-gnat-892": 848689, + "quaint-echidna-159": 848697, + "healthy-duck-348": 848698, + "shocking-rook-505": 848706, + "exciting-whale-354": 848708, + "posh-antelope-457": 848711, + "content-warthog-689": 848712, + "diligent-seal-466": 848720, + "useful-anaconda-653": 848721, + "proper-goat-80": 848722, + "famous-labrador-873": 848723, + "combative-guanaco-824": 848724, + "small-scorpion-579": 848725, + "outstanding-mastiff-597": 848743, + "adamant-labrador-593": 848744, + "first-leopard-788": 848745, + "intent-bear-617": 848746, + "impartial-toucan-352": 848746, + "proper-swan-400": 848766, + "warmhearted-bird-859": 848767, + "affable-deer-663": 848773, + "curious-bloodhound-444": 848775, + "confident-bat-646": 848776, + "groovy-dog-527": 848777, + "decisive-scorpion-86": 848779, + "effervescent-lemur-476": 848780, + "bright-panda-641": 848811, + "sincere-woodpecker-478": 848812, + "greedy-crocodile-348": 848850, + "outstanding-fish-870": 848851, + "careful-goldfinch-478": 848859, + "energetic-caterpillar-119": 848860, + "festive-bloodhound-480": 848882, + "upbeat-bison-518": 848883, + "graceful-eagle-912": 848897, + "veracious-egret-447": 848898, + "small-roadrunner-674": 848900, + "aromatic-echidna-840": 848901, + "honorable-hamster-92": 848918, + "calm-stoat-284": 848920, + "tidy-peacock-757": 848958, + "bold-frog-13": 848959, + "successful-trout-270": 848971, + "hidden-dolphin-750": 848972, + "vibrant-tapir-81": 848984, + "glorious-hummingbird-936": 848985, + "zealous-poodle-768": 849003, + "deafening-mockingbird-195": 849006, + "abundant-dogfish-380": 849010, + "combative-lion-240": 849011, + "rare-penguin-895": 849019, + "dashing-cheetah-903": 849020, + "marvelous-grasshopper-459": 849037, + "ardent-marmot-284": 849038, + "hallowed-ram-123": 849052, + "quixotic-stingray-389": 849053, + "optimistic-lark-862": 849056, + "polished-jay-597": 849057, + "cheery-toad-854": 849057, + "vivid-rat-681": 849063, + "elated-oyster-148": 849064, + "tangible-emu-821": 849071, + "hushed-cassowary-177": 849072, + "stoic-kookabura-957": 849077, + "kindly-basilisk-228": 849078, + "tremendous-ibis-249": 849093, + "intent-bass-685": 849094, + "elated-kangaroo-394": 849125, + "secret-heron-980": 849126, + "festive-stoat-101": 849134, + "earnest-impala-348": 849135, + "grandiose-rabbit-986": 849151, + "colorless-meadowlark-7": 849152, + "fantastic-pony-869": 849152, + "resilient-shark-738": 849154, + "hushed-hippopotamus-496": 849155, + "grandiose-bison-815": 849164, + "grandiose-setter-145": 849166, + "expert-ibis-353": 849181, + "affable-roadrunner-547": 849182, + "handsome-marmot-533": 849187, + "combative-sardine-223": 849188, + "rapid-giraffe-675": 849206, + "merry-lyrebird-408": 849207, + "hallowed-zebra-940": 849207, + "elated-cobra-223": 849228, + "exciting-firefly-155": 849229, + "neat-badger-821": 849283, + "quick-sparrow-742": 849284, + "agile-woodpecker-218": 849305, + "doting-fly-918": 849307, + "different-iguana-455": 849350, + "resilient-hummingbird-661": 849351, + "expert-dinosaur-409": 849364, + "energized-gecko-81": 849365, + "energized-elk-128": 849391, + "next-hummingbird-611": 849392, + "hardy-shepherd-427": 849403, + "fast-peacock-754": 849404, + "agile-bird-314": 849409, + "pastel-cormorant-343": 849410, + "limitless-viper-262": 849410, + "tremendous-bandicoot-622": 849416, + "superb-lemur-227": 849417, + "agile-fish-309": 849417, + "polished-guanaco-705": 849440, + "festive-pig-98": 849441, + "courteous-pika-971": 849467, + "capable-caiman-755": 849468, + "wry-setter-520": 849506, + "wandering-ram-937": 849507, + "grateful-stingray-498": 849548, + "decisive-swan-823": 849549, + "energetic-peccary-251": 849552, + "bright-panda-512": 849553, + "little-partridge-410": 849553, + "opulent-mongoose-252": 849591, + "coordinated-sardine-399": 849592, + "merry-starling-965": 849596, + "grand-sandpiper-551": 849597, + "shocking-owl-454": 849603, + "exuberant-greyhound-490": 849604, + "dutiful-setter-725": 849604, + "greedy-dinosaur-794": 849605, + "fleet-penguin-5": 849606, + "ideal-ox-979": 849609, + "famous-elephant-546": 849610, + "canny-fly-607": 849611, + "first-cuttlefish-256": 849612, + "scrupulous-gopher-96": 849619, + "fastidious-corgi-904": 849620, + "grandiose-hyena-577": 849630, + "fast-owl-61": 849631, + "industrious-retriever-107": 849643, + "cool-hedgehog-794": 849644, + "dazzling-alligator-152": 849649, + "first-rat-760": 849650, + "hallowed-ostrich-193": 849655, + "focused-ibex-318": 849656, + "colorful-jay-611": 849667, + "formal-shrimp-747": 849668, + "notable-peacock-512": 849688, + "adorable-toucan-340": 849689, + "benevolent-meadowlark-489": 849691, + "calm-labrador-343": 849692, + "outgoing-mink-253": 849749, + "fastidious-oyster-127": 849750, + "fantastic-kudu-258": 849787, + "majestic-kingfisher-813": 849788, + "wooden-tern-273": 849797, + "sensible-weasel-746": 849798, + "courteous-axolotl-605": 849798, + "upbeat-penguin-857": 849820, + "polite-fly-504": 849821, + "resolute-possum-610": 849840, + "shocking-owl-179": 849842, + "acoustic-cardinal-518": 849847, + "energetic-chicken-618": 849848, + "handsome-gull-974": 849852, + "fast-lion-552": 849853, + "proper-seal-762": 849866, + "giant-snake-927": 849867, + "outstanding-rat-410": 849904, + "knowing-duck-916": 849905, + "laudable-newt-422": 849907, + "festive-viper-79": 849908, + "marvelous-guanaco-968": 849908, + "quixotic-caterpillar-794": 849924, + "wary-warbler-628": 849925, + "flippant-warthog-97": 849926, + "diligent-gazelle-603": 849927, + "hearty-cricket-80": 849964, + "opulent-sardine-23": 849965, + "grandiose-donkey-663": 849968, + "agreeable-deer-952": 849969, + "basic-curlew-316": 850024, + "adept-mockingbird-112": 850025, + "merry-bear-954": 850030, + "moonlit-schnauzer-698": 850031, + "wary-possum-222": 850067, + "loyal-bee-187": 850068, + "tough-bloodhound-810": 850100, + "trustworthy-swordfish-839": 850101, + "lovable-boar-651": 850134, + "handsome-parakeet-103": 850135, + "adventurous-caiman-194": 850136, + "agreeable-dalmatian-693": 850137, + "agreeable-peacock-118": 850139, + "sleek-marlin-357": 850140, + "rosy-wolverine-432": 850161, + "wooden-barracuda-812": 850162, + "glorious-leopard-543": 850162, + "lovely-butterfly-870": 850215, + "glorious-dodo-87": 850216, + "scintillating-horse-901": 850237, + "mellow-hyena-359": 850238, + "acrobatic-magpie-30": 850238, + "superb-bison-681": 850274, + "grateful-wolverine-66": 850275, + "aromatic-weasel-227": 850277, + "fastidious-bison-130": 850278, + "impartial-kudu-540": 850300, + "opulent-cobra-497": 850302, + "good-goldfish-715": 850311, + "bright-wren-616": 850312, + "healthy-eel-517": 850312, + "earnest-peacock-891": 850321, + "resilient-labrador-173": 850322, + "strong-starfish-676": 850337, + "qualified-spaniel-896": 850338, + "hearty-ladybug-416": 850338, + "fast-marmot-902": 850348, + "robust-capybara-778": 850349, + "flexible-dalmatian-260": 850356, + "shiny-pigeon-291": 850357, + "limitless-malamute-496": 850363, + "necessary-whale-131": 850364, + "veracious-camel-170": 850372, + "veracious-mosquito-416": 850373, + "judicious-wolverine-465": 850386, + "wandering-guineapig-982": 850387, + "outstanding-wildebeest-871": 850390, + "valuable-salamander-595": 850391, + "peaceful-crocodile-405": 850415, + "valuable-otter-71": 850416, + "scintillating-hedgehog-977": 850419, + "hallowed-camel-365": 850420, + "tangible-spoonbill-57": 850465, + "charming-puma-623": 850466, + "enchanted-lion-20": 850473, + "grandiose-chickadee-65": 850474, + "hallowed-egret-945": 850510, + "warmhearted-bobcat-468": 850511, + "sleek-bullfrog-298": 850515, + "terrific-hummingbird-954": 850517, + "glorious-frog-883": 850547, + "fearless-falcon-439": 850548, + "laudable-peccary-951": 850549, + "patient-bat-520": 850550, + "calm-penguin-337": 850555, + "pleasant-curlew-230": 850556, + "third-kookabura-693": 850562, + "small-boar-306": 850563, + "optimistic-sardine-777": 850595, + "honorable-spoonbill-144": 850596, + "dapper-axolotl-646": 850601, + "utmost-pika-738": 850602, + "whimsical-snail-149": 850651, + "exuberant-bird-569": 850652, + "woozy-opossum-711": 850661, + "dapper-yak-764": 850662, + "polished-scorpion-359": 850669, + "polite-rook-217": 850670, + "steady-possum-734": 850677, + "coordinated-sardine-90": 850678, + "marvelous-stork-208": 850687, + "ceaseless-horse-671": 850688, + "quiet-seahorse-411": 850688, + "cheery-goshawk-365": 850691, + "striped-dog-647": 850692, + "grandiose-seal-229": 850696, + "cautious-wolf-610": 850697, + "utmost-panda-441": 850711, + "dynamic-gnu-545": 850713, + "grateful-cod-591": 850717, + "tidy-dotterel-757": 850718, + "abundant-sturgeon-160": 850733, + "quick-chihuahua-919": 850734, + "outstanding-dinosaur-259": 850753, + "befitting-rabbit-688": 850754, + "vibrant-sheep-359": 850775, + "fine-salmon-721": 850776, + "nautical-caterpillar-768": 850778, + "usable-civet-421": 850779, + "disciplined-rook-12": 850785, + "original-elephant-437": 850786, + "hushed-puma-134": 850794, + "vivid-clam-893": 850795, + "beloved-cat-989": 850800, + "woozy-kangaroo-652": 850801, + "dynamic-butterfly-315": 850802, + "unique-frog-397": 850803, + "descriptive-kingfisher-489": 850808, + "quirky-oyster-56": 850809, + "little-dolphin-492": 850809, + "cautious-mosquito-888": 850811, + "posh-dolphin-864": 850813, + "adjoining-kookabura-708": 850814, + "frugal-bee-254": 850815, + "shocking-jellyfish-170": 850815, + "effervescent-fly-408": 850862, + "grand-hyena-0": 850863, + "tough-hound-147": 850867, + "glorious-parrot-626": 850868, + "watchful-mockingbird-85": 850871, + "academic-mole-512": 850873, + "silent-seal-242": 850873, + "enduring-wildebeest-231": 850893, + "limitless-albatross-947": 850895, + "energized-goat-962": 850917, + "quiet-hedgehog-517": 850918, + "merry-falcon-242": 850959, + "fastidious-hare-369": 850960, + "reminiscent-bat-899": 850975, + "academic-rat-886": 850976, + "industrious-cuttlefish-357": 850976, + "qualified-giraffe-574": 850980, + "valiant-marmot-748": 850981, + "coordinated-kookabura-992": 850981, + "ceaseless-firefly-975": 850986, + "content-cow-392": 850987, + "secret-rat-450": 851001, + "watchful-wildcat-249": 851003, + "outstanding-reindeer-682": 851027, + "merry-lyrebird-382": 851028, + "groovy-impala-487": 851028, + "mellow-emu-498": 851034, + "prestigious-robin-677": 851035, + "unique-wombat-619": 851035, + "grateful-ibis-287": 851037, + "precise-platypus-52": 851039, + "impressive-platypus-246": 851039, + "deafening-warthog-564": 851066, + "proficient-oriole-965": 851067, + "wandering-hamster-103": 851068, + "rosy-tortoise-558": 851077, + "merry-robin-501": 851078, + "dazzling-lark-696": 851112, + "rapid-giraffe-395": 851113, + "proficient-horse-96": 851125, + "earnest-husky-145": 851126, + "marvelous-leopard-6": 851126, + "tacit-warbler-54": 851133, + "steady-newt-521": 851134, + "energized-sockeye-881": 851134, + "neighborly-viper-816": 851144, + "agreeable-eel-506": 851146, + "grateful-terrier-640": 851146, + "accomplished-opossum-126": 851149, + "wary-herring-393": 851150, + "rapid-wolf-156": 851153, + "sleek-sardine-587": 851154, + "gallant-greyhound-27": 851214, + "cheery-badger-214": 851215, + "hushed-alligator-816": 851237, + "content-hound-242": 851238, + "next-spoonbill-298": 851248, + "adorable-mammoth-428": 851249, + "spotted-narwhal-745": 851255, + "perceptive-armadillo-506": 851256, + "benevolent-mammoth-702": 851257, + "flexible-chihuahua-461": 851258, + "clean-grasshopper-403": 851258, + "adept-shepherd-462": 851269, + "aware-mouse-387": 851270, + "enduring-salmon-287": 851270, + "accurate-crane-686": 851273, + "blessed-marlin-4": 851274, + "sensible-ocelot-285": 851286, + "intent-dolphin-275": 851287, + "giant-koala-463": 851323, + "academic-gnat-61": 851325, + "greedy-hamster-462": 851354, + "accomplished-hyena-966": 851355, + "adept-hamster-308": 851365, + "brilliant-blackbird-132": 851366, + "ardent-sturgeon-160": 851366, + "shiny-lemur-264": 851367, + "famous-bandicoot-245": 851368, + "courteous-butterfly-462": 851368, + "resolute-tern-513": 851370, + "rare-deer-107": 851371, + "resolute-lemur-326": 851389, + "cool-opossum-186": 851390, + "sensible-mockingbird-631": 851401, + "dependable-llama-363": 851402, + "deafening-caterpillar-352": 851402, + "lovable-cricket-853": 851419, + "amicable-caiman-925": 851420, + "original-lapwing-717": 851428, + "woozy-woodpecker-608": 851429, + "majestic-finch-815": 851432, + "brilliant-curlew-423": 851433, + "upbeat-lapwing-242": 851435, + "vivid-hawk-326": 851436, + "efficient-hornet-9": 851436, + "knowing-rooster-31": 851439, + "grateful-emu-762": 851440, + "reliable-crane-486": 851465, + "adorable-elephant-421": 851466, + "fleet-egret-1": 851473, + "fine-marten-131": 851474, + "efficient-hawk-207": 851480, + "incredible-bat-757": 851481, + "neat-jellyfish-342": 851482, + "dutiful-dinosaur-712": 851483, + "fabulous-caribou-66": 851483, + "wary-perch-797": 851490, + "beloved-newt-683": 851493, + "perceptive-stork-473": 851500, + "polite-terrier-931": 851501, + "adept-ladybug-432": 851503, + "astute-kudu-758": 851504, + "shiny-marten-875": 851524, + "laudable-horse-256": 851525, + "coordinated-bullfrog-366": 851546, + "dazzling-newt-607": 851547, + "silent-viper-665": 851548, + "flippant-armadillo-972": 851549, + "frugal-mallard-352": 851550, + "steady-flamingo-367": 851551, + "uncommon-salamander-145": 851551, + "woozy-marlin-433": 851556, + "frugal-bison-413": 851558, + "tacit-pelican-964": 851558, + "oceanic-minnow-222": 851573, + "famous-stork-523": 851574, + "adorable-turtle-419": 851576, + "agreeable-porcupine-451": 851577, + "accomplished-cod-421": 851579, + "outstanding-greyhound-426": 851581, + "careful-dotterel-1": 851583, + "formal-malamute-40": 851584, + "moonlit-possum-750": 851586, + "judicious-minnow-369": 851587, + "mellow-seahorse-176": 851610, + "vibrant-minnow-258": 851611, + "ceaseless-shepherd-322": 851611, + "artful-curlew-780": 851631, + "giddy-aardvark-761": 851632, + "industrious-orca-403": 851646, + "giant-guineapig-827": 851647, + "adorable-mockingbird-847": 851664, + "cheery-grouse-761": 851665, + "silent-roadrunner-599": 851679, + "enduring-manatee-885": 851680, + "intent-hedgehog-490": 851680, + "brilliant-trout-822": 851682, + "clean-peccary-301": 851683, + "focused-dog-197": 851692, + "quiet-axolotl-99": 851693, + "befitting-alligator-207": 851701, + "silent-caiman-936": 851702, + "rapid-snail-826": 851709, + "marvelous-swordfish-79": 851710, + "hallowed-grasshopper-745": 851711, + "agreeable-tiger-516": 851713, + "fiery-jellyfish-176": 851734, + "veracious-magpie-422": 851736, + "acoustic-narwhal-145": 851737, + "accurate-jaguar-829": 851738, + "robust-ram-315": 851750, + "quiet-hound-629": 851752, + "hidden-peccary-287": 851752, + "impartial-lyrebird-465": 851774, + "brilliant-beagle-932": 851775, + "fiery-spoonbill-240": 851782, + "adjoining-horse-650": 851783, + "kindhearted-bat-981": 851797, + "adamant-ram-255": 851798, + "handsome-swan-208": 851804, + "harmless-boar-870": 851805, + "exuberant-shepherd-274": 851816, + "descriptive-goldfish-189": 851818, + "careful-moose-647": 851823, + "jovial-cardinal-534": 851824, + "shocking-perch-841": 851828, + "sleek-marlin-62": 851829, + "coordinated-penguin-727": 851829, + "famous-gazelle-367": 851830, + "grand-armadillo-133": 851831, + "steady-possum-66": 851832, + "enduring-orca-199": 851833, + "frugal-ocelot-23": 851837, + "tangible-malamute-217": 851839, + "dependable-chickadee-460": 851849, + "terrific-eagle-685": 851850, + "robust-vulture-429": 851857, + "cheerful-bee-103": 851858, + "courteous-clownfish-610": 851863, + "trustworthy-swordfish-100": 851865, + "chatty-basilisk-772": 851866, + "fearless-malamute-144": 851867, + "shocking-lion-580": 851869, + "grand-coyote-703": 851870, + "outstanding-guineapig-5": 851881, + "steady-oyster-834": 851882, + "secret-mastiff-663": 851882, + "resilient-seahorse-266": 851885, + "strong-herring-662": 851886, + "neat-echidna-143": 851895, + "glad-donkey-183": 851896, + "brazen-sardine-977": 851897, + "agile-cuttlefish-126": 851898, + "curious-platypus-190": 851898, + "fortunate-cheetah-468": 851951, + "tough-yak-559": 851953, + "scrupulous-fish-231": 851961, + "steady-opossum-979": 851963, + "zealous-pony-347": 851965, + "pleasant-hamster-480": 851966, + "chatty-herring-77": 851980, + "proper-marmot-270": 851981, + "dapper-setter-199": 851981, + "confident-spoonbill-903": 851996, + "resilient-spider-92": 851998, + "adept-kingfisher-898": 852002, + "hushed-fish-593": 852004, + "tame-lapwing-233": 852019, + "usable-swordfish-361": 852020, + "admired-crow-300": 852020, + "hardy-elk-359": 852043, + "adventurous-cassowary-422": 852044, + "acoustic-goldfish-912": 852059, + "loyal-dotterel-64": 852061, + "chatty-chinchilla-106": 852069, + "glorious-leopard-536": 852071, + "tough-boar-795": 852088, + "peaceful-hound-8": 852089, + "brazen-orca-953": 852123, + "exciting-starfish-425": 852124, + "earnest-clownfish-545": 852124, + "wonderful-bear-509": 852176, + "watchful-boar-20": 852178, + "combative-quail-743": 852225, + "warmhearted-wolf-446": 852226, + "valuable-walrus-203": 852246, + "enduring-malamute-341": 852247, + "terrific-dodo-865": 852262, + "quiet-sheep-342": 852263, + "calm-sardine-437": 852293, + "befitting-pheasant-721": 852294, + "adventurous-reindeer-556": 852328, + "abundant-viper-551": 852330, + "terrific-hound-417": 852330, + "confident-vole-721": 852334, + "beloved-clownfish-256": 852335, + "ceaseless-gecko-769": 852411, + "befitting-condor-358": 852412, + "basic-seahorse-664": 852412, + "cool-meadowlark-954": 852430, + "standing-elephant-971": 852431, + "coordinated-loris-122": 852512, + "fleet-leopard-690": 852513, + "quirky-moose-605": 852515, + "fortunate-grouse-559": 852516, + "decisive-mammoth-623": 852518, + "formal-caribou-609": 852519, + "dutiful-cod-913": 852527, + "sleek-setter-618": 852528, + "aromatic-opossum-253": 852528, + "perceptive-elk-837": 852539, + "reminiscent-cardinal-892": 852540, + "efficient-duck-438": 852574, + "adept-flamingo-626": 852575, + "vibrant-akita-933": 852678, + "formal-woodpecker-572": 852679, + "benevolent-mosquito-214": 852705, + "industrious-marlin-354": 852707, + "ardent-goldfinch-664": 852730, + "quixotic-hedgehog-824": 852732, + "joyous-mandrill-526": 852738, + "vivid-mouse-810": 852739, + "resolute-mule-893": 852804, + "first-falcon-101": 852805, + "dutiful-salmon-206": 852854, + "limitless-anteater-130": 852855, + "polished-viper-425": 852911, + "rare-moose-245": 852913, + "grand-beagle-854": 853007, + "rugged-kookabura-753": 853008, + "precious-wombat-249": 853076, + "basic-vulture-419": 853077, + "adorable-lark-901": 853077, + "fabulous-dinosaur-717": 853085, + "outstanding-swordfish-698": 853086, + "zealous-emu-423": 853100, + "precious-rat-375": 853101, + "laudable-eel-73": 853131, + "famous-mink-807": 853132, + "silent-kingfisher-404": 853148, + "famous-lemming-66": 853149, + "brainy-minnow-551": 853208, + "scintillating-hummingbird-822": 853209, + "artful-ocelot-653": 853224, + "earnest-deer-26": 853225, + "gregarious-labrador-410": 853239, + "elegant-quail-574": 853241, + "scrupulous-finch-739": 853244, + "quaint-dolphin-871": 853245, + "patient-bobcat-711": 853254, + "modest-pigeon-254": 853255, + "vibrant-malamute-245": 853260, + "compassionate-raven-762": 853261, + "grandiose-impala-492": 853266, + "basic-loris-383": 853267, + "striped-sturgeon-871": 853278, + "fabulous-kiwi-749": 853279, + "brainy-swordfish-153": 853280, + "fiery-buffalo-41": 853286, + "brilliant-chickadee-621": 853307, + "cool-firefly-933": 853313, + "colorful-shrimp-949": 853314, + "opulent-kookabura-750": 853347, + "marvelous-sandpiper-745": 853348, + "brave-magpie-366": 853350, + "confident-alligator-809": 853351, + "grateful-shrimp-42": 853388, + "hidden-snake-548": 853389, + "valuable-curlew-125": 853399, + "vivid-kiwi-916": 853400, + "proper-impala-928": 853404, + "outgoing-armadillo-849": 853405, + "necessary-weasel-203": 853415, + "good-civet-862": 853416, + "descriptive-lark-59": 853455, + "adorable-nightingale-498": 853456, + "clean-hawk-978": 853456, + "efficient-cobra-97": 853472, + "effervescent-hornet-306": 853473, + "kindhearted-raven-878": 853475, + "wonderful-cuttlefish-641": 853476, + "scintillating-goldfinch-907": 853490, + "adjoining-puffin-344": 853491, + "knowing-clownfish-706": 853495, + "beaming-gnu-437": 853514, + "wonderful-curlew-212": 853515, + "ardent-panther-952": 853523, + "coordinated-rhinoceros-315": 853524, + "loyal-eel-886": 853550, + "brainy-toucan-169": 853551, + "veracious-mule-132": 853572, + "grandiose-crane-400": 853573, + "rightful-fennec-451": 853599, + "savory-wildcat-972": 853600, + "warmhearted-swan-580": 853646, + "loyal-butterfly-929": 853647, + "cautious-penguin-946": 853647, + "fearless-wildcat-99": 853652, + "shocking-hound-372": 853653, + "different-mink-436": 853664, + "glorious-sturgeon-585": 853665, + "curious-caterpillar-213": 853689, + "content-deer-134": 853691, + "warmhearted-ladybug-203": 853729, + "opulent-boar-788": 853730, + "upbeat-albatross-937": 853737, + "hardy-rabbit-265": 853738, + "impressive-sturgeon-119": 853738, + "reliable-dog-209": 853766, + "fastidious-snail-504": 853767, + "youthful-dragon-13": 853767, + "dusty-axolotl-738": 853793, + "dashing-gull-25": 853794, + "striped-camel-521": 853806, + "brainy-lyrebird-101": 853807, + "friendly-stingray-812": 853827, + "shiny-trout-829": 853828, + "healthy-meadowlark-664": 853834, + "watchful-alpaca-696": 853835, + "next-dotterel-362": 853842, + "fine-partridge-831": 853843, + "curious-bloodhound-558": 853848, + "fast-sheep-402": 853849, + "adorable-iguana-156": 853853, + "diligent-puffin-909": 853854, + "acrobatic-beagle-167": 853860, + "befitting-hyena-745": 853861, + "dashing-vole-509": 853873, + "rugged-dog-239": 853874, + "perceptive-malamute-979": 853887, + "vibrant-echidna-907": 853888, + "fabulous-anteater-278": 853888, + "laudable-kingfisher-742": 853897, + "pleasant-sockeye-650": 853898, + "proficient-corgi-131": 853898, + "cool-porpoise-486": 853918, + "precise-echidna-568": 853919, + "loyal-manatee-250": 853919, + "hardy-chickadee-82": 853934, + "posh-sandpiper-785": 853935, + "steady-iguana-950": 853939, + "giddy-raven-176": 853940, + "fearless-cardinal-858": 853959, + "decisive-coyote-308": 853960, + "grand-schnauzer-171": 853970, + "next-frog-809": 853971, + "valuable-opossum-997": 853994, + "greedy-dogfish-367": 853995, + "usable-ibis-499": 854005, + "superb-starfish-814": 854006, + "formal-partridge-599": 854010, + "agreeable-tortoise-334": 854011, + "adventurous-bee-138": 854012, + "bold-hyena-52": 854013, + "graceful-oriole-133": 854013, + "aware-tern-166": 854027, + "gregarious-warbler-777": 854029, + "basic-rook-471": 854044, + "utmost-scorpion-78": 854046, + "friendly-jackal-690": 854046, + "proficient-armadillo-549": 854062, + "spotted-pigeon-955": 854063, + "mild-ferret-937": 854065, + "acrobatic-vole-226": 854066, + "mild-partridge-927": 854068, + "brave-spider-282": 854069, + "majestic-cheetah-96": 854076, + "pleasant-cobra-409": 854077, + "first-walrus-293": 854094, + "hushed-bass-995": 854096, + "brilliant-rhinoceros-709": 854106, + "friendly-albatross-421": 854109, + "qualified-snail-354": 854116, + "opulent-frog-81": 854117, + "moonlit-pheasant-337": 854117, + "utmost-labrador-470": 854125, + "adventurous-stoat-675": 854126, + "zealous-rhinoceros-289": 854133, + "cautious-grasshopper-362": 854134, + "loyal-finch-940": 854178, + "sensible-kookabura-134": 854179, + "valuable-swan-742": 854179, + "necessary-cassowary-763": 854190, + "dapper-quail-217": 854192, + "adventurous-dragon-214": 854194, + "courteous-falcon-561": 854195, + "robust-cuttlefish-217": 854197, + "accomplished-sockeye-581": 854198, + "fabulous-crow-264": 854198, + "cheery-mandrill-752": 854206, + "rightful-porpoise-956": 854207, + "kindhearted-peacock-16": 854215, + "striped-panther-896": 854216, + "deafening-dalmatian-151": 854222, + "unique-ram-291": 854223, + "ardent-hare-657": 854223, + "exciting-goose-126": 854232, + "animated-toucan-699": 854233, + "mellow-trout-538": 854257, + "laudable-ostrich-254": 854258, + "beaming-goat-243": 854258, + "canny-walrus-242": 854263, + "abundant-starfish-457": 854264, + "impressive-minnow-306": 854264, + "optimistic-basilisk-372": 854269, + "precious-anteater-358": 854270, + "dazzling-warbler-605": 854271, + "friendly-terrier-719": 854272, + "academic-shrimp-138": 854272, + "combative-dotterel-570": 854331, + "befitting-eel-394": 854332, + "resolute-swordfish-287": 854337, + "tremendous-badger-953": 854338, + "basic-walrus-824": 854338, + "kindly-pig-920": 854374, + "wry-poodle-429": 854375, + "enchanted-ostrich-893": 854409, + "original-akita-956": 854410, + "veracious-stoat-564": 854410, + "moonlit-panther-431": 854444, + "perfect-monitor-821": 854445, + "coordinated-duck-161": 854473, + "opulent-starfish-800": 854474, + "wooden-elk-398": 854484, + "kindred-jellyfish-920": 854485, + "formal-anaconda-531": 854511, + "quiet-owl-537": 854512, + "brilliant-buzzard-275": 854513, + "cheerful-gerbil-604": 854514, + "good-hawk-452": 854526, + "hardy-hare-142": 854527, + "necessary-gazelle-497": 854533, + "majestic-moose-714": 854534, + "grand-chihuahua-759": 854534, + "sleek-alpaca-880": 854544, + "brave-scorpion-765": 854545, + "useful-chihuahua-616": 854557, + "clean-gull-112": 854558, + "polished-clownfish-205": 854561, + "proficient-raven-463": 854562, + "ideal-swordfish-369": 854562, + "marvelous-badger-16": 854563, + "judicious-falcon-548": 854564, + "necessary-bird-467": 854570, + "decisive-starling-705": 854571, + "beloved-coyote-367": 854576, + "harmless-egret-308": 854577, + "compassionate-tiger-3": 854601, + "zany-jay-422": 854602, + "perfect-setter-124": 854602, + "pastel-salamander-560": 854618, + "quaint-crab-266": 854620, + "rare-turtle-847": 854673, + "shiny-shark-473": 854674, + "optimistic-jellyfish-333": 854674, + "colorless-loris-826": 854698, + "qualified-parrot-680": 854699, + "formal-swordfish-689": 854759, + "accomplished-koala-265": 854760, + "uncommon-mosquito-162": 854761, + "impartial-cheetah-497": 854762, + "kindly-hummingbird-770": 854767, + "adventurous-chickadee-430": 854768, + "energetic-salmon-742": 854792, + "cautious-hyena-246": 854793, + "canny-kiwi-794": 854813, + "dynamic-hound-737": 854814, + "enduring-falcon-559": 854827, + "precise-lark-401": 854828, + "focused-elephant-3": 854828, + "rosy-elephant-457": 854869, + "rugged-crocodile-951": 854870, + "quiet-camel-255": 854883, + "avid-bat-690": 854884, + "outstanding-dolphin-348": 854884, + "cool-yak-762": 854888, + "famous-mastiff-379": 854889, + "cool-axolotl-27": 854900, + "brilliant-pheasant-693": 854901, + "grand-cassowary-937": 854903, + "steady-platypus-12": 854904, + "aromatic-porcupine-493": 854911, + "tacit-mallard-297": 854913, + "helpful-egret-247": 854915, + "elated-horse-314": 854916, + "cheery-sparrow-987": 854937, + "resilient-bear-886": 854938, + "watchful-cheetah-34": 854987, + "small-marten-453": 854988, + "reminiscent-emu-367": 855004, + "adamant-swordfish-707": 855006, + "flexible-kingfisher-121": 855016, + "quirky-ibis-282": 855025, + "polite-reindeer-892": 855026, + "efficient-mink-814": 855026, + "blessed-herring-255": 855054, + "energized-flamingo-745": 855055, + "wry-axolotl-385": 855060, + "hidden-porpoise-317": 855062, + "handsome-goat-606": 855062, + "lovely-shrimp-171": 855069, + "warmhearted-dragon-531": 855070, + "reliable-cow-428": 855075, + "accurate-hare-922": 855076, + "greedy-egret-31": 855131, + "quaint-bloodhound-275": 855132, + "impartial-jackal-499": 855132, + "gregarious-dove-720": 855151, + "nautical-minnow-506": 855153, + "trustworthy-turtle-515": 855158, + "notable-terrier-582": 855159, + "valuable-skunk-336": 855165, + "greedy-giraffe-53": 855166, + "fabulous-shepherd-196": 855182, + "majestic-toad-444": 855183, + "energized-whale-51": 855183, + "good-poodle-725": 855225, + "notable-ocelot-636": 855226, + "hip-kookabura-398": 855238, + "brazen-tern-209": 855239, + "determined-ermine-866": 855246, + "hardy-puma-904": 855247, + "kindhearted-marten-544": 855253, + "effervescent-avocet-174": 855254, + "spotted-gopher-999": 855264, + "dynamic-penguin-254": 855265, + "moonlit-goldfinch-767": 855266, + "hardy-ibex-272": 855267, + "doting-ox-212": 855267, + "deafening-gopher-803": 855269, + "terrific-tern-961": 855270, + "gallant-hare-578": 855270, + "adjoining-octopus-994": 855278, + "amicable-eel-602": 855279, + "prestigious-guineapig-203": 855308, + "successful-seal-443": 855310, + "dutiful-mandrill-572": 855310, + "modest-lyrebird-579": 855317, + "pastel-boar-924": 855318, + "sincere-elephant-2": 855337, + "fleet-meadowlark-627": 855338, + "upbeat-toad-752": 855367, + "rightful-parakeet-43": 855368, + "beloved-rat-67": 855395, + "proper-vole-724": 855396, + "descriptive-hamster-48": 855414, + "sleek-caribou-577": 855415, + "adorable-firefly-52": 855456, + "fortunate-emu-456": 855457, + "sleek-labrador-576": 855459, + "descriptive-weasel-777": 855461, + "blessed-dinosaur-205": 855461, + "judicious-capybara-383": 855462, + "dutiful-bat-927": 855464, + "expert-caterpillar-56": 855476, + "precise-antelope-831": 855477, + "helpful-dalmatian-53": 855518, + "rightful-dinosaur-901": 855519, + "reminiscent-snake-183": 855525, + "agreeable-kudu-146": 855526, + "robust-mastiff-721": 855548, + "nautical-flamingo-477": 855549, + "outgoing-hamster-766": 855558, + "content-seal-140": 855559, + "oceanic-squirrel-808": 855559, + "quixotic-reindeer-738": 855564, + "admired-giraffe-616": 855565, + "careful-nightingale-214": 855570, + "fantastic-antelope-915": 855571, + "famous-narwhal-601": 855573, + "avid-wren-712": 855574, + "hidden-badger-679": 855592, + "deafening-bird-722": 855593, + "proper-dogfish-154": 855610, + "insightful-stoat-855": 855611, + "joyous-aardvark-894": 855611, + "notable-peacock-525": 855615, + "good-chickadee-965": 855616, + "marvelous-mole-166": 855616, + "wary-pelican-513": 855629, + "blessed-poodle-241": 855630, + "warmhearted-opossum-79": 855641, + "adorable-scorpion-420": 855642, + "zealous-poodle-81": 855698, + "famous-turtle-108": 855699, + "basic-moose-732": 855699, + "qualified-zebra-997": 855745, + "nautical-lyrebird-492": 855746, + "perfect-nightingale-297": 855766, + "hearty-cassowary-510": 855767, + "beloved-partridge-537": 855783, + "admired-porpoise-986": 855784, + "neat-sardine-735": 855784, + "grateful-cobra-285": 855828, + "warmhearted-whale-202": 855829, + "oceanic-weasel-484": 855829, + "basic-beagle-737": 855855, + "focused-magpie-925": 855857, + "academic-ant-475": 855867, + "reliable-flamingo-394": 855868, + "patient-chinchilla-76": 855871, + "knowing-cheetah-45": 855872, + "strong-mule-636": 855926, + "judicious-grasshopper-101": 855927, + "rightful-salamander-447": 855938, + "shiny-corgi-253": 855939, + "honorable-impala-201": 855944, + "basic-penguin-413": 855945, + "aromatic-goat-189": 855945, + "quixotic-marten-529": 855960, + "cautious-terrier-418": 855961, + "admired-setter-7": 855961, + "neighborly-tern-993": 855989, + "adamant-partridge-652": 855990, + "proper-rhinoceros-73": 855994, + "stoic-bee-782": 855995, + "notable-platypus-658": 856002, + "energetic-hound-336": 856003, + "adjoining-clam-53": 856027, + "quirky-bison-101": 856028, + "rosy-sandpiper-678": 856039, + "cheery-caiman-690": 856040, + "secret-snake-88": 856057, + "quiet-guanaco-253": 856059, + "prestigious-alpaca-264": 856071, + "first-finch-808": 856072, + "sincere-ram-102": 856097, + "first-herring-491": 856098, + "quiet-goldfish-346": 856115, + "wry-shepherd-572": 856116, + "affable-mosquito-292": 856123, + "reminiscent-salamander-316": 856124, + "perceptive-flamingo-775": 856130, + "admired-shark-517": 856131, + "famous-wildcat-961": 856138, + "kindhearted-dachshund-397": 856139, + "quixotic-gopher-803": 856144, + "proficient-schnauzer-170": 856145, + "precious-kingfisher-2": 856198, + "glad-tern-203": 856199, + "rapid-starfish-449": 856239, + "terrific-shark-148": 856240, + "neighborly-dragon-185": 856260, + "giant-buffalo-621": 856261, + "deafening-ibis-542": 856283, + "small-porcupine-410": 856284, + "brilliant-cardinal-65": 856301, + "wry-weasel-604": 856302, + "diligent-chinchilla-345": 856312, + "good-pelican-480": 856313, + "precious-caterpillar-693": 856369, + "veracious-starfish-15": 856370, + "befitting-toucan-157": 856379, + "deafening-wren-439": 856380, + "acrobatic-cassowary-19": 856419, + "animated-cheetah-288": 856420, + "diligent-echidna-504": 856422, + "efficient-marmot-955": 856423, + "festive-panther-338": 856460, + "brazen-otter-829": 856461, + "sleek-mastiff-797": 856482, + "agile-squid-152": 856483, + "elated-goldfish-275": 856493, + "friendly-dragon-233": 856494, + "fast-gecko-659": 856534, + "adamant-horse-996": 856535, + "enduring-oyster-877": 856535, + "impressive-mallard-498": 856549, + "prestigious-pig-658": 856550, + "limitless-emu-515": 856579, + "formal-rat-883": 856580, + "chatty-eel-122": 856580, + "fearless-lapwing-444": 856610, + "quaint-weasel-379": 856611, + "impartial-squid-687": 856619, + "agreeable-peacock-792": 856620, + "kindhearted-hornet-825": 856670, + "earnest-snake-175": 856671, + "silent-opossum-110": 856716, + "giant-orca-989": 856717, + "wandering-dolphin-227": 856734, + "jovial-eel-208": 856735, + "energetic-fennec-347": 856737, + "judicious-finch-751": 856738, + "precious-roadrunner-447": 856738, + "acrobatic-eel-722": 856741, + "exuberant-mongoose-700": 856742, + "diligent-pony-193": 856777, + "blessed-rat-474": 856778, + "posh-woodpecker-505": 856778, + "amiable-alligator-765": 856798, + "graceful-flamingo-457": 856799, + "energized-jay-784": 856802, + "joyous-dodo-905": 856803, + "ceaseless-avocet-372": 856837, + "hidden-crab-847": 856838, + "famous-dinosaur-315": 856902, + "ideal-turtle-43": 856903, + "capable-wolverine-973": 856912, + "bold-goshawk-156": 856913, + "friendly-dragon-305": 856935, + "superb-guanaco-398": 856936, + "marvelous-stoat-805": 856936, + "blissful-starling-294": 856939, + "intent-firefly-908": 856940, + "amiable-giraffe-602": 856948, + "lovable-camel-953": 856949, + "necessary-guineapig-169": 856949, + "dutiful-owl-176": 857005, + "colorless-elk-270": 857006, + "steady-tiger-223": 857021, + "benevolent-lynx-221": 857022, + "dapper-trout-141": 857041, + "wonderful-finch-808": 857042, + "greedy-minnow-101": 857043, + "rightful-malamute-617": 857044, + "aware-armadillo-231": 857051, + "scintillating-seahorse-169": 857052, + "marvelous-dolphin-810": 857057, + "befitting-mink-692": 857058, + "hip-monitor-19": 857064, + "qualified-goldfish-907": 857065, + "dapper-cassowary-674": 857073, + "energetic-buzzard-781": 857074, + "doting-herring-875": 857077, + "accurate-bandicoot-726": 857078, + "stoic-kingfisher-881": 857094, + "savory-whale-985": 857095, + "silent-shark-914": 857109, + "agreeable-crane-478": 857110, + "grand-spoonbill-641": 857115, + "rightful-wildcat-722": 857116, + "marvelous-lobster-742": 857125, + "agreeable-swan-38": 857126, + "steady-falcon-320": 857136, + "marvelous-hound-472": 857137, + "bright-gazelle-255": 857185, + "mild-goldfish-629": 857186, + "combative-crow-526": 857198, + "stoic-chihuahua-514": 857199, + "hip-buzzard-26": 857206, + "fantastic-anteater-470": 857207, + "colorless-meadowlark-52": 857214, + "courteous-leopard-820": 857215, + "hallowed-mallard-877": 857220, + "wooden-lyrebird-944": 857221, + "fine-basilisk-122": 857229, + "avid-hornet-352": 857230, + "gregarious-trout-827": 857243, + "mellow-canary-763": 857244, + "third-lapwing-831": 857247, + "efficient-chameleon-294": 857248, + "glad-marlin-215": 857252, + "canny-jaguar-277": 857253, + "ceaseless-barracuda-985": 857260, + "loyal-eagle-747": 857261, + "hearty-antelope-13": 857283, + "calculating-starfish-494": 857284, + "vivid-mule-381": 857285, + "hushed-bison-460": 857286, + "dazzling-bulldog-744": 857289, + "blessed-loris-495": 857290, + "kindhearted-bass-212": 857290, + "scintillating-dodo-431": 857303, + "groovy-llama-116": 857304, + "adorable-gazelle-230": 857310, + "striped-elk-676": 857311, + "first-koala-442": 857311, + "reliable-perch-326": 857314, + "moonlit-bat-902": 857316, + "merry-herring-771": 857322, + "hip-crow-430": 857323, + "blessed-mouse-537": 857334, + "first-sardine-191": 857335, + "dutiful-otter-411": 857343, + "insightful-schnauzer-382": 857344, + "wooden-chameleon-630": 857346, + "secret-hamster-33": 857348, + "doting-elephant-581": 857353, + "wonderful-malamute-60": 857354, + "accurate-squirrel-283": 857374, + "wandering-gerbil-133": 857378, + "doting-sturgeon-720": 857383, + "prestigious-wildcat-158": 857384, + "neat-perch-173": 857385, + "vibrant-mosquito-507": 857387, + "quiet-bear-808": 857390, + "outgoing-wolf-812": 857392, + "vivid-marten-962": 857394, + "strong-mouse-146": 857395, + "adjoining-chameleon-868": 857396, + "marvelous-opossum-668": 857403, + "fortunate-porpoise-521": 857404, + "pastel-moose-455": 857414, + "tidy-orca-830": 857415, + "formal-whale-754": 857477, + "tacit-wombat-384": 857478, + "energized-bulldog-862": 857483, + "careful-cobra-262": 857484, + "quiet-partridge-372": 857488, + "third-porcupine-1": 857489, + "wandering-warbler-461": 857497, + "upbeat-aardvark-915": 857498, + "kindly-eel-737": 857498, + "rapid-wolverine-762": 857500, + "watchful-gnu-740": 857501, + "fiery-dodo-567": 857501, + "energetic-goose-460": 857503, + "intent-spider-558": 857504, + "modest-kookabura-263": 857505, + "rugged-grasshopper-786": 857506, + "woozy-elk-557": 857507, + "dynamic-hare-829": 857508, + "scintillating-sockeye-955": 857509, + "acrobatic-warbler-229": 857510, + "acoustic-nightingale-89": 857512, + "moonlit-frog-896": 857513, + "animated-husky-324": 857515, + "grateful-ladybug-181": 857516, + "sleek-beagle-274": 857517, + "proper-mule-186": 857519, + "optimistic-magpie-62": 857535, + "pastel-starling-649": 857536, + "handsome-iguana-800": 857538, + "decisive-capybara-28": 857539, + "brainy-civet-296": 857546, + "benevolent-barracuda-947": 857547, + "qualified-hedgehog-646": 857606, + "groovy-guineapig-480": 857607, + "gregarious-ptarmigan-241": 857608, + "giddy-oriole-679": 857609, + "enduring-giraffe-77": 857614, + "resilient-tern-204": 857615, + "wooden-starling-48": 857616, + "judicious-bird-286": 857618, + "oceanic-swordfish-505": 857624, + "energized-finch-110": 857625, + "superb-lemming-390": 857630, + "hidden-labrador-933": 857632, + "impressive-crab-689": 857640, + "usable-alpaca-184": 857641, + "exciting-eel-827": 857643, + "tame-crab-350": 857644, + "rare-yak-426": 857646, + "glorious-grouse-847": 857647, + "disciplined-koala-89": 857653, + "agreeable-goldfish-392": 857654, + "beaming-dog-519": 857654, + "polite-possum-213": 857698, + "outgoing-gull-114": 857699, + "adventurous-starling-697": 857702, + "secret-lapwing-20": 857704, + "exuberant-chameleon-726": 857704, + "brainy-bison-145": 857716, + "youthful-ostrich-505": 857717, + "disciplined-octopus-645": 857718, + "fast-jay-293": 857719, + "coordinated-duck-656": 857728, + "effervescent-mockingbird-211": 857729, + "wooden-viper-633": 857763, + "dutiful-impala-966": 857764, + "festive-ibex-438": 857772, + "proper-gerbil-783": 857773, + "calm-marten-198": 857797, + "veracious-bat-867": 857798, + "brazen-magpie-855": 857799, + "decisive-armadillo-633": 857800, + "clean-mallard-933": 857801, + "intent-hamster-756": 857802, + "fabulous-schnauzer-748": 857809, + "coordinated-cardinal-605": 857810, + "outgoing-monitor-371": 857818, + "youthful-sockeye-606": 857819, + "fleet-jellyfish-144": 857848, + "tacit-tortoise-761": 857849, + "terrific-eel-390": 857852, + "harmless-eel-151": 857853, + "admired-hound-811": 857855, + "expert-moose-722": 857856, + "affable-grouse-824": 857860, + "valiant-cod-55": 857861, + "grand-clam-645": 857864, + "befitting-sparrow-16": 857865, + "pastel-sandpiper-309": 857876, + "calculating-jay-5": 857877, + "useful-rook-98": 857878, + "acrobatic-robin-363": 857879, + "dazzling-kudu-61": 857896, + "proper-spoonbill-6": 857897, + "beaming-jay-762": 857912, + "artful-cuttlefish-824": 857913, + "quick-crane-385": 857916, + "confident-viper-979": 857917, + "descriptive-capybara-289": 857919, + "fastidious-jay-663": 857920, + "brilliant-sardine-438": 857922, + "zealous-otter-917": 857923, + "wry-starfish-770": 857927, + "quick-canary-675": 857928, + "perfect-curlew-687": 857934, + "bright-marmot-464": 857935, + "kindly-cardinal-999": 857939, + "wry-ostrich-376": 857940, + "enchanted-avocet-960": 857967, + "tacit-schnauzer-812": 857968, + "dutiful-labrador-294": 857980, + "frugal-gecko-608": 857981, + "prestigious-mosquito-603": 858031, + "gallant-oriole-669": 858032, + "tough-hippopotamus-984": 858032, + "tremendous-badger-744": 858070, + "spotted-ptarmigan-345": 858072, + "sensible-platypus-150": 858092, + "upbeat-hedgehog-220": 858093, + "adorable-bird-971": 858151, + "secret-capybara-570": 858152, + "artful-ox-715": 858192, + "beaming-sheep-428": 858193, + "dutiful-fox-624": 858195, + "beaming-sheep-722": 858196, + "hallowed-grasshopper-981": 858200, + "aware-hawk-982": 858201, + "clear-koala-813": 858221, + "famous-horse-503": 858222, + "warmhearted-pony-176": 858232, + "basic-mole-32": 858233, + "hushed-rook-30": 858234, + "earnest-beagle-280": 858235, + "fine-hummingbird-111": 858236, + "dashing-heron-469": 858237, + "ardent-cobra-812": 858340, + "clean-parrot-282": 858342, + "glorious-buffalo-630": 858353, + "dusty-ermine-265": 858354, + "moonlit-greyhound-70": 858387, + "animated-mongoose-937": 858388, + "calculating-beagle-990": 858415, + "brave-marlin-488": 858416, + "greedy-monitor-973": 858442, + "first-cricket-427": 858443, + "striped-hamster-420": 858448, + "basic-ibis-589": 858449, + "kindly-hummingbird-309": 858459, + "zealous-stork-793": 858460, + "little-ibis-257": 858467, + "polished-clam-109": 858468, + "trustworthy-clownfish-908": 858484, + "cautious-koala-52": 858485, + "effervescent-elephant-772": 858506, + "qualified-sturgeon-342": 858507, + "keen-jellyfish-515": 858617, + "deafening-fly-369": 858618, + "handsome-mammoth-582": 858629, + "agile-sardine-151": 858630, + "kindhearted-llama-732": 858644, + "admired-turtle-441": 858645, + "zealous-ram-32": 858646, + "sleek-toad-708": 858647, + "jovial-schnauzer-352": 858649, + "giddy-donkey-531": 858651, + "compassionate-fennec-140": 858653, + "modest-goose-100": 858654, + "brilliant-woodpecker-532": 858666, + "proficient-opossum-686": 858668, + "trustworthy-weasel-596": 858669, + "agreeable-narwhal-519": 858670, + "impartial-newt-830": 858681, + "pleasant-guanaco-689": 858682, + "glad-gnu-926": 858682, + "superb-woodpecker-900": 858683, + "opulent-badger-928": 858684, + "zany-bird-655": 858690, + "acrobatic-chinchilla-830": 858691, + "terrific-alligator-158": 858692, + "efficient-deer-921": 858694, + "opulent-ferret-274": 858695, + "energetic-armadillo-682": 858696, + "merry-meerkat-996": 858708, + "reminiscent-woodpecker-891": 858709, + "ardent-bee-666": 858709, + "glad-weasel-796": 858713, + "disciplined-crane-245": 858714, + "brainy-dalmatian-399": 858714, + "marvelous-bullfrog-599": 858728, + "resilient-meerkat-667": 858729, + "courteous-caiman-9": 858729, + "knowing-okapi-825": 858736, + "brainy-wombat-465": 858737, + "clear-mockingbird-407": 858738, + "harmless-tapir-40": 858739, + "combative-flamingo-389": 858740, + "enduring-hyena-605": 858741, + "brainy-flamingo-775": 858743, + "wandering-buzzard-784": 858744, + "friendly-lapwing-492": 858792, + "valiant-cheetah-833": 858793, + "energized-shepherd-792": 858794, + "hidden-greyhound-6": 858795, + "festive-trout-394": 858808, + "cheerful-rooster-384": 858810, + "brave-grouse-575": 858816, + "adjoining-poodle-93": 858817, + "keen-goldfinch-711": 858817, + "utmost-gerbil-621": 858850, + "good-cormorant-360": 858851, + "disciplined-mule-647": 858893, + "rightful-herring-312": 858894, + "capable-aardvark-220": 858924, + "opulent-elk-327": 858925, + "precise-hawk-783": 858952, + "sleek-mouse-457": 858954, + "tangible-rook-814": 858954, + "wry-cuttlefish-84": 859004, + "mild-parrot-413": 859006, + "terrific-butterfly-395": 859105, + "patient-cod-769": 859106, + "adorable-caribou-44": 859131, + "striped-aardvark-28": 859132, + "different-kingfisher-303": 859194, + "striped-terrier-970": 859195, + "different-coyote-306": 859217, + "beaming-setter-462": 859220, + "perceptive-mosquito-602": 859220, + "next-scorpion-512": 859228, + "valiant-giraffe-482": 859229, + "zealous-hawk-553": 859233, + "judicious-gerbil-116": 859234, + "spotted-stork-596": 859271, + "blessed-kookabura-99": 859273, + "posh-beagle-320": 859273, + "mild-warthog-697": 859283, + "rare-finch-357": 859284, + "helpful-monitor-167": 859306, + "handsome-caterpillar-792": 859307, + "exuberant-toucan-261": 859313, + "dynamic-partridge-668": 859314, + "watchful-dove-735": 859329, + "rightful-possum-417": 859330, + "doting-trout-153": 859355, + "admired-panda-661": 859357, + "canny-ostrich-1": 859359, + "steady-schnauzer-7": 859360, + "cheerful-akita-876": 859377, + "adventurous-sandpiper-806": 859378, + "outgoing-panther-461": 859388, + "avid-otter-597": 859389, + "coordinated-dogfish-720": 859422, + "canny-guineapig-921": 859423, + "standing-dolphin-151": 859430, + "loyal-cormorant-811": 859431, + "sincere-elk-169": 859431, + "polite-squid-839": 859471, + "modest-badger-975": 859472, + "hardy-axolotl-597": 859500, + "scrupulous-reindeer-213": 859501, + "rugged-yak-718": 859506, + "superb-goat-362": 859507, + "aromatic-opossum-288": 859510, + "wonderful-goose-123": 859511, + "groovy-walrus-415": 859513, + "rare-corgi-632": 859514, + "zealous-quail-953": 859566, + "careful-kingfisher-208": 859568, + "giant-meadowlark-896": 859573, + "lovely-goldfish-633": 859574, + "energized-turtle-58": 859665, + "animated-dachshund-264": 859666, + "bright-tiger-294": 859762, + "precise-mongoose-971": 859763, + "impressive-weasel-447": 859830, + "majestic-tiger-166": 859831, + "ardent-cuttlefish-847": 859834, + "original-impala-497": 859835, + "hearty-snail-31": 859854, + "artful-rabbit-504": 859855, + "oceanic-mink-192": 859855, + "usable-impala-792": 859912, + "first-hippopotamus-305": 859913, + "neighborly-cow-681": 859929, + "elegant-sheep-169": 859930, + "fleet-ibex-489": 859930, + "wandering-cardinal-30": 859946, + "perfect-gull-257": 859947, + "perceptive-moose-776": 859948, + "laudable-albatross-160": 859949, + "dutiful-deer-886": 859950, + "outgoing-tapir-443": 859951, + "dazzling-setter-847": 859978, + "striped-pelican-800": 859979, + "cautious-mosquito-279": 860008, + "striped-egret-168": 860009, + "animated-dragon-692": 860038, + "silent-dachshund-764": 860039, + "accurate-bird-321": 860097, + "blessed-yak-777": 860098, + "courteous-cuttlefish-616": 860109, + "deafening-stork-256": 860110, + "bright-roadrunner-766": 860123, + "youthful-lobster-325": 860124, + "bold-hamster-994": 860232, + "tame-shepherd-690": 860233, + "zealous-alpaca-623": 860253, + "chatty-beagle-539": 860254, + "grand-okapi-753": 860324, + "greedy-squirrel-779": 860325, + "polite-bear-481": 860333, + "courteous-camel-19": 860334, + "gallant-anaconda-918": 860335, + "wooden-tiger-335": 860336, + "adjoining-lyrebird-873": 860365, + "dazzling-herring-965": 860366, + "watchful-duck-528": 860366, + "insightful-shrimp-996": 860383, + "adjoining-dodo-894": 860384, + "neighborly-opossum-798": 860384, + "good-whale-641": 860409, + "calculating-rabbit-452": 860410, + "descriptive-dolphin-858": 860431, + "third-bullfrog-822": 860432, + "brazen-goldfinch-544": 860465, + "outgoing-lark-683": 860466, + "dependable-chipmunk-208": 860470, + "friendly-cricket-612": 860471, + "adventurous-fennec-382": 860471, + "wandering-salamander-517": 860473, + "affable-emu-70": 860474, + "neat-coyote-991": 860482, + "enchanted-emu-133": 860483, + "friendly-hornet-90": 860483, + "adept-oriole-300": 860508, + "warmhearted-magpie-692": 860509, + "focused-dove-665": 860531, + "groovy-husky-662": 860532, + "clean-cod-764": 860538, + "flexible-tortoise-427": 860539, + "agreeable-clam-721": 860561, + "clean-meerkat-804": 860562, + "resilient-husky-142": 860562, + "vivid-panda-543": 860592, + "valiant-dolphin-299": 860593, + "hidden-egret-929": 860636, + "quaint-guineapig-766": 860637, + "bold-buzzard-713": 860649, + "earnest-basilisk-426": 860650, + "laudable-mule-460": 860672, + "good-dalmatian-983": 860673, + "rare-marten-404": 860684, + "careful-axolotl-944": 860685, + "sincere-salmon-771": 860685, + "perfect-perch-121": 860713, + "polished-trout-99": 860715, + "optimistic-crow-959": 860720, + "oceanic-dalmatian-296": 860721, + "accomplished-penguin-887": 860769, + "outstanding-mockingbird-765": 860770, + "calculating-chinchilla-998": 860777, + "lovable-robin-380": 860778, + "precious-wombat-826": 860778, + "perfect-lapwing-463": 860821, + "ceaseless-starfish-117": 860822, + "formal-peccary-567": 860825, + "brilliant-mastiff-108": 860828, + "rare-oyster-379": 860832, + "healthy-hippopotamus-540": 860834, + "determined-giraffe-77": 860845, + "posh-crab-295": 860846, + "quaint-axolotl-133": 860908, + "valiant-chipmunk-272": 860909, + "successful-tiger-489": 860937, + "fearless-buzzard-200": 860938, + "mellow-flamingo-378": 860953, + "deafening-magpie-563": 860954, + "impressive-spoonbill-289": 860997, + "canny-fish-353": 860998, + "tacit-camel-357": 861030, + "fiery-mouse-672": 861031, + "fortunate-gopher-795": 861085, + "outstanding-iguana-404": 861086, + "reliable-kangaroo-604": 861098, + "flexible-antelope-734": 861099, + "optimistic-viper-286": 861118, + "wooden-horse-179": 861119, + "secret-labrador-884": 861119, + "agreeable-husky-77": 861120, + "acoustic-perch-272": 861121, + "friendly-crocodile-71": 861121, + "adorable-jaguar-613": 861166, + "patient-alligator-750": 861181, + "brilliant-labrador-645": 861182, + "basic-jaguar-924": 861182, + "sensible-hummingbird-126": 861200, + "zealous-parrot-275": 861201, + "adamant-butterfly-838": 861234, + "adventurous-marlin-766": 861236, + "scrupulous-loris-982": 861275, + "tacit-nightingale-91": 861276, + "famous-newt-613": 861283, + "cheery-otter-389": 861284, + "woozy-caterpillar-751": 861284, + "trustworthy-oriole-273": 861345, + "first-ptarmigan-252": 861347, + "limitless-flamingo-478": 861349, + "dusty-skunk-201": 861351, + "befitting-chameleon-932": 861353, + "warmhearted-warbler-701": 861363, + "brainy-finch-590": 861364, + "modest-chihuahua-950": 861368, + "knowing-kudu-978": 861369, + "clever-vole-341": 861369, + "wary-cassowary-847": 861412, + "wary-clam-755": 861413, + "formal-terrier-544": 861455, + "doting-vulture-851": 861456, + "pleasant-eel-732": 861537, + "lovable-chickadee-629": 861539, + "ideal-cheetah-933": 861545, + "strong-frog-545": 861546, + "successful-emu-545": 861546, + "unique-quail-446": 861572, + "dusty-hare-905": 861573, + "secret-magpie-762": 861573, + "brainy-gnu-945": 861586, + "industrious-guanaco-315": 861587, + "diligent-opossum-139": 861597, + "tangible-impala-940": 861598, + "resilient-pigeon-160": 861603, + "wry-tiger-642": 861605, + "precious-goldfish-147": 861643, + "rare-cod-846": 861644, + "harmless-mink-734": 861644, + "giddy-sturgeon-40": 861659, + "joyous-spaniel-528": 861660, + "dependable-albatross-353": 861679, + "kindred-rooster-874": 861680, + "dutiful-dodo-546": 861743, + "dashing-hare-778": 861744, + "glorious-moose-798": 861764, + "hardy-dotterel-112": 861765, + "brainy-axolotl-758": 861789, + "scintillating-stoat-92": 861790, + "incredible-ladybug-710": 861790, + "tangible-duck-581": 861810, + "basic-pig-671": 861811, + "savory-labrador-585": 861811, + "rare-beagle-441": 861827, + "limitless-pig-999": 861828, + "jovial-hamster-358": 861836, + "rosy-rooster-91": 861837, + "utmost-spider-377": 861837, + "careful-ox-410": 861867, + "amiable-moose-618": 861868, + "lovely-frog-686": 861868, + "focused-meadowlark-712": 861904, + "accurate-mongoose-639": 861905, + "next-bison-38": 861914, + "tidy-nightingale-440": 861915, + "woozy-goshawk-44": 861915, + "mellow-salmon-279": 861983, + "rosy-oyster-616": 861984, + "small-toucan-87": 862027, + "marvelous-ermine-849": 862029, + "impartial-starling-953": 862029, + "grand-buzzard-220": 862036, + "adorable-parakeet-730": 862038, + "uncommon-butterfly-564": 862077, + "incredible-lobster-737": 862078, + "astute-echidna-755": 862078, + "incredible-corgi-279": 862099, + "cool-falcon-162": 862100, + "gallant-penguin-178": 862100, + "affable-labrador-354": 862112, + "sensible-loris-373": 862113, + "terrific-fox-749": 862137, + "avid-hamster-85": 862138, + "adventurous-lobster-990": 862174, + "woozy-cod-268": 862175, + "valuable-mosquito-268": 862175, + "brilliant-narwhal-717": 862192, + "notable-bear-225": 862193, + "quiet-clam-600": 862193, + "ideal-anteater-33": 862217, + "charming-wren-78": 862218, + "neat-pigeon-607": 862220, + "wry-lemming-298": 862221, + "wonderful-snail-581": 862238, + "fantastic-dodo-627": 862239, + "uncommon-iguana-60": 862270, + "fantastic-jellyfish-310": 862271, + "dapper-mouse-176": 862280, + "good-marlin-328": 862281, + "valuable-meerkat-992": 862282, + "cool-jackal-214": 862283, + "precise-kangaroo-630": 862301, + "woozy-bee-30": 862303, + "canny-civet-18": 862303, + "rapid-hawk-676": 862336, + "cheery-guineapig-407": 862337, + "third-capybara-971": 862337, + "frugal-chicken-74": 862388, + "little-grouse-233": 862389, + "adept-emu-65": 862389, + "fine-chipmunk-746": 862406, + "quirky-chicken-727": 862407, + "rosy-crocodile-306": 862439, + "impressive-clownfish-700": 862440, + "energized-bird-835": 862500, + "agreeable-fish-138": 862501, + "flippant-sardine-401": 862536, + "festive-warbler-774": 862538, + "decisive-bear-161": 862542, + "wooden-dolphin-550": 862543, + "tidy-mouse-25": 862558, + "adventurous-pheasant-738": 862559, + "unique-canary-802": 862563, + "precise-peccary-419": 862564, + "laudable-parrot-93": 862588, + "colorful-cormorant-737": 862589, + "earnest-pelican-935": 862595, + "original-moose-199": 862596, + "oceanic-vole-288": 862602, + "pleasant-goat-322": 862604, + "elegant-gazelle-595": 862613, + "neighborly-shark-428": 862614, + "basic-dragon-138": 862620, + "quiet-fish-7": 862621, + "uncommon-toucan-956": 862623, + "diligent-egret-222": 862624, + "different-weasel-129": 862627, + "beloved-narwhal-765": 862628, + "optimistic-jellyfish-74": 862628, + "curious-possum-762": 862629, + "cautious-bandicoot-713": 862630, + "decisive-whale-532": 862630, + "healthy-dragon-821": 862633, + "decisive-parrot-102": 862634, + "notable-raccoon-502": 862658, + "blissful-warbler-847": 862659, + "befitting-dragon-182": 862672, + "little-spaniel-112": 862674, + "bold-spaniel-889": 862682, + "mild-oriole-264": 862683, + "festive-hare-607": 862683, + "incredible-pheasant-6": 862685, + "perceptive-cod-409": 862686, + "reliable-opossum-368": 862710, + "dusty-kingfisher-537": 862711, + "focused-opossum-469": 862718, + "perfect-hyena-736": 862719, + "shocking-greyhound-666": 862722, + "superb-clownfish-957": 862723, + "artful-bear-875": 862737, + "trustworthy-fly-998": 862738, + "optimistic-shark-488": 862738, + "disciplined-meerkat-250": 862769, + "lovely-herring-533": 862770, + "aromatic-partridge-595": 862806, + "original-donkey-587": 862807, + "notable-basilisk-976": 862817, + "hip-axolotl-512": 862818, + "insightful-raven-220": 862839, + "dependable-guanaco-527": 862840, + "small-cricket-142": 862843, + "rightful-ibis-975": 862844, + "first-grasshopper-61": 862927, + "quaint-shrimp-874": 862930, + "animated-jay-369": 862942, + "artful-chihuahua-90": 862943, + "dependable-peacock-749": 862954, + "brainy-hound-375": 862956, + "glad-wolverine-864": 862991, + "dutiful-spoonbill-447": 862992, + "adjoining-giraffe-586": 862997, + "adamant-goldfish-189": 862998, + "strong-nightingale-872": 863053, + "impressive-goldfish-457": 863054, + "glad-basilisk-547": 863115, + "cautious-monitor-56": 863117, + "industrious-woodpecker-613": 863126, + "combative-spaniel-577": 863127, + "impartial-dodo-918": 863166, + "hearty-dove-591": 863167, + "hip-echidna-867": 863169, + "rosy-bird-786": 863171, + "knowing-stingray-244": 863172, + "dusty-dachshund-202": 863173, + "accomplished-bison-216": 863174, + "exuberant-sturgeon-123": 863213, + "elegant-kangaroo-468": 863228, + "benevolent-hippopotamus-970": 863230, + "handsome-bison-890": 863238, + "keen-ram-790": 863240, + "striped-lion-538": 863243, + "adamant-anaconda-54": 863244, + "brave-koala-851": 863245, + "utmost-heron-317": 863246, + "handsome-gnu-36": 863251, + "academic-snake-5": 863252, + "disciplined-ibex-677": 863273, + "patient-cormorant-913": 863274, + "graceful-platypus-636": 863308, + "giddy-parakeet-681": 863309, + "wooden-turtle-110": 863322, + "reminiscent-kingfisher-652": 863324, + "decisive-pelican-753": 863340, + "rapid-camel-905": 863340, + "trustworthy-buffalo-841": 863355, + "gregarious-mandrill-165": 863356, + "spotted-clam-486": 863356, + "amicable-fly-692": 863377, + "quirky-jellyfish-350": 863378, + "capable-cuttlefish-658": 863412, + "fortunate-dachshund-616": 863414, + "harmless-okapi-571": 863425, + "hushed-hummingbird-911": 863426, + "precise-cricket-126": 863427, + "perceptive-sandpiper-925": 863429, + "hip-moose-705": 863441, + "grand-husky-830": 863443, + "fearless-lemur-222": 863454, + "elegant-badger-132": 863455, + "aware-jaguar-75": 863471, + "next-dachshund-199": 863472, + "opulent-eagle-233": 863496, + "fastidious-dragon-411": 863497, + "hushed-whale-854": 863518, + "graceful-civet-703": 863519, + "terrific-kudu-760": 863519, + "fantastic-schnauzer-358": 863536, + "canny-falcon-620": 863537, + "giant-crab-705": 863576, + "rosy-loris-342": 863577, + "diligent-rat-169": 863578, + "watchful-guineapig-329": 863579, + "tremendous-hippopotamus-455": 863658, + "original-trout-686": 863658, + "kindhearted-squirrel-664": 863751, + "agreeable-lion-414": 863752, + "outgoing-ant-137": 863755, + "quick-dolphin-483": 863756, + "third-ostrich-863": 863756, + "amiable-shrimp-69": 863783, + "veracious-rat-263": 863784, + "uncommon-fennec-874": 863784, + "proficient-dragon-372": 863822, + "accurate-ferret-151": 863823, + "famous-tiger-655": 863827, + "tangible-guineapig-846": 863828, + "rosy-kudu-319": 863828, + "rare-oriole-663": 863874, + "flexible-tapir-467": 863875, + "cool-trout-844": 863875, + "mild-wolf-816": 863903, + "sincere-fish-8": 863904, + "beaming-spoonbill-332": 863907, + "small-lyrebird-74": 863908, + "fantastic-albatross-162": 863908, + "kindred-monitor-644": 863951, + "gregarious-mockingbird-371": 863952, + "tough-elephant-445": 863960, + "oceanic-aardvark-801": 863962, + "adjoining-eel-223": 863964, + "elated-lion-962": 863965, + "uncommon-marten-358": 863965, + "blissful-elk-199": 863967, + "fearless-hare-188": 863969, + "steady-weasel-971": 863969, + "energized-mink-107": 863973, + "gallant-buffalo-367": 863974, + "scintillating-snail-108": 863976, + "nautical-lark-948": 863977, + "quirky-seal-991": 863997, + "brave-civet-788": 863998, + "brazen-camel-279": 864002, + "first-raccoon-444": 864003, + "utmost-fly-646": 864024, + "affable-buffalo-730": 864027, + "hushed-camel-218": 864030, + "avid-goldfish-833": 864031, + "steady-caterpillar-305": 864073, + "quixotic-koala-726": 864074, + "patient-goose-780": 864081, + "third-gopher-5": 864082, + "earnest-parakeet-322": 864082, + "avid-alpaca-523": 864103, + "dutiful-emu-957": 864105, + "careful-monitor-89": 864109, + "abundant-gazelle-956": 864117, + "original-rabbit-847": 864118, + "original-porcupine-52": 864130, + "tidy-manatee-101": 864131, + "patient-crane-49": 864204, + "fortunate-hornet-876": 864205, + "aromatic-pig-127": 864205, + "amicable-otter-595": 864220, + "calculating-oriole-852": 864221, + "fearless-chinchilla-492": 864221, + "deafening-minnow-986": 864304, + "rosy-ermine-693": 864305, + "jovial-rat-84": 864325, + "moonlit-koala-33": 864326, + "dazzling-egret-767": 864362, + "merry-gnu-563": 864364, + "kindhearted-viper-800": 864364, + "laudable-mongoose-318": 864416, + "ceaseless-crane-354": 864417, + "astute-stoat-711": 864418, + "first-opossum-505": 864420, + "opulent-mongoose-81": 864421, + "graceful-moose-251": 864422, + "valiant-stork-531": 864423, + "exciting-snail-166": 864424, + "standing-terrier-384": 864426, + "canny-scorpion-168": 864427, + "brainy-whale-420": 864428, + "groovy-civet-192": 864429, + "adept-buffalo-330": 864441, + "rare-meerkat-100": 864442, + "amiable-vulture-463": 864467, + "kindred-dalmatian-801": 864468, + "standing-gerbil-253": 864510, + "hushed-goldfish-766": 864512, + "notable-stoat-707": 864565, + "beaming-trout-806": 864567, + "resolute-eel-413": 864567, + "fabulous-hummingbird-59": 864619, + "exuberant-cow-628": 864620, + "enchanted-salmon-680": 864622, + "loyal-lobster-162": 864623, + "neighborly-sardine-3": 864646, + "fine-bulldog-271": 864648, + "uncommon-ram-976": 864661, + "clear-swan-444": 864662, + "peaceful-lyrebird-444": 864662, + "qualified-mongoose-707": 864666, + "woozy-tortoise-869": 864667, + "rugged-mallard-768": 864681, + "quaint-squirrel-675": 864682, + "grateful-starfish-507": 864689, + "first-magpie-156": 864690, + "mild-hummingbird-344": 864761, + "terrific-ocelot-627": 864763, + "giddy-roadrunner-181": 864763, + "exuberant-gopher-578": 864822, + "ceaseless-jay-206": 864823, + "outgoing-lark-320": 864843, + "dashing-puffin-348": 864844, + "expert-bison-80": 864844, + "bright-dog-170": 864849, + "hardy-loris-499": 864850, + "sincere-ox-301": 864929, + "proficient-raccoon-233": 864931, + "third-hornet-931": 864944, + "bright-shark-758": 864945, + "animated-gopher-644": 864952, + "robust-platypus-39": 864953, + "standing-koala-369": 864990, + "woozy-elk-441": 864991, + "grandiose-wombat-763": 864999, + "greedy-narwhal-847": 865000, + "tidy-falcon-706": 865035, + "tremendous-elephant-621": 865037, + "exciting-koala-811": 865049, + "impartial-mosquito-667": 865050, + "tame-snail-863": 865193, + "wary-butterfly-457": 865194, + "frugal-tiger-802": 865215, + "clever-lion-268": 865217, + "descriptive-crow-446": 865230, + "tidy-canary-742": 865231, + "successful-narwhal-681": 865258, + "flippant-eel-316": 865259, + "third-penguin-340": 865307, + "groovy-toucan-266": 865308, + "determined-kookabura-765": 865308, + "expert-jellyfish-280": 865309, + "cheerful-ibex-673": 865310, + "determined-toad-750": 865348, + "insightful-corgi-650": 865349, + "amicable-possum-699": 865496, + "grateful-warthog-719": 865497, + "hushed-setter-796": 865497, + "giddy-chickadee-414": 865508, + "aware-penguin-331": 865509, + "affable-cobra-767": 865509, + "benevolent-raccoon-591": 865552, + "exuberant-spoonbill-333": 865553, + "hip-toad-483": 865553, + "wooden-woodpecker-362": 865555, + "greedy-weasel-216": 865556, + "little-chameleon-156": 865556, + "dapper-warthog-167": 865578, + "rosy-puffin-192": 865579, + "calculating-crocodile-381": 865584, + "small-guineapig-223": 865585, + "precise-eel-494": 865618, + "adept-parakeet-36": 865619, + "dapper-elephant-557": 865820, + "rosy-vulture-140": 865821, + "earnest-whale-647": 865902, + "polite-dodo-677": 865911, + "ardent-robin-678": 865912, + "gregarious-mandrill-653": 865931, + "hushed-iguana-215": 865932, + "scrupulous-wombat-168": 865932, + "adamant-bandicoot-976": 866008, + "proficient-mallard-30": 866009, + "dazzling-finch-479": 866091, + "merry-anaconda-816": 866092, + "clean-donkey-928": 866103, + "tame-fox-739": 866104, + "festive-kiwi-472": 866107, + "glorious-axolotl-416": 866108, + "adamant-pig-735": 866120, + "proficient-beagle-993": 866121, + "intent-heron-155": 866161, + "colorless-frog-515": 866162, + "fine-lemur-834": 866162, + "rapid-eagle-884": 866221, + "rugged-minnow-602": 866222, + "doting-swan-275": 866354, + "kindly-scorpion-463": 866355, + "cool-jay-272": 866447, + "disciplined-guineapig-295": 866448, + "incredible-hornet-161": 866453, + "expert-impala-358": 866454, + "fabulous-sheep-784": 866505, + "capable-butterfly-886": 866506, + "wandering-gnat-442": 866525, + "little-cardinal-888": 866527, + "brazen-jaguar-848": 866528, + "kindred-snail-623": 866529, + "elegant-sardine-890": 866580, + "small-crow-862": 866581, + "abundant-pig-189": 866632, + "perceptive-barracuda-261": 866633, + "harmless-marmot-426": 866677, + "resilient-dodo-636": 866678, + "polished-woodpecker-912": 866689, + "fearless-clownfish-488": 866690, + "clear-pika-613": 866691, + "savory-buzzard-669": 866693, + "valuable-hedgehog-453": 866767, + "bold-zebra-340": 866768, + "dashing-mallard-279": 866774, + "adept-meadowlark-657": 866775, + "lovable-warthog-293": 866813, + "rare-lemur-818": 866815, + "silent-clownfish-611": 866831, + "brilliant-rat-810": 866832, + "valiant-mallard-140": 866868, + "avid-mandrill-760": 866869, + "uncommon-pika-997": 866873, + "handsome-marmot-305": 866874, + "hushed-chickadee-914": 866905, + "astute-coyote-712": 866906, + "grand-gopher-177": 866906, + "striped-mastiff-72": 866910, + "robust-dolphin-677": 866911, + "scrupulous-weasel-486": 866929, + "benevolent-dinosaur-779": 866931, + "polite-viper-242": 866942, + "exuberant-sturgeon-361": 866943, + "moonlit-penguin-495": 866971, + "notable-ptarmigan-745": 866972, + "sincere-partridge-989": 867021, + "quiet-mockingbird-819": 867022, + "graceful-firefly-794": 867032, + "agreeable-weasel-927": 867033, + "rosy-orca-229": 867062, + "wry-guanaco-753": 867063, + "ideal-dinosaur-123": 867101, + "mellow-emu-67": 867102, + "blissful-wildcat-914": 867158, + "whimsical-mandrill-157": 867159, + "rare-horse-158": 867174, + "third-dalmatian-30": 867175, + "descriptive-tern-449": 867217, + "moonlit-mallard-246": 867218, + "quixotic-giraffe-796": 867220, + "diligent-dog-851": 867221, + "notable-manatee-212": 867255, + "rapid-elk-496": 867256, + "clever-parakeet-122": 867272, + "deafening-vole-957": 867273, + "terrific-marten-780": 867281, + "impartial-meerkat-396": 867282, + "outgoing-bass-236": 867282, + "hardy-clam-95": 867294, + "admired-dolphin-372": 867295, + "grandiose-lyrebird-620": 867295, + "colorful-leopard-490": 867298, + "outgoing-spaniel-958": 867299, + "deafening-ferret-933": 867304, + "giddy-armadillo-76": 867305, + "confident-albatross-836": 867311, + "fleet-pelican-219": 867313, + "outgoing-husky-499": 867313, + "sincere-fly-488": 867335, + "quaint-squirrel-120": 867336, + "veracious-pika-495": 867337, + "beaming-chihuahua-0": 867343, + "dynamic-mockingbird-651": 867344, + "descriptive-giraffe-844": 867350, + "dutiful-sardine-458": 867351, + "outgoing-caribou-22": 867377, + "greedy-aardvark-678": 867378, + "acoustic-ladybug-735": 867383, + "incredible-rhinoceros-202": 867384, + "posh-chameleon-945": 867384, + "harmless-otter-730": 867451, + "woozy-turtle-617": 867452, + "doting-caterpillar-770": 867452, + "nautical-wolf-520": 867475, + "savory-panda-585": 867476, + "sincere-grasshopper-926": 867490, + "cautious-jackal-907": 867491, + "flippant-bat-707": 867526, + "scrupulous-yak-691": 867527, + "ardent-sandpiper-417": 867601, + "striped-cod-816": 867602, + "aromatic-goshawk-462": 867626, + "clear-mastiff-755": 867627, + "deafening-ibis-698": 867646, + "graceful-barracuda-480": 867647, + "frugal-alligator-115": 867650, + "fearless-duck-157": 867651, + "upbeat-rooster-649": 867688, + "lovely-nightingale-179": 867689, + "adamant-robin-304": 867703, + "elegant-husky-695": 867704, + "enchanted-dotterel-164": 867742, + "fabulous-alligator-427": 867744, + "rugged-starling-236": 867761, + "honorable-marlin-92": 867762, + "whimsical-sheep-45": 867783, + "groovy-mammoth-415": 867785, + "strong-dragon-403": 867830, + "qualified-perch-764": 867831, + "chatty-gazelle-80": 867837, + "sensible-spoonbill-256": 867838, + "patient-heron-903": 867904, + "intent-labrador-404": 867905, + "adventurous-shepherd-471": 867967, + "glorious-kangaroo-930": 867968, + "befitting-oyster-294": 868010, + "adamant-chicken-477": 868011, + "dapper-iguana-860": 868024, + "affable-monitor-146": 868025, + "quixotic-grouse-546": 868046, + "tough-alligator-651": 868047, + "kindred-condor-360": 868101, + "wooden-scorpion-784": 868103, + "fastidious-lobster-692": 868103, + "bold-labrador-689": 868137, + "enchanted-mallard-864": 868138, + "nautical-hare-536": 868143, + "resilient-stingray-478": 868144, + "colorful-possum-514": 868257, + "intent-okapi-923": 868258, + "colorless-marlin-161": 868312, + "neat-trout-774": 868313, + "ideal-caterpillar-479": 868394, + "fleet-viper-853": 868395, + "hearty-wolverine-674": 868400, + "fantastic-pig-829": 868401, + "astute-axolotl-911": 868409, + "perfect-hummingbird-936": 868410, + "savory-badger-326": 868420, + "exciting-orca-194": 868421, + "aromatic-mule-757": 868421, + "useful-clam-714": 868512, + "shiny-oriole-287": 868513, + "little-octopus-558": 868522, + "peaceful-squirrel-516": 868523, + "posh-dotterel-430": 868537, + "patient-warthog-585": 868538, + "exciting-vole-854": 868568, + "quixotic-partridge-147": 868568, + "scintillating-panda-72": 868571, + "fearless-wildebeest-687": 868616, + "hearty-goldfish-998": 868617, + "valiant-mosquito-250": 868617, + "tough-rook-601": 868640, + "glad-owl-239": 868641, + "scintillating-goat-444": 868641, + "expert-eagle-978": 868664, + "neighborly-cat-855": 868665, + "kindly-gnat-141": 868665, + "impartial-retriever-614": 868675, + "affable-marten-524": 868676, + "trustworthy-panda-520": 868762, + "agile-crocodile-268": 868763, + "expert-guineapig-337": 868809, + "little-kudu-881": 868812, + "calm-porcupine-466": 868873, + "sincere-bear-72": 868874, + "accurate-manatee-469": 868922, + "energetic-dachshund-462": 868923, + "lovely-chicken-707": 868925, + "precise-axolotl-439": 868926, + "animated-wolverine-606": 868930, + "tough-duck-291": 868931, + "adorable-crane-599": 868977, + "shiny-ibis-601": 868978, + "sleek-rooster-495": 869024, + "hallowed-marmot-272": 869025, + "tremendous-octopus-779": 869054, + "uncommon-ladybug-607": 869055, + "adjoining-chameleon-119": 869140, + "kindly-marten-437": 869141, + "expert-marten-189": 869141, + "hushed-goshawk-577": 869148, + "judicious-clownfish-918": 869149, + "youthful-cobra-487": 869149, + "curious-raven-567": 869194, + "quick-gull-269": 869195, + "marvelous-hummingbird-431": 869196, + "quiet-dalmatian-755": 869197, + "adjoining-elephant-402": 869209, + "abundant-robin-72": 869210, + "kindhearted-aardvark-570": 869267, + "fabulous-bass-225": 869268, + "next-fly-320": 869314, + "unique-rat-960": 869316, + "hip-spoonbill-120": 869316, + "neighborly-caterpillar-486": 869317, + "outgoing-swordfish-628": 869318, + "third-lion-556": 869319, + "charming-badger-310": 869320, + "reliable-starling-313": 869320, + "efficient-leopard-27": 869440, + "neat-raccoon-230": 869441, + "striped-cassowary-354": 869560, + "gregarious-sandpiper-351": 869561, + "striped-vole-252": 869574, + "quixotic-mandrill-331": 869575, + "agile-parakeet-577": 869754, + "zealous-spaniel-461": 869755, + "pastel-duck-290": 869811, + "quiet-mule-833": 869812, + "scintillating-squirrel-599": 869815, + "oceanic-squirrel-35": 869817, + "tremendous-mastiff-711": 869838, + "limitless-fennec-252": 869839, + "rare-ox-146": 869847, + "courteous-meadowlark-484": 869848, + "academic-gnat-144": 869852, + "valuable-snake-231": 869853, + "youthful-cassowary-750": 869853, + "insightful-chameleon-169": 869887, + "proper-bison-53": 869888, + "quaint-sardine-335": 869889, + "hip-civet-542": 869890, + "mild-marlin-2": 869925, + "flippant-moose-265": 869926, + "valuable-caiman-358": 870043, + "next-okapi-266": 870044, + "brazen-sheep-134": 870045, + "accurate-woodpecker-60": 870046, + "moonlit-mongoose-797": 870108, + "fantastic-salamander-872": 870109, + "enchanted-axolotl-822": 870451, + "agreeable-zebra-526": 870452, + "expert-fennec-924": 870456, + "brilliant-grouse-958": 870457, + "acrobatic-husky-253": 870458, + "quiet-lion-185": 870459, + "optimistic-reindeer-937": 870460, + "whimsical-porpoise-866": 870461, + "tangible-squid-206": 870513, + "limitless-puma-313": 870514, + "content-civet-602": 870524, + "accurate-barracuda-847": 870525, + "bright-jackal-102": 870557, + "rosy-ibis-728": 870558, + "formal-spaniel-723": 870560, + "frugal-marmot-654": 870561, + "wooden-otter-377": 870561, + "brazen-rhinoceros-851": 870570, + "charming-cat-861": 870571, + "different-caribou-811": 870632, + "patient-lion-444": 870633, + "descriptive-mandrill-686": 870691, + "compassionate-akita-364": 870692, + "mild-pig-748": 870692, + "brazen-dinosaur-630": 870706, + "neat-curlew-489": 870707, + "unique-pelican-545": 870708, + "scrupulous-roadrunner-598": 870710, + "successful-gecko-26": 870745, + "strong-gnat-754": 870746, + "precious-marten-782": 870766, + "formal-rabbit-704": 870767, + "flexible-canary-224": 870767, + "necessary-herring-690": 870773, + "savory-yak-930": 870774, + "rapid-newt-107": 870775, + "artful-stoat-303": 870776, + "rapid-peccary-523": 870787, + "academic-guineapig-805": 870788, + "mellow-narwhal-240": 870789, + "outgoing-cuttlefish-732": 870790, + "flexible-poodle-492": 870790, + "kindly-aardvark-431": 870814, + "befitting-swan-816": 870815, + "descriptive-pika-380": 870823, + "capable-sockeye-181": 870825, + "cheery-lark-806": 870825, + "gallant-tapir-348": 870832, + "graceful-puma-976": 870833, + "bright-curlew-261": 870862, + "confident-bass-955": 870864, + "vibrant-deer-803": 870864, + "tough-kangaroo-149": 870882, + "gregarious-otter-871": 870883, + "careful-nightingale-652": 870895, + "clean-puma-589": 870896, + "steady-mandrill-123": 870904, + "shiny-quail-999": 870905, + "acrobatic-okapi-686": 870998, + "successful-goose-451": 870999, + "efficient-vulture-289": 871031, + "dusty-kangaroo-485": 871032, + "utmost-seal-285": 871035, + "tremendous-dodo-205": 871036, + "bold-ant-614": 871057, + "utmost-guanaco-349": 871059, + "valiant-chipmunk-971": 871059, + "kindred-starfish-601": 871115, + "small-herring-865": 871116, + "majestic-stork-175": 871130, + "quiet-mouse-14": 871131, + "scintillating-alpaca-734": 871131, + "curious-sturgeon-6": 871194, + "different-jellyfish-248": 871195, + "accomplished-ptarmigan-2": 871211, + "posh-cormorant-55": 871213, + "kindred-vulture-628": 871215, + "wry-kookabura-34": 871217, + "next-chickadee-262": 871217, + "admired-pelican-786": 871235, + "vivid-camel-699": 871236, + "fast-octopus-207": 871252, + "quick-oyster-348": 871253, + "intent-jackal-585": 871404, + "bright-jackal-84": 871405, + "glorious-rhinoceros-140": 871435, + "healthy-roadrunner-455": 871436, + "perceptive-crow-575": 871466, + "hardy-mammoth-241": 871468, + "unique-toad-649": 871476, + "notable-shark-485": 871477, + "fiery-parakeet-855": 871483, + "abundant-ibis-281": 871484, + "grateful-hare-726": 871497, + "focused-dalmatian-214": 871498, + "dazzling-buffalo-199": 871498, + "helpful-gecko-440": 871510, + "fine-koala-956": 871511, + "flippant-woodpecker-645": 871519, + "successful-caterpillar-122": 871520, + "compassionate-setter-325": 871544, + "loyal-clownfish-554": 871545, + "elated-spoonbill-23": 871545, + "charming-crane-836": 871607, + "tremendous-minnow-265": 871609, + "hidden-ostrich-249": 871648, + "kindred-lion-766": 871649, + "abundant-squid-708": 871710, + "aromatic-flamingo-588": 871711, + "descriptive-yak-779": 871711, + "descriptive-chipmunk-529": 871714, + "valiant-avocet-248": 871715, + "tacit-gazelle-293": 871715, + "adorable-spaniel-435": 871748, + "adamant-shark-966": 871749, + "cheerful-jaguar-24": 871790, + "coordinated-sardine-50": 871791, + "silent-skunk-466": 871791, + "greedy-bullfrog-392": 871797, + "honorable-alligator-52": 871798, + "zany-shark-750": 871798, + "original-retriever-618": 871816, + "lovable-rat-49": 871817, + "blissful-basilisk-589": 871907, + "oceanic-aardvark-479": 871908, + "handsome-mouse-678": 871908, + "outgoing-antelope-901": 871954, + "terrific-shark-615": 871955, + "insightful-puma-208": 871998, + "tangible-squid-455": 871999, + "giddy-axolotl-710": 871999, + "blissful-wombat-650": 872005, + "utmost-ladybug-603": 872006, + "decisive-mammoth-5": 872092, + "careful-dachshund-380": 872093, + "wry-armadillo-775": 872127, + "coordinated-rat-471": 872128, + "fantastic-pelican-519": 872215, + "intent-alligator-185": 872216, + "watchful-weasel-107": 872224, + "original-wren-977": 872225, + "zealous-cobra-900": 872230, + "fortunate-eagle-756": 872231, + "pleasant-duck-441": 872238, + "tremendous-walrus-871": 872239, + "lovable-clownfish-325": 872271, + "animated-anteater-642": 872272, + "healthy-crocodile-544": 872272, + "tough-penguin-274": 872290, + "ceaseless-oyster-942": 872292, + "adjoining-donkey-278": 872292, + "loyal-jackal-333": 872299, + "festive-dolphin-719": 872300, + "cautious-impala-780": 872362, + "adjoining-ocelot-259": 872363, + "fine-cricket-134": 872365, + "glad-gull-634": 872367, + "sleek-jay-617": 872367, + "clean-marlin-866": 872397, + "shocking-cricket-147": 872399, + "fiery-cobra-10": 872447, + "famous-aardvark-756": 872449, + "brazen-iguana-474": 872454, + "admired-bird-23": 872455, + "wandering-cheetah-980": 872466, + "oceanic-jackal-969": 872467, + "handsome-condor-393": 872516, + "fastidious-toad-733": 872518, + "avid-shrimp-4": 872572, + "cheerful-pigeon-660": 872573, + "artful-warbler-749": 872626, + "resolute-duck-896": 872627, + "adjoining-sandpiper-975": 872699, + "effervescent-swan-6": 872700, + "outgoing-pika-919": 872700, + "utmost-anaconda-520": 872819, + "original-eel-634": 872820, + "formal-ibex-895": 872892, + "canny-caribou-664": 872893, + "friendly-dog-351": 872893, + "blissful-chihuahua-88": 872918, + "curious-spider-944": 872919, + "precise-elk-117": 872919, + "colorless-avocet-733": 872944, + "youthful-kiwi-424": 872946, + "dusty-kangaroo-808": 872957, + "adventurous-avocet-882": 872958, + "tremendous-schnauzer-765": 872978, + "secret-spaniel-548": 872979, + "precious-bobcat-915": 872979, + "hushed-kookabura-551": 873067, + "opulent-turtle-694": 873068, + "first-skunk-280": 873069, + "clear-hyena-362": 873070, + "wary-guanaco-904": 873070, + "friendly-bobcat-989": 873196, + "rosy-hummingbird-149": 873197, + "bright-falcon-655": 873197, + "flippant-lynx-867": 873222, + "peaceful-egret-79": 873223, + "descriptive-bison-585": 873223, + "beloved-raccoon-593": 873263, + "giant-impala-976": 873264, + "confident-gopher-870": 873273, + "judicious-dinosaur-660": 873274, + "pastel-beagle-671": 873299, + "quick-minnow-966": 873300, + "focused-chicken-341": 873300, + "amiable-gopher-16": 873374, + "gregarious-guanaco-858": 873375, + "little-jaguar-904": 873378, + "valiant-robin-369": 873379, + "mellow-marlin-28": 873432, + "opulent-boar-388": 873434, + "beloved-cricket-469": 873447, + "joyous-kookabura-886": 873448, + "judicious-llama-218": 873459, + "coordinated-gerbil-912": 873460, + "industrious-snake-608": 873598, + "valuable-wombat-575": 873599, + "usable-cormorant-822": 873621, + "ardent-shrimp-660": 873622, + "hearty-coyote-144": 873672, + "courteous-pheasant-526": 873673, + "rightful-okapi-270": 873714, + "exuberant-barracuda-551": 873715, + "incredible-lyrebird-175": 873715, + "pastel-cod-187": 873753, + "blissful-swordfish-165": 873754, + "combative-avocet-700": 873784, + "artful-rooster-266": 873786, + "quirky-husky-107": 873786, + "proficient-buzzard-993": 873794, + "impressive-mastiff-157": 873795, + "deafening-crab-28": 873801, + "pleasant-scorpion-636": 873802, + "hardy-bullfrog-631": 873853, + "vivid-pony-999": 873855, + "rosy-swordfish-792": 873889, + "resilient-eel-896": 873890, + "qualified-giraffe-944": 873890, + "savory-horse-904": 873893, + "giant-octopus-631": 873894, + "affable-swordfish-245": 873938, + "laudable-bobcat-5": 873940, + "aromatic-lynx-12": 873941, + "outstanding-chinchilla-350": 873942, + "zealous-dinosaur-843": 873947, + "reminiscent-alpaca-527": 873949, + "notable-bulldog-469": 873949, + "little-pony-635": 873969, + "warmhearted-grasshopper-674": 873970, + "opulent-corgi-435": 873970, + "agreeable-snake-662": 873978, + "flippant-pig-354": 873980, + "fleet-rabbit-260": 873985, + "perfect-puma-854": 873987, + "dynamic-camel-458": 874014, + "fast-stork-562": 874015, + "grateful-anaconda-486": 874072, + "polite-sparrow-224": 874075, + "tough-ibis-906": 874081, + "chatty-alligator-488": 874082, + "watchful-mockingbird-631": 874082, + "fantastic-gerbil-705": 874086, + "glorious-snail-592": 874087, + "exciting-hamster-29": 874106, + "jovial-elk-646": 874107, + "zealous-hyena-853": 874107, + "valiant-ibex-626": 874135, + "usable-salamander-889": 874136, + "quick-ladybug-54": 874151, + "quaint-goat-970": 874152, + "polished-perch-792": 874179, + "confident-grasshopper-974": 874180, + "vivid-mastiff-59": 874205, + "handsome-fennec-936": 874207, + "dependable-wolverine-765": 874207, + "tacit-yak-113": 874234, + "zany-stoat-726": 874235, + "aromatic-emu-337": 874245, + "good-aardvark-230": 874246, + "aromatic-peccary-690": 874328, + "brainy-mongoose-61": 874329, + "rare-turtle-536": 874330, + "opulent-mastiff-981": 874331, + "earnest-lemur-679": 874422, + "adamant-lynx-288": 874423, + "descriptive-clam-118": 874444, + "astute-puffin-886": 874445, + "kindred-spoonbill-831": 874445, + "savory-panther-332": 874505, + "acoustic-kangaroo-652": 874508, + "secret-hamster-539": 874571, + "groovy-ram-770": 874572, + "glad-partridge-790": 874572, + "colorless-grasshopper-616": 874621, + "accomplished-leopard-895": 874622, + "posh-lyrebird-498": 874629, + "astute-blackbird-490": 874630, + "tame-wildebeest-332": 874630, + "fabulous-deer-843": 874665, + "content-shrimp-34": 874666, + "standing-sturgeon-83": 874672, + "tremendous-seal-643": 874673, + "elated-grasshopper-387": 874695, + "modest-fox-330": 874696, + "friendly-hedgehog-632": 874849, + "calm-minnow-563": 874850, + "fastidious-mouse-724": 874931, + "tangible-elk-533": 874932, + "quaint-hound-288": 875025, + "giant-kingfisher-594": 875026, + "opulent-bobcat-356": 875134, + "little-lynx-826": 875135, + "tame-camel-592": 875336, + "woozy-pika-605": 875338, + "posh-kudu-13": 875378, + "determined-shark-183": 875379, + "polite-hedgehog-927": 875379, + "quick-wolverine-103": 875381, + "quirky-kingfisher-286": 875382, + "wandering-walrus-228": 875441, + "prestigious-cod-802": 875442, + "marvelous-cormorant-538": 875446, + "precise-moose-811": 875447, + "exciting-falcon-827": 875773, + "zealous-horse-75": 875773, + "lovable-egret-780": 875951, + "wry-kiwi-892": 875953, + "tremendous-cheetah-893": 875954, + "optimistic-walrus-574": 875955, + "perceptive-hippopotamus-187": 875957, + "fantastic-pigeon-550": 875959, + "hearty-wildcat-299": 875961, + "secret-narwhal-713": 875962, + "stoic-rook-428": 875976, + "avid-manatee-89": 875977, + "accomplished-buffalo-491": 875991, + "whimsical-ox-563": 875992, + "notable-dinosaur-410": 876007, + "unique-snail-42": 876008, + "adorable-possum-475": 876008, + "aware-cormorant-55": 876010, + "secret-echidna-810": 876011, + "small-dog-275": 876011, + "abundant-starfish-834": 876012, + "accurate-marten-914": 876013, + "basic-fish-36": 876014, + "capable-starfish-609": 876015, + "majestic-chicken-896": 876015, + "curious-aardvark-777": 876066, + "hip-cormorant-991": 876067, + "dapper-camel-771": 876086, + "astute-pelican-774": 876087, + "elated-snail-4": 876094, + "hidden-magpie-813": 876095, + "agreeable-leopard-904": 876102, + "small-ptarmigan-665": 876103, + "amiable-lemur-778": 876112, + "warmhearted-quail-273": 876113, + "peaceful-echidna-329": 876114, + "exciting-setter-927": 876116, + "intent-eagle-940": 876118, + "admired-goldfish-747": 876119, + "proper-labrador-315": 876125, + "frugal-leopard-484": 876126, + "intent-lion-199": 876135, + "expert-donkey-899": 876136, + "resolute-trout-94": 876136, + "flippant-bear-920": 876142, + "adventurous-marten-762": 876143, + "accurate-tiger-999": 876143, + "befitting-cardinal-293": 876176, + "efficient-warbler-866": 876177, + "valuable-peacock-470": 876210, + "rightful-loris-538": 876211, + "tangible-gnat-773": 876223, + "sincere-hound-123": 876224, + "woozy-chicken-613": 876368, + "colorless-caterpillar-828": 876378, + "kindhearted-tortoise-467": 876378, + "glorious-octopus-50": 876462, + "fantastic-setter-423": 876463, + "neat-deer-948": 876473, + "bold-kookabura-274": 876474, + "animated-civet-172": 876478, + "friendly-chameleon-349": 876479, + "kindly-crane-693": 876574, + "impressive-kookabura-769": 876575, + "frugal-squirrel-628": 876582, + "aromatic-goldfinch-210": 876583, + "valiant-duck-129": 876614, + "coordinated-stork-965": 876615, + "blessed-nightingale-759": 876659, + "mild-mouse-533": 876660, + "agreeable-cat-464": 876661, + "steady-wren-813": 876662, + "tame-toucan-407": 876663, + "stoic-bulldog-638": 876664, + "astute-kiwi-335": 876666, + "gregarious-oriole-275": 876670, + "aware-greyhound-166": 876671, + "dashing-eel-418": 876715, + "beloved-turtle-612": 876716, + "astute-cow-204": 876719, + "academic-gerbil-319": 876720, + "resolute-cricket-210": 876720, + "dashing-stingray-34": 876733, + "peaceful-caribou-467": 876734, + "glorious-lapwing-805": 876742, + "zany-cricket-691": 876743, + "zealous-porcupine-880": 876743, + "cheerful-bloodhound-629": 876801, + "insightful-cricket-593": 876803, + "superb-mallard-752": 876844, + "giddy-labrador-217": 876845, + "giddy-ibis-903": 876845, + "secret-wolf-892": 876859, + "superb-mastiff-554": 876860, + "lovely-parrot-433": 876917, + "little-quail-190": 876918, + "valiant-kangaroo-768": 876929, + "strong-gerbil-759": 876951, + "abundant-kingfisher-779": 876952, + "laudable-fly-178": 876976, + "silent-kangaroo-612": 876977, + "lovable-gerbil-615": 876985, + "expert-trout-21": 876986, + "dutiful-porpoise-988": 877041, + "perceptive-bullfrog-671": 877042, + "courteous-trout-603": 877045, + "pleasant-orca-836": 877046, + "calculating-toucan-619": 877059, + "courteous-sardine-513": 877060, + "sensible-mammoth-204": 877063, + "friendly-cormorant-38": 877064, + "oceanic-grasshopper-289": 877113, + "tacit-dove-974": 877114, + "oceanic-llama-89": 877152, + "dashing-vulture-159": 877154, + "energized-gnu-333": 877164, + "basic-hyena-943": 877165, + "enchanted-oyster-214": 877188, + "doting-kookabura-297": 877189, + "coordinated-husky-893": 877193, + "joyous-lemming-544": 877194, + "different-condor-402": 877197, + "effervescent-civet-90": 877198, + "flexible-sandpiper-46": 877202, + "cautious-puffin-926": 877202, + "rapid-guineapig-766": 877219, + "nautical-goshawk-234": 877221, + "energized-salmon-168": 877256, + "warmhearted-stoat-589": 877257, + "combative-elephant-129": 877261, + "scintillating-caterpillar-386": 877262, + "impartial-flamingo-757": 877267, + "calm-swan-383": 877268, + "oceanic-pig-706": 877291, + "fast-echidna-117": 877292, + "upbeat-crow-862": 877407, + "original-mongoose-60": 877408, + "woozy-alligator-934": 877423, + "flexible-bloodhound-441": 877424, + "healthy-rabbit-838": 877424, + "resolute-crow-866": 877554, + "polished-seal-767": 877555, + "tough-bear-530": 877610, + "befitting-chipmunk-424": 877611, + "enduring-albatross-676": 877611, + "flexible-snake-918": 877713, + "knowing-wren-153": 877714, + "combative-anaconda-798": 877729, + "quiet-tortoise-228": 877731, + "disciplined-dragon-821": 877769, + "harmless-goat-400": 877771, + "jovial-echidna-148": 877771, + "avid-warthog-458": 877785, + "expert-orca-121": 877786, + "moonlit-starfish-418": 877791, + "dependable-chameleon-500": 877792, + "enduring-goat-944": 877803, + "harmless-okapi-965": 877804, + "pastel-possum-442": 877804, + "wandering-lemur-575": 877885, + "greedy-ferret-75": 877886, + "shocking-octopus-116": 877904, + "prestigious-lemur-112": 877905, + "affable-hummingbird-619": 877975, + "chatty-echidna-466": 877976, + "tidy-wombat-512": 877977, + "fleet-octopus-738": 877978, + "rare-mule-783": 878064, + "scintillating-frog-723": 878065, + "artful-bear-899": 878084, + "abundant-lemur-540": 878085, + "pleasant-bee-416": 878116, + "flexible-dove-521": 878117, + "wary-setter-730": 878141, + "rightful-heron-379": 878142, + "hardy-buzzard-187": 878143, + "third-rhinoceros-405": 878145, + "prestigious-porpoise-1": 878341, + "youthful-wolf-441": 878345, + "calm-dodo-783": 878348, + "beloved-caterpillar-880": 878350, + "cool-alpaca-523": 878386, + "glad-gopher-255": 878388, + "astute-goat-38": 878391, + "dashing-frog-518": 878392, + "bold-labrador-320": 878494, + "clever-ladybug-178": 878881, + "silent-rabbit-754": 878882, + "unique-camel-218": 878892, + "colorful-rooster-729": 878893, + "gallant-vulture-243": 878898, + "dutiful-ostrich-120": 878899, + "cautious-ibex-639": 878901, + "wary-porpoise-46": 878902, + "little-curlew-981": 878908, + "rightful-pelican-548": 878909, + "diligent-puma-491": 878910, + "mild-mole-513": 878913, + "chatty-mule-888": 878924, + "fabulous-mongoose-25": 878925, + "quick-squirrel-984": 878936, + "striped-oriole-623": 878938, + "valuable-falcon-709": 878939, + "fine-falcon-942": 878942, + "limitless-chipmunk-324": 878945, + "effervescent-horse-454": 878946, + "ardent-cod-686": 878948, + "terrific-lobster-657": 878951, + "agreeable-stork-634": 878952, + "stoic-fennec-941": 878955, + "upbeat-bat-787": 878956, + "scrupulous-zebra-204": 878988, + "tacit-fennec-663": 878990, + "academic-emu-529": 878990, + "fine-whale-894": 878998, + "lovely-bandicoot-300": 879000, + "sleek-magpie-982": 879059, + "fine-ferret-683": 879060, + "accurate-puffin-493": 879088, + "content-chameleon-76": 879090, + "fleet-gecko-212": 879116, + "wandering-goldfinch-989": 879117, + "rosy-pika-646": 879151, + "elegant-stoat-881": 879152, + "tame-dotterel-789": 879169, + "brilliant-cobra-232": 879170, + "impartial-dragon-857": 879249, + "rosy-tortoise-805": 879250, + "charming-pony-402": 879286, + "tremendous-sturgeon-144": 879289, + "kindly-panther-736": 879289, + "optimistic-dolphin-363": 879335, + "pastel-mockingbird-699": 879336, + "blessed-robin-118": 879337, + "lovely-schnauzer-320": 879340, + "first-crab-623": 879340, + "determined-cobra-208": 879347, + "hallowed-poodle-678": 879348, + "agreeable-caribou-293": 879418, + "healthy-okapi-316": 879419, + "compassionate-minnow-439": 879422, + "fearless-alligator-586": 879423, + "cheery-toad-603": 879439, + "impartial-impala-446": 879440, + "dynamic-pony-112": 879511, + "shocking-bandicoot-920": 879513, + "quiet-skunk-938": 879514, + "adjoining-mastiff-116": 879553, + "adorable-anaconda-437": 879554, + "resolute-lynx-99": 879613, + "efficient-schnauzer-956": 879614, + "optimistic-gnu-503": 879614, + "cautious-kingfisher-615": 879623, + "agile-bulldog-105": 879624, + "outgoing-armadillo-293": 879624, + "hidden-roadrunner-786": 879632, + "grand-rooster-939": 879633, + "mild-llama-199": 879666, + "cautious-mockingbird-344": 879667, + "original-gecko-614": 879680, + "dutiful-nightingale-457": 879681, + "glad-shepherd-874": 879692, + "giant-wolf-3": 879693, + "formal-lynx-615": 879736, + "frugal-stingray-528": 879737, + "hearty-dragon-766": 879741, + "notable-bird-267": 879744, + "dapper-seal-579": 879748, + "greedy-herring-455": 879749, + "quirky-cat-870": 879754, + "dapper-robin-113": 879755, + "zealous-buzzard-409": 879768, + "dynamic-labrador-461": 879769, + "enduring-whale-409": 879809, + "first-marmot-960": 879810, + "modest-schnauzer-79": 879810, + "qualified-whale-973": 879873, + "steady-starfish-539": 879874, + "affable-terrier-644": 879891, + "honorable-eel-441": 879892, + "tame-lemming-558": 879911, + "shocking-jackal-54": 879912, + "valuable-akita-101": 879963, + "canny-octopus-713": 879964, + "determined-mockingbird-758": 879965, + "aware-stingray-529": 879966, + "first-elephant-83": 880020, + "content-mammoth-6": 880021, + "intent-canary-425": 880044, + "agile-mule-577": 880045, + "earnest-octopus-283": 880045, + "spotted-pigeon-730": 880047, + "moonlit-grouse-500": 880048, + "acoustic-gecko-690": 880073, + "wooden-starfish-414": 880074, + "helpful-stork-115": 880098, + "upbeat-guineapig-735": 880099, + "prestigious-hound-467": 880103, + "acrobatic-pigeon-12": 880104, + "prestigious-wolverine-609": 880139, + "majestic-terrier-341": 880140, + "clever-puffin-209": 880155, + "content-poodle-285": 880156, + "zany-salamander-442": 880168, + "deafening-setter-426": 880169, + "harmless-skunk-189": 880170, + "intent-bee-734": 880171, + "adventurous-bobcat-871": 880198, + "determined-bass-26": 880199, + "wary-rhinoceros-705": 880204, + "proper-anaconda-254": 880205, + "energized-ladybug-985": 880209, + "tangible-quail-415": 880210, + "peaceful-loris-795": 880228, + "fleet-snail-510": 880229, + "rightful-koala-199": 880232, + "pastel-herring-376": 880246, + "neat-wren-998": 880247, + "rightful-ox-216": 880247, + "hearty-stoat-563": 880250, + "energized-akita-623": 880251, + "necessary-goose-659": 880257, + "artful-seal-778": 880258, + "acoustic-wolverine-598": 880271, + "standing-spoonbill-72": 880272, + "healthy-lobster-735": 880277, + "kindred-reindeer-612": 880279, + "perfect-alligator-347": 880305, + "dazzling-skunk-395": 880306, + "aware-chicken-871": 880323, + "acoustic-swordfish-840": 880324, + "aromatic-squid-441": 880358, + "vivid-cow-835": 880360, + "shiny-fox-379": 880364, + "proper-pheasant-973": 880365, + "accomplished-dolphin-959": 880386, + "glad-crab-658": 880387, + "prestigious-boar-417": 880391, + "glorious-mink-555": 880392, + "woozy-avocet-295": 880422, + "watchful-hare-225": 880423, + "ardent-fly-430": 880428, + "limitless-puffin-260": 880429, + "zany-ermine-436": 880429, + "gallant-kudu-93": 880431, + "savory-giraffe-295": 880432, + "fastidious-oyster-722": 880443, + "glad-eagle-422": 880444, + "merry-okapi-920": 880460, + "animated-heron-361": 880461, + "wooden-roadrunner-380": 880461, + "steady-chinchilla-801": 880464, + "cheerful-firefly-625": 880465, + "dynamic-iguana-224": 880513, + "prestigious-armadillo-509": 880514, + "useful-retriever-175": 880544, + "quaint-sandpiper-424": 880545, + "savory-terrier-259": 880561, + "careful-swan-446": 880562, + "watchful-ptarmigan-932": 880598, + "flexible-gopher-280": 880600, + "oceanic-grasshopper-415": 880600, + "academic-crow-678": 880660, + "exciting-crocodile-493": 880661, + "industrious-warbler-743": 880661, + "veracious-salmon-912": 880666, + "proficient-caribou-739": 880668, + "shocking-cormorant-588": 880694, + "keen-dachshund-643": 880695, + "flexible-pig-693": 880738, + "calculating-buffalo-821": 880739, + "moonlit-penguin-746": 880773, + "rapid-duck-594": 880774, + "neighborly-guineapig-278": 880774, + "handsome-cat-448": 880776, + "judicious-ram-945": 880777, + "cautious-ferret-782": 880777, + "limitless-coyote-401": 880795, + "hearty-narwhal-811": 880797, + "keen-giraffe-957": 880848, + "impressive-panda-590": 880850, + "wary-trout-25": 880854, + "acoustic-beagle-425": 880855, + "courteous-moose-677": 880857, + "wooden-pony-422": 880858, + "dusty-dachshund-442": 880859, + "artful-giraffe-448": 880860, + "steady-bear-396": 880866, + "resolute-alpaca-520": 880867, + "resolute-bandicoot-825": 880873, + "adorable-setter-943": 880874, + "joyous-puma-963": 880880, + "befitting-lyrebird-704": 880882, + "perfect-tortoise-242": 880883, + "dependable-seahorse-700": 880884, + "artful-lemur-720": 880885, + "fortunate-avocet-567": 880887, + "stoic-terrier-150": 880890, + "woozy-gecko-737": 880891, + "uncommon-penguin-329": 880893, + "limitless-goat-482": 880894, + "loyal-clownfish-652": 880921, + "lovable-lapwing-710": 880922, + "handsome-squirrel-832": 880941, + "sleek-meerkat-453": 880942, + "valuable-ostrich-211": 880944, + "small-clownfish-214": 880946, + "energized-labrador-876": 880947, + "moonlit-shark-680": 880948, + "enchanted-bee-478": 880949, + "outstanding-bass-263": 880950, + "incredible-coyote-857": 880954, + "dashing-whale-661": 880955, + "grateful-fennec-431": 880958, + "scintillating-swordfish-388": 880959, + "veracious-tiger-434": 880961, + "animated-fox-659": 880962, + "cool-vulture-162": 880966, + "content-tiger-223": 880967, + "rightful-gull-425": 880967, + "small-ocelot-233": 880973, + "gallant-elephant-500": 880975, + "pleasant-flamingo-380": 880983, + "avid-cardinal-283": 880984, + "lovable-roadrunner-694": 880993, + "secret-magpie-966": 880995, + "helpful-civet-429": 881012, + "compassionate-elephant-7": 881013, + "patient-leopard-827": 881013, + "energetic-clam-257": 881017, + "gregarious-swan-286": 881018, + "diligent-wildcat-255": 881022, + "grand-capybara-70": 881023, + "festive-caiman-205": 881043, + "giddy-giraffe-790": 881044, + "clean-pheasant-143": 881045, + "kindred-cormorant-214": 881046, + "next-goat-700": 881049, + "striped-eel-95": 881050, + "kindhearted-cow-903": 881062, + "different-warbler-200": 881063, + "handsome-warbler-577": 881161, + "zealous-gopher-800": 881162, + "adamant-moose-418": 881205, + "usable-ram-1": 881206, + "efficient-moose-177": 881208, + "resilient-gerbil-288": 881209, + "posh-kingfisher-85": 881348, + "handsome-spaniel-713": 881350, + "fleet-falcon-834": 881369, + "silent-goose-41": 881370, + "ceaseless-bison-43": 881398, + "amiable-lark-450": 881399, + "usable-nightingale-499": 881399, + "curious-panther-123": 881410, + "shiny-cricket-129": 881411, + "utmost-basilisk-952": 881411, + "stoic-fish-747": 881607, + "watchful-monitor-740": 881608, + "artful-beagle-198": 881614, + "wooden-shrimp-38": 881616, + "sensible-chihuahua-735": 881630, + "neat-impala-832": 881631, + "festive-spoonbill-559": 881659, + "brilliant-salmon-279": 881660, + "elegant-cat-204": 881660, + "flippant-sparrow-209": 881693, + "formal-cuttlefish-373": 881694, + "famous-bandicoot-498": 881775, + "utmost-vulture-83": 881776, + "silent-condor-960": 881779, + "scintillating-gnu-723": 881780, + "vivid-poodle-276": 881780, + "utmost-magpie-510": 881840, + "brilliant-grasshopper-744": 881841, + "quick-puffin-741": 881872, + "neat-magpie-412": 881873, + "third-mockingbird-723": 881941, + "adventurous-porcupine-655": 881943, + "elated-toad-848": 881947, + "adjoining-stork-164": 881948, + "keen-hedgehog-343": 881987, + "energetic-pheasant-374": 881988, + "decisive-finch-295": 881991, + "combative-ferret-492": 881992, + "kindhearted-toad-269": 882020, + "accurate-dachshund-375": 882021, + "doting-falcon-174": 882021, + "original-hummingbird-869": 882025, + "quick-jaguar-455": 882026, + "fabulous-warbler-662": 882054, + "wooden-ibex-156": 882055, + "little-sturgeon-633": 882068, + "different-panda-448": 882070, + "dazzling-bandicoot-183": 882144, + "exuberant-zebra-18": 882146, + "grand-pelican-679": 882185, + "clear-chickadee-930": 882186, + "quirky-chihuahua-705": 882197, + "dutiful-possum-749": 882200, + "colorless-hamster-77": 882204, + "rapid-clownfish-22": 882205, + "lovable-dolphin-669": 882206, + "joyous-mouse-905": 882206, + "vibrant-shepherd-708": 882210, + "terrific-greyhound-968": 882211, + "rapid-kookabura-313": 882214, + "handsome-cardinal-330": 882215, + "flippant-puffin-33": 882217, + "proper-schnauzer-888": 882217, + "strong-panda-421": 882230, + "amicable-weasel-953": 882231, + "third-pony-443": 882232, + "valiant-hamster-228": 882232, + "hallowed-penguin-66": 882238, + "incredible-bloodhound-975": 882246, + "clever-ibis-204": 882250, + "honorable-sparrow-942": 882278, + "merry-curlew-992": 882294, + "cool-wombat-945": 882294, + "tame-gecko-9": 882295, + "zany-camel-854": 882295, + "adorable-goose-306": 882437, + "blessed-fox-289": 882447, + "wandering-crane-42": 882447, + "graceful-lapwing-177": 882448, + "blissful-rhinoceros-851": 882464, + "clear-camel-299": 882468, + "laudable-bat-807": 882469, + "rugged-mockingbird-616": 882472, + "focused-herring-439": 882473, + "dependable-viper-44": 882476, + "exuberant-weasel-896": 882476, + "flippant-goose-965": 882479, + "elegant-shark-664": 882480, + "notable-jellyfish-722": 882481, + "mellow-caribou-48": 882482, + "steady-spoonbill-822": 882482, + "dependable-starfish-675": 882485, + "brave-anteater-802": 882486, + "zany-ram-633": 882579, + "focused-wren-995": 882581, + "tough-kingfisher-159": 882595, + "ideal-chinchilla-41": 882596, + "brazen-pigeon-866": 882628, + "quirky-grasshopper-804": 882629, + "next-ostrich-549": 882647, + "oceanic-vole-234": 882648, + "successful-meerkat-816": 882648, + "astute-echidna-446": 882670, + "standing-tortoise-72": 882671, + "dutiful-turtle-344": 882715, + "descriptive-yak-40": 882716, + "resolute-cormorant-556": 882852, + "vivid-cardinal-528": 882950, + "enchanted-curlew-192": 882951, + "friendly-caribou-840": 882985, + "grandiose-guineapig-831": 882986, + "pastel-bulldog-527": 883006, + "whimsical-crocodile-39": 883007, + "scrupulous-scorpion-704": 883047, + "groovy-bat-119": 883048, + "next-lemming-673": 883077, + "scrupulous-goldfinch-897": 883078, + "charming-monitor-303": 883110, + "fleet-panther-248": 883111, + "incredible-clam-796": 883123, + "hardy-lion-294": 883124, + "dapper-rooster-199": 883133, + "polished-mole-315": 883134, + "spotted-ladybug-409": 883136, + "upbeat-skunk-403": 883137, + "tidy-mandrill-319": 883137, + "first-pigeon-669": 883152, + "fantastic-bee-857": 883153, + "abundant-flamingo-129": 883154, + "qualified-panda-342": 883155, + "warmhearted-goldfish-426": 883155, + "perfect-hound-709": 883159, + "adept-hippopotamus-12": 883160, + "cool-puma-740": 883187, + "next-cat-156": 883188, + "elated-mastiff-361": 883192, + "merry-swan-148": 883193, + "tremendous-heron-525": 883250, + "fine-quail-257": 883251, + "amiable-fennec-572": 883257, + "rapid-spaniel-236": 883258, + "adjoining-impala-913": 883268, + "energized-salmon-256": 883269, + "rare-minnow-6": 883308, + "next-possum-867": 883309, + "wonderful-tapir-271": 883313, + "hallowed-setter-117": 883314, + "prestigious-eagle-495": 883320, + "adjoining-flamingo-847": 883321, + "usable-dachshund-284": 883324, + "veracious-squid-542": 883325, + "fiery-platypus-553": 883328, + "disciplined-viper-108": 883329, + "beaming-oriole-638": 883331, + "clean-partridge-489": 883332, + "charming-iguana-56": 883337, + "tacit-kudu-623": 883338, + "decisive-marten-852": 883341, + "canny-kookabura-136": 883342, + "exciting-tern-161": 883349, + "giddy-gerbil-228": 883350, + "impartial-ram-522": 883353, + "resilient-rooster-556": 883354, + "content-bass-795": 883355, + "outgoing-chicken-238": 883356, + "doting-dachshund-432": 883375, + "valuable-zebra-802": 883376, + "cheerful-caribou-241": 883382, + "sincere-trout-821": 883383, + "determined-pelican-934": 883456, + "wonderful-badger-413": 883457, + "trustworthy-fish-140": 883459, + "admired-deer-301": 883460, + "notable-wombat-665": 883490, + "scrupulous-otter-655": 883491, + "grandiose-mule-935": 883511, + "useful-reindeer-928": 883512, + "acrobatic-crane-741": 883545, + "moonlit-mammoth-915": 883546, + "benevolent-rook-999": 883546, + "proper-hound-973": 883557, + "courteous-marten-134": 883558, + "diligent-ptarmigan-591": 883612, + "blessed-lark-88": 883614, + "neat-lobster-659": 883631, + "ceaseless-clam-309": 883632, + "veracious-heron-399": 883632, + "tacit-bass-250": 883640, + "capable-leopard-441": 883641, + "artful-shepherd-399": 883657, + "focused-dodo-399": 883658, + "scrupulous-cheetah-759": 883730, + "calculating-salamander-411": 883731, + "grateful-pig-594": 883731, + "energized-clam-938": 883834, + "judicious-tern-684": 883835, + "effervescent-mallard-871": 883835, + "neat-goldfinch-217": 883859, + "reliable-condor-741": 883860, + "shiny-puma-393": 883937, + "uncommon-bloodhound-581": 883938, + "striped-mongoose-492": 883994, + "glorious-mouse-150": 883996, + "quirky-caterpillar-297": 884014, + "modest-pig-991": 884015, + "different-cod-438": 884095, + "majestic-dalmatian-196": 884096, + "tangible-condor-543": 884111, + "scrupulous-chipmunk-869": 884113, + "third-panther-156": 884119, + "handsome-yak-772": 884122, + "energetic-armadillo-989": 884126, + "dutiful-iguana-244": 884127, + "original-frog-132": 884131, + "determined-fennec-617": 884132, + "kindly-gnat-756": 884159, + "trustworthy-gerbil-954": 884160, + "doting-dinosaur-406": 884160, + "coordinated-zebra-358": 884279, + "reminiscent-meerkat-743": 884280, + "famous-porcupine-211": 884316, + "befitting-ibis-954": 884317, + "brainy-falcon-701": 884334, + "standing-coyote-545": 884335, + "adventurous-akita-324": 884391, + "beloved-pigeon-317": 884392, + "doting-hippopotamus-141": 884403, + "graceful-peccary-224": 884404, + "tough-marten-529": 884407, + "superb-opossum-870": 884408, + "efficient-bloodhound-937": 884408, + "adamant-roadrunner-25": 884460, + "intent-guineapig-795": 884461, + "proficient-gull-90": 884461, + "efficient-capybara-567": 884490, + "content-chihuahua-419": 884491, + "usable-mandrill-140": 884491, + "qualified-cormorant-279": 884533, + "quirky-possum-455": 884534, + "unique-mongoose-537": 884534, + "secret-porcupine-305": 884585, + "dynamic-iguana-239": 884586, + "resilient-pika-185": 884615, + "industrious-bear-981": 884616, + "sleek-duck-32": 884616, + "upbeat-setter-464": 884647, + "rosy-otter-624": 884648, + "warmhearted-herring-55": 884648, + "standing-lyrebird-888": 884657, + "nautical-hedgehog-482": 884658, + "first-lobster-54": 884692, + "wary-gerbil-18": 884693, + "limitless-horse-505": 884835, + "cheery-ant-918": 884837, + "original-greyhound-525": 884848, + "adorable-ferret-555": 884850, + "dynamic-chameleon-927": 884850, + "fastidious-squirrel-122": 884910, + "exciting-hamster-123": 884911, + "optimistic-kiwi-719": 884921, + "tame-wren-429": 884922, + "youthful-hummingbird-9": 884950, + "accurate-hippopotamus-805": 884951, + "zealous-crocodile-916": 885034, + "beloved-monitor-855": 885035, + "neat-lapwing-459": 885090, + "opulent-hornet-827": 885091, + "proper-mosquito-243": 885135, + "earnest-lapwing-93": 885139, + "ceaseless-chameleon-111": 885166, + "uncommon-wolverine-892": 885167, + "flexible-bear-666": 885248, + "adorable-bass-476": 885249, + "exuberant-clownfish-810": 885417, + "laudable-tortoise-936": 885418, + "earnest-penguin-310": 885604, + "compassionate-reindeer-242": 885605, + "clever-dodo-590": 885660, + "majestic-sheep-136": 885662, + "honorable-hummingbird-299": 885662, + "warmhearted-squid-809": 885716, + "posh-tortoise-679": 885717, + "polite-seal-507": 885798, + "sincere-guanaco-890": 885799, + "compassionate-buffalo-677": 885809, + "kindly-stoat-17": 885811, + "calm-manatee-267": 885889, + "neighborly-chickadee-189": 885892, + "clear-eel-872": 885894, + "tough-civet-856": 885895, + "capable-gnat-910": 885949, + "quick-snake-470": 885951, + "insightful-ibex-201": 885971, + "judicious-ermine-621": 885972, + "dutiful-marten-593": 886005, + "intent-woodpecker-858": 886006, + "keen-jackal-802": 886006, + "hushed-jellyfish-555": 886029, + "adept-reindeer-777": 886030, + "patient-beagle-632": 886030, + "disciplined-capybara-594": 886051, + "modest-civet-691": 886052, + "fantastic-barracuda-831": 886077, + "clean-bobcat-873": 886078, + "perceptive-flamingo-991": 886078, + "gregarious-blackbird-941": 886105, + "dynamic-wildcat-685": 886107, + "abundant-spaniel-76": 886107, + "ceaseless-spoonbill-87": 886118, + "capable-hound-876": 886119, + "original-quail-552": 886228, + "blessed-shepherd-949": 886229, + "fast-roadrunner-123": 886240, + "fleet-duck-308": 886242, + "avid-panda-567": 886279, + "knowing-cormorant-879": 886280, + "upbeat-swordfish-539": 886337, + "sincere-swordfish-851": 886338, + "calm-rook-532": 886359, + "tangible-narwhal-293": 886360, + "scintillating-clam-478": 886461, + "valuable-trout-423": 886462, + "deafening-stoat-509": 886533, + "wandering-caterpillar-13": 886534, + "grandiose-butterfly-140": 886535, + "flexible-spoonbill-709": 886536, + "scintillating-hyena-66": 886536, + "dapper-beagle-161": 886538, + "wooden-aardvark-712": 886539, + "superb-rooster-424": 886541, + "watchful-pig-443": 886543, + "dutiful-marten-219": 886546, + "accomplished-chinchilla-432": 886548, + "polished-mink-862": 886552, + "amicable-ibex-414": 886553, + "cautious-bandicoot-323": 886556, + "bright-parrot-513": 886557, + "rosy-weasel-225": 886557, + "diligent-perch-482": 886561, + "compassionate-anteater-675": 886562, + "pleasant-terrier-453": 886564, + "giant-dolphin-127": 886565, + "terrific-tortoise-346": 886565, + "curious-crow-573": 886567, + "kindly-crow-696": 886568, + "enduring-blackbird-572": 886589, + "artful-panda-954": 886590, + "clear-gnat-120": 886603, + "effervescent-husky-845": 886604, + "valuable-anaconda-188": 886665, + "combative-kudu-757": 886666, + "quixotic-tiger-101": 886793, + "nautical-marlin-579": 886796, + "flexible-okapi-454": 886815, + "decisive-cheetah-964": 886817, + "quirky-mallard-123": 886817, + "colorless-reindeer-701": 886837, + "grandiose-fly-959": 886838, + "peaceful-pelican-13": 886984, + "benevolent-jay-118": 886985, + "vivid-lynx-410": 886985, + "decisive-ptarmigan-544": 887034, + "decisive-wildcat-814": 887035, + "bold-reindeer-234": 887050, + "hardy-guineapig-777": 887052, + "canny-kangaroo-140": 887052, + "formal-opossum-197": 887091, + "sensible-whale-397": 887093, + "exciting-dalmatian-850": 887094, + "valiant-stoat-391": 887095, + "frugal-warbler-780": 887096, + "gregarious-octopus-979": 887098, + "admired-skunk-473": 887106, + "academic-gopher-689": 887137, + "whimsical-chicken-193": 887138, + "giant-dog-336": 887217, + "precise-toad-128": 887218, + "adorable-malamute-703": 887288, + "elated-cassowary-208": 887290, + "energetic-rat-485": 887302, + "amiable-cheetah-640": 887303, + "agreeable-goldfish-405": 887307, + "keen-goshawk-239": 887308, + "calculating-gnat-996": 887396, + "adjoining-whale-117": 887397, + "bright-sturgeon-205": 887414, + "brazen-coyote-226": 887415, + "friendly-trout-569": 887416, + "dependable-moose-896": 887417, + "flippant-hound-309": 887441, + "wry-jellyfish-444": 887442, + "wonderful-monitor-115": 887469, + "terrific-goshawk-947": 887470, + "formal-bass-73": 887470, + "gallant-kudu-215": 887500, + "elated-trout-395": 887501, + "animated-setter-392": 887501, + "avid-pheasant-999": 887555, + "formal-rat-354": 887556, + "upbeat-warbler-575": 887556, + "hearty-blackbird-437": 887562, + "careful-hawk-839": 887616, + "outgoing-capybara-73": 887617, + "quiet-stoat-426": 887677, + "exuberant-newt-219": 887678, + "graceful-platypus-633": 887751, + "impressive-lyrebird-349": 887752, + "hearty-pig-911": 887763, + "perceptive-cuttlefish-690": 887764, + "brave-bass-826": 887798, + "wonderful-hamster-290": 887799, + "utmost-flamingo-806": 887802, + "qualified-wolverine-76": 887803, + "famous-hornet-890": 887821, + "confident-seahorse-459": 887822, + "outstanding-opossum-658": 887828, + "hip-dragon-344": 887830, + "striped-tapir-480": 887871, + "acoustic-falcon-789": 887872, + "helpful-crab-113": 887887, + "tough-bloodhound-72": 887889, + "vibrant-cat-797": 887895, + "amiable-walrus-554": 887896, + "efficient-chickadee-67": 887933, + "brilliant-loris-281": 887934, + "trustworthy-avocet-102": 887958, + "tremendous-oriole-623": 887960, + "expert-marlin-104": 887986, + "accomplished-aardvark-63": 887987, + "content-roadrunner-681": 887994, + "notable-caterpillar-355": 887995, + "earnest-oyster-752": 888002, + "graceful-kookabura-547": 888003, + "hip-dragon-607": 888020, + "coordinated-jellyfish-728": 888021, + "wry-civet-478": 888128, + "neighborly-possum-895": 888129, + "dynamic-wombat-713": 888164, + "quick-horse-387": 888165, + "graceful-dachshund-898": 888171, + "usable-grouse-482": 888172, + "rosy-sparrow-293": 888346, + "ideal-goldfinch-945": 888347, + "limitless-emu-42": 888354, + "veracious-wren-567": 888355, + "bright-giraffe-878": 888442, + "enduring-oriole-650": 888443, + "grateful-ostrich-22": 888837, + "lovely-bloodhound-879": 888838, + "dutiful-perch-427": 888913, + "cheerful-echidna-711": 888914, + "hip-penguin-756": 888922, + "clean-loris-626": 888923, + "nautical-clownfish-919": 888927, + "sensible-rabbit-671": 888928, + "fiery-cat-431": 888949, + "dependable-alligator-59": 888950, + "exciting-terrier-548": 889024, + "glorious-lark-24": 889025, + "flippant-shrimp-488": 889046, + "sensible-platypus-262": 889047, + "perceptive-porpoise-820": 889117, + "deafening-egret-746": 889196, + "courteous-blackbird-100": 889197, + "focused-coyote-493": 889379, + "opulent-trout-780": 889382, + "capable-raccoon-635": 889410, + "ardent-guineapig-985": 889411, + "canny-cod-522": 889412, + "first-hippopotamus-877": 889413, + "shiny-lapwing-106": 889437, + "rightful-albatross-725": 889438, + "fortunate-spoonbill-627": 889542, + "watchful-shrimp-518": 889543, + "necessary-hyena-374": 889543, + "oceanic-poodle-194": 889568, + "dusty-bass-139": 889569, + "zealous-giraffe-825": 889632, + "sensible-warthog-500": 889634, + "zany-eagle-125": 890050, + "dutiful-narwhal-545": 890051, + "secret-stoat-834": 890245, + "avid-porpoise-273": 890246, + "fabulous-beagle-486": 890267, + "aware-horse-57": 890268, + "elated-dachshund-623": 890333, + "elegant-mandrill-790": 890334, + "ardent-panda-454": 890336, + "vibrant-trout-104": 890339, + "upbeat-hedgehog-207": 890448, + "beaming-hound-805": 890449, + "aromatic-sturgeon-557": 890612, + "marvelous-cassowary-812": 890613, + "determined-ladybug-963": 890615, + "reminiscent-parrot-814": 890616, + "dutiful-scorpion-60": 890662, + "intent-fish-708": 890663, + "bright-avocet-911": 890737, + "polite-lemur-982": 890738, + "veracious-akita-1": 890793, + "giant-lion-368": 890794, + "fine-rat-465": 890794, + "loyal-jaguar-374": 890797, + "veracious-chipmunk-108": 890805, + "deafening-ibis-530": 890807, + "wry-whale-389": 890809, + "avid-ladybug-458": 890810, + "effervescent-toucan-568": 890810, + "brave-elk-468": 890842, + "keen-frog-479": 890844, + "prestigious-kookabura-986": 890844, + "small-hyena-749": 890860, + "good-gnu-269": 890861, + "little-bass-499": 890883, + "astute-sardine-582": 890885, + "striped-wombat-311": 890922, + "neighborly-barracuda-445": 890923, + "pastel-impala-227": 890945, + "qualified-hyena-833": 890946, + "majestic-herring-664": 890967, + "different-raccoon-575": 890970, + "uncommon-reindeer-462": 891011, + "bright-skunk-610": 891012, + "aromatic-badger-999": 891012, + "nautical-shark-701": 891016, + "glad-hedgehog-966": 891017, + "dazzling-shark-99": 891105, + "veracious-dachshund-278": 891107, + "kindly-setter-861": 891128, + "calm-eagle-620": 891129, + "acoustic-lemming-940": 891159, + "flippant-zebra-730": 891160, + "grateful-hedgehog-879": 891311, + "marvelous-ferret-749": 891312, + "watchful-butterfly-356": 891400, + "adamant-dog-757": 891401, + "grandiose-raccoon-79": 891405, + "flexible-panda-147": 891407, + "vivid-mongoose-901": 891428, + "abundant-mallard-116": 891429, + "graceful-walrus-38": 891466, + "standing-ocelot-659": 891467, + "descriptive-ptarmigan-636": 891481, + "giddy-duck-1": 891482, + "hardy-meerkat-517": 891502, + "lovable-ermine-88": 891503, + "flexible-labrador-613": 891503, + "majestic-rooster-178": 891513, + "different-orca-328": 891515, + "tacit-orca-753": 891607, + "opulent-goldfish-343": 891608, + "pleasant-dotterel-458": 891614, + "warmhearted-dalmatian-842": 891615, + "clear-elk-103": 891636, + "artful-chinchilla-188": 891637, + "cheery-kingfisher-679": 891638, + "watchful-ermine-841": 891640, + "vivid-gopher-767": 891646, + "deafening-caribou-253": 891648, + "valuable-caterpillar-959": 891652, + "tough-ox-4": 891653, + "dusty-lyrebird-306": 891657, + "blissful-tern-188": 891657, + "shiny-yak-908": 891674, + "effervescent-labrador-908": 891677, + "befitting-crab-785": 891694, + "valuable-ox-795": 891695, + "veracious-platypus-97": 891746, + "energetic-leopard-677": 891747, + "resolute-stork-181": 891747, + "patient-penguin-908": 891754, + "impartial-caiman-175": 891755, + "animated-terrier-296": 891841, + "steady-retriever-483": 891842, + "befitting-tern-695": 891849, + "limitless-salmon-914": 891885, + "usable-herring-907": 891886, + "brilliant-ladybug-872": 891891, + "vivid-avocet-79": 892005, + "elated-goat-952": 892006, + "cautious-dalmatian-871": 892019, + "limitless-ox-631": 892020, + "scintillating-marmot-863": 892072, + "quixotic-fox-273": 892073, + "careful-crocodile-985": 892100, + "grandiose-weasel-762": 892101, + "resilient-caiman-124": 892134, + "determined-chihuahua-46": 892135, + "adventurous-hedgehog-281": 892135, + "abundant-mastiff-448": 892141, + "effervescent-lobster-535": 892142, + "avid-malamute-410": 892282, + "rare-crab-212": 892283, + "fine-wombat-678": 892316, + "dynamic-cobra-223": 892317, + "content-armadillo-203": 892346, + "usable-heron-718": 892347, + "neighborly-flamingo-313": 892386, + "artful-leopard-584": 892387, + "scintillating-hamster-148": 892419, + "rugged-minnow-747": 892420, + "cheerful-alpaca-770": 892468, + "capable-partridge-965": 892469, + "astute-cheetah-985": 892536, + "modest-husky-951": 892537, + "colorless-crab-430": 892558, + "nautical-grouse-284": 892560, + "hidden-guineapig-498": 892568, + "merry-robin-835": 892569, + "combative-heron-486": 892577, + "groovy-manatee-497": 892579, + "valuable-terrier-966": 892596, + "impressive-capybara-370": 892597, + "judicious-hawk-389": 892668, + "flexible-malamute-766": 892669, + "majestic-swan-416": 892670, + "charming-goose-492": 892671, + "silent-oyster-95": 892737, + "impartial-dinosaur-667": 892738, + "enduring-cobra-409": 892745, + "good-crane-924": 892794, + "judicious-ox-945": 892796, + "accomplished-jellyfish-353": 892817, + "mild-buffalo-814": 892818, + "mellow-kookabura-838": 892825, + "valiant-bandicoot-170": 892826, + "accurate-leopard-229": 892828, + "efficient-robin-315": 892830, + "accurate-turtle-432": 892832, + "compassionate-giraffe-618": 892889, + "grateful-civet-734": 892890, + "patient-ibex-603": 892891, + "clean-goshawk-752": 892892, + "ardent-goat-536": 892893, + "aware-sheep-93": 892894, + "usable-dinosaur-599": 892895, + "opulent-penguin-692": 892896, + "disciplined-husky-703": 892897, + "hallowed-dinosaur-336": 892898, + "savory-gazelle-625": 892899, + "basic-lobster-609": 892900, + "first-oriole-274": 892901, + "woozy-poodle-926": 892902, + "opulent-donkey-584": 892904, + "exciting-sandpiper-87": 892905, + "lovely-cat-915": 892906, + "utmost-mastiff-396": 892907, + "quick-trout-209": 892908, + "nautical-sturgeon-505": 892909, + "keen-mule-550": 892916, + "kindhearted-hippopotamus-336": 892917, + "combative-bison-472": 892941, + "woozy-peccary-437": 892942, + "scintillating-panda-315": 892966, + "rapid-crocodile-587": 892967, + "polite-puffin-659": 892983, + "cheerful-wren-615": 892984, + "focused-sardine-48": 893052, + "helpful-hare-273": 893053, + "confident-puffin-162": 893054, + "brazen-vole-100": 893055, + "sensible-bat-604": 893055, + "ardent-gopher-321": 893089, + "tangible-moose-827": 893090, + "oceanic-chihuahua-218": 893102, + "graceful-partridge-569": 893112, + "lovely-rook-197": 893113, + "benevolent-ostrich-328": 893113, + "marvelous-dogfish-222": 893154, + "deafening-minnow-897": 893180, + "outgoing-mule-586": 893181, + "wonderful-curlew-288": 893189, + "clever-emu-63": 893195, + "adorable-hippopotamus-848": 893196, + "cool-aardvark-912": 893203, + "mild-moose-580": 893204, + "brilliant-iguana-34": 893237, + "intent-antelope-99": 893238, + "fiery-tortoise-309": 893272, + "canny-raven-977": 893273, + "exciting-cow-58": 893311, + "hardy-woodpecker-543": 893346, + "adorable-warthog-617": 893347, + "colorful-warthog-281": 893359, + "modest-tortoise-574": 893360, + "wonderful-hippopotamus-502": 893373, + "colorless-schnauzer-146": 893374, + "valuable-stoat-957": 893382, + "dapper-impala-586": 893383, + "elated-pigeon-417": 893385, + "cheery-barracuda-451": 893388, + "neighborly-cuttlefish-588": 893388, + "trustworthy-panda-453": 893431, + "expert-puma-786": 893432, + "oceanic-leopard-923": 893451, + "grandiose-weasel-397": 893452, + "quaint-yak-546": 893457, + "limitless-civet-780": 893458, + "dutiful-axolotl-200": 893464, + "effervescent-gazelle-434": 893465, + "whimsical-zebra-972": 893478, + "glad-sturgeon-332": 893479, + "rightful-deer-162": 893480, + "whimsical-iguana-777": 893481, + "bright-quail-185": 893494, + "capable-fennec-600": 893495, + "useful-crane-340": 893508, + "posh-opossum-660": 893510, + "agreeable-hawk-772": 893516, + "warmhearted-akita-288": 893520, + "lovable-guineapig-646": 893526, + "jovial-firefly-577": 893527, + "festive-perch-315": 893592, + "pleasant-hare-337": 893593, + "decisive-salamander-836": 893597, + "sleek-marlin-715": 893598, + "harmless-hornet-824": 893603, + "content-magpie-999": 893619, + "secret-penguin-411": 893620, + "capable-loris-229": 893677, + "usable-bandicoot-54": 893678, + "small-hippopotamus-876": 893684, + "beloved-dolphin-736": 893685, + "proper-blackbird-931": 893730, + "gallant-cricket-730": 893732, + "proficient-egret-586": 893737, + "fleet-trout-157": 893738, + "optimistic-anaconda-310": 893743, + "shiny-badger-264": 893744, + "benevolent-hummingbird-108": 893753, + "chatty-mink-762": 893754, + "honorable-firefly-655": 893763, + "focused-poodle-440": 893764, + "dynamic-setter-485": 893776, + "peaceful-crocodile-670": 893777, + "quixotic-cow-469": 893817, + "gallant-hornet-23": 893818, + "precious-spaniel-338": 893824, + "cool-peacock-168": 893827, + "admired-rook-409": 893840, + "little-albatross-252": 893841, + "harmless-bee-391": 893846, + "fantastic-setter-419": 893848, + "exciting-mouse-840": 893856, + "optimistic-crane-113": 893857, + "giant-vole-86": 893859, + "rare-ram-719": 893880, + "giant-owl-903": 893881, + "first-mink-843": 893887, + "groovy-kiwi-63": 893895, + "combative-buffalo-891": 893896, + "basic-hare-882": 893896, + "trustworthy-grouse-477": 893915, + "tidy-porpoise-481": 893917, + "dusty-buzzard-249": 893930, + "shiny-terrier-315": 893933, + "befitting-bandicoot-231": 893993, + "efficient-setter-741": 893994, + "spotted-civet-816": 893995, + "sleek-chickadee-319": 893996, + "rare-avocet-824": 893997, + "mellow-pig-671": 893998, + "strong-sardine-529": 893999, + "trustworthy-alpaca-389": 894000, + "determined-oyster-195": 894001, + "confident-tern-391": 894002, + "oceanic-badger-347": 894003, + "honorable-gerbil-476": 894004, + "glorious-labrador-473": 894005, + "abundant-hound-785": 894005, + "compassionate-chicken-393": 894006, + "dapper-chicken-980": 894045, + "ardent-starling-722": 894048, + "adamant-mammoth-585": 894080, + "sensible-ibis-883": 894083, + "youthful-shark-0": 894089, + "harmless-horse-292": 894090, + "ardent-dolphin-950": 894103, + "healthy-clownfish-114": 894104, + "tidy-dinosaur-452": 894251, + "admired-cricket-889": 894252, + "sleek-goshawk-187": 894292, + "groovy-bobcat-302": 894295, + "flexible-starfish-822": 894295, + "silent-donkey-329": 894296, + "brazen-shrimp-929": 894297, + "first-deer-635": 894297, + "standing-platypus-993": 894569, + "reminiscent-mandrill-733": 894570, + "bold-spoonbill-194": 894573, + "ideal-lemming-694": 894574, + "blessed-otter-485": 894600, + "savory-buffalo-721": 894601, + "amicable-ostrich-700": 894856, + "resolute-axolotl-631": 894857, + "shiny-oriole-798": 894881, + "colorful-hyena-753": 894882, + "cool-clownfish-832": 894928, + "small-retriever-333": 894929, + "scrupulous-cassowary-655": 894992, + "impressive-alpaca-860": 894994, + "next-bobcat-406": 894994, + "judicious-dodo-285": 895018, + "exciting-spaniel-429": 895019, + "majestic-heron-486": 895205, + "mellow-bandicoot-845": 895207, + "resilient-herring-462": 895209, + "brilliant-cricket-155": 895210, + "chatty-camel-565": 895215, + "brazen-chicken-20": 895216, + "patient-roadrunner-907": 895315, + "perfect-manatee-451": 895316, + "outgoing-clownfish-56": 895321, + "fleet-crane-247": 895322, + "grandiose-tiger-51": 895323, + "harmless-bee-491": 895324, + "chatty-gazelle-193": 895351, + "rare-meerkat-889": 895352, + "giddy-pig-417": 895358, + "savory-caiman-372": 895359, + "efficient-tern-863": 895370, + "dazzling-emu-616": 895371, + "blissful-cardinal-990": 895439, + "marvelous-kudu-387": 895441, + "utmost-hyena-267": 895455, + "impressive-wildebeest-322": 895456, + "wry-poodle-326": 895467, + "admired-butterfly-889": 895469, + "bold-lark-676": 895479, + "notable-monitor-685": 895480, + "kindly-lemur-776": 895493, + "useful-lyrebird-569": 895495, + "standing-pika-573": 895618, + "avid-buzzard-777": 895619, + "third-opossum-459": 895635, + "agile-wildebeest-299": 895636, + "efficient-mink-428": 895636, + "strong-partridge-608": 895751, + "outgoing-cobra-483": 895752, + "quick-corgi-386": 895773, + "hip-gazelle-765": 895775, + "tangible-bee-959": 895806, + "befitting-woodpecker-149": 895807, + "patient-marmot-267": 895810, + "peaceful-cuttlefish-300": 895812, + "lovable-shepherd-556": 895863, + "neighborly-peacock-660": 895865, + "rightful-bloodhound-883": 895894, + "quirky-bat-278": 895895, + "strong-swan-713": 895957, + "scrupulous-scorpion-700": 895958, + "fiery-kingfisher-143": 895958, + "knowing-cat-639": 895964, + "hushed-akita-875": 895965, + "flippant-marmot-133": 896029, + "adept-chameleon-723": 896030, + "deafening-ox-251": 896030, + "fantastic-cricket-785": 896038, + "reminiscent-albatross-721": 896039, + "agreeable-opossum-791": 896045, + "giant-yak-354": 896046, + "rugged-ostrich-757": 896091, + "optimistic-egret-428": 896092, + "descriptive-lemur-811": 896124, + "amiable-marmot-587": 896125, + "agreeable-pig-905": 896125, + "exciting-rooster-966": 896141, + "cool-rooster-279": 896142, + "brainy-chickadee-445": 896151, + "calm-goose-365": 896152, + "fearless-chameleon-911": 896160, + "mild-ocelot-932": 896161, + "woozy-orca-490": 896178, + "optimistic-caribou-277": 896179, + "grandiose-sandpiper-581": 896228, + "coordinated-wolverine-794": 896229, + "rapid-chihuahua-730": 896238, + "sincere-warbler-499": 896239, + "dazzling-panda-265": 896244, + "hushed-porpoise-718": 896245, + "adept-oyster-213": 896257, + "dependable-guineapig-382": 896258, + "descriptive-gnu-430": 896270, + "brave-flamingo-386": 896271, + "woozy-buffalo-439": 896285, + "limitless-marten-366": 896286, + "gallant-eel-307": 896286, + "kindly-whale-129": 896323, + "flippant-guanaco-831": 896324, + "brave-rhinoceros-904": 896324, + "blissful-marmot-383": 896585, + "reminiscent-falcon-937": 896586, + "patient-toucan-932": 896666, + "basic-bullfrog-917": 896667, + "gregarious-pheasant-578": 896729, + "nautical-gecko-503": 896730, + "brazen-marmot-495": 896778, + "colorful-porcupine-100": 896779, + "patient-bird-990": 896781, + "content-goldfinch-814": 896782, + "clever-lemur-384": 896851, + "honorable-leopard-446": 896853, + "dynamic-hyena-486": 896965, + "colorful-sheep-69": 896966, + "fastidious-dalmatian-0": 897002, + "compassionate-warthog-674": 897004, + "avid-rat-394": 897156, + "rare-chicken-834": 897157, + "merry-fox-901": 897268, + "qualified-marten-654": 897269, + "brazen-buzzard-102": 897275, + "laudable-raven-708": 897276, + "friendly-panther-517": 897446, + "exciting-rat-761": 897447, + "scrupulous-puffin-938": 897470, + "doting-sheep-554": 897471, + "tough-bullfrog-153": 897471, + "oceanic-husky-141": 897521, + "modest-jellyfish-782": 897522, + "moonlit-rat-531": 897522, + "handsome-parrot-633": 897523, + "agreeable-lapwing-750": 897524, + "descriptive-fish-542": 897585, + "effervescent-roadrunner-884": 897617, + "flippant-toad-624": 897618, + "first-spaniel-825": 897632, + "dusty-wren-790": 897633, + "helpful-pig-36": 897636, + "polished-rooster-386": 897637, + "spotted-anaconda-660": 897637, + "confident-vole-977": 897689, + "dutiful-finch-135": 897690, + "peaceful-ermine-377": 897690, + "scintillating-zebra-318": 897721, + "pleasant-ox-762": 897722, + "terrific-orca-201": 897761, + "adorable-kiwi-163": 897763, + "quixotic-yak-533": 897763, + "whimsical-guanaco-814": 897784, + "ceaseless-lobster-567": 897787, + "accurate-oyster-995": 897796, + "industrious-pelican-963": 897797, + "savory-bandicoot-352": 897797, + "earnest-antelope-579": 897810, + "blessed-blackbird-655": 897812, + "wry-skunk-632": 897833, + "courteous-manatee-605": 897834, + "lovable-wren-71": 897834, + "tangible-setter-123": 897875, + "exciting-chipmunk-600": 897876, + "fast-gazelle-498": 897876, + "joyous-buffalo-193": 897945, + "handsome-cheetah-73": 897946, + "rosy-chinchilla-649": 897956, + "brazen-gazelle-48": 897957, + "avid-llama-392": 897969, + "combative-sheep-739": 897970, + "canny-leopard-157": 898210, + "careful-jay-359": 898212, + "polite-marlin-377": 898255, + "kindly-elk-141": 898258, + "adorable-bullfrog-708": 898281, + "clever-wombat-883": 898282, + "necessary-retriever-147": 898474, + "vibrant-alligator-609": 898475, + "brilliant-cow-859": 898503, + "joyous-echidna-64": 898504, + "little-boar-606": 898534, + "acrobatic-lyrebird-636": 898537, + "neat-kudu-616": 898537, + "necessary-falcon-652": 898570, + "dashing-rook-37": 898571, + "honorable-meadowlark-295": 898583, + "grand-lark-448": 898584, + "usable-emu-310": 898584, + "posh-skunk-130": 898642, + "qualified-chipmunk-278": 898643, + "efficient-ant-111": 898699, + "savory-platypus-808": 898700, + "woozy-fox-937": 898805, + "festive-vole-836": 898806, + "oceanic-crow-967": 898903, + "enchanted-falcon-471": 898904, + "successful-badger-787": 898904, + "whimsical-mastiff-338": 899139, + "judicious-ox-682": 899140, + "laudable-sturgeon-698": 899142, + "friendly-parrot-612": 899143, + "qualified-cow-548": 899159, + "animated-hamster-982": 899160, + "first-crow-282": 899234, + "glad-crab-400": 899235, + "loyal-ibis-322": 899254, + "proficient-gazelle-981": 899255, + "wooden-sardine-378": 899255, + "ceaseless-toad-710": 899261, + "marvelous-duck-396": 899262, + "fortunate-orca-879": 899276, + "striped-frog-431": 899277, + "marvelous-bulldog-512": 899279, + "amicable-flamingo-845": 899280, + "small-tapir-22": 899328, + "incredible-koala-469": 899329, + "courteous-weasel-522": 899338, + "benevolent-cricket-664": 899339, + "fearless-hawk-320": 899350, + "dusty-yak-240": 899352, + "valiant-ocelot-482": 899352, + "steady-owl-150": 899372, + "quirky-roadrunner-638": 899373, + "combative-puffin-123": 899411, + "lovely-possum-668": 899412, + "gregarious-jaguar-507": 899412, + "tremendous-badger-213": 899501, + "first-hamster-316": 899502, + "animated-fly-845": 899502, + "rosy-sparrow-799": 899519, + "veracious-oyster-388": 899520, + "hardy-walrus-933": 899540, + "secret-ostrich-923": 899541, + "joyous-rooster-628": 899615, + "glad-chameleon-921": 899616, + "gallant-owl-298": 899617, + "nautical-hare-162": 899619, + "unique-husky-170": 899619, + "tacit-peacock-438": 899672, + "moonlit-clam-983": 899673, + "giddy-parrot-558": 899726, + "patient-hyena-857": 899728, + "grand-magpie-113": 899804, + "modest-spider-668": 899805, + "vibrant-swan-605": 899830, + "cautious-curlew-391": 899831, + "perceptive-snail-472": 899831, + "acoustic-bison-396": 899848, + "brilliant-mouse-172": 899872, + "adept-axolotl-637": 899875, + "deafening-vole-651": 899881, + "capable-kookabura-258": 899882, + "festive-gerbil-282": 899889, + "marvelous-rabbit-366": 899890, + "proper-alpaca-370": 899907, + "third-bullfrog-627": 899908, + "abundant-tortoise-14": 899929, + "tangible-retriever-704": 899930, + "oceanic-penguin-978": 899953, + "frugal-dotterel-937": 899954, + "amiable-monitor-601": 900042, + "tame-sockeye-528": 900043, + "graceful-bloodhound-516": 900067, + "brilliant-cobra-456": 900068, + "precious-mosquito-421": 900191, + "sensible-gazelle-729": 900192, + "amiable-barracuda-746": 900200, + "hushed-dove-28": 900201, + "rapid-cobra-249": 900291, + "kindred-newt-92": 900292, + "friendly-tiger-224": 900304, + "qualified-clam-754": 900305, + "wandering-cobra-674": 900309, + "posh-platypus-69": 900310, + "adamant-crocodile-464": 900310, + "stoic-skunk-103": 900328, + "befitting-mammoth-797": 900329, + "dependable-lynx-609": 900394, + "outgoing-bloodhound-686": 900395, + "neat-orca-250": 900481, + "marvelous-coyote-560": 900482, + "famous-impala-489": 900486, + "fast-raven-81": 900488, + "rosy-donkey-750": 900586, + "enduring-opossum-828": 900587, + "amiable-crocodile-45": 900696, + "exuberant-mastiff-841": 900700, + "cheerful-gull-634": 900712, + "industrious-wolverine-839": 900713, + "accomplished-viper-279": 900733, + "pastel-rabbit-86": 900734, + "aware-ibex-304": 900746, + "different-tapir-57": 900748, + "fast-hamster-607": 900765, + "quixotic-herring-403": 900766, + "handsome-canary-247": 900786, + "brazen-dove-511": 900787, + "energetic-mammoth-365": 900787, + "keen-kiwi-57": 900831, + "abundant-dragon-426": 900833, + "frugal-dalmatian-669": 900925, + "combative-echidna-354": 900926, + "polished-guineapig-973": 900926, + "hardy-warthog-957": 900967, + "affable-warthog-377": 900968, + "tame-jaguar-559": 901106, + "patient-wolf-384": 901107, + "calculating-mouse-214": 901117, + "jovial-scorpion-672": 901118, + "rare-gopher-48": 901131, + "famous-goshawk-930": 901132, + "canny-butterfly-77": 901154, + "dutiful-pigeon-504": 901155, + "wonderful-spaniel-500": 901155, + "wonderful-curlew-682": 901214, + "tame-cod-312": 901216, + "amicable-dog-596": 901229, + "fiery-cassowary-81": 901230, + "sensible-fly-721": 901230, + "wary-capybara-815": 901262, + "shocking-marlin-538": 901263, + "lovely-ptarmigan-239": 901282, + "tremendous-gopher-635": 901283, + "earnest-grouse-195": 901285, + "hardy-chameleon-267": 901285, + "canny-bass-977": 901286, + "honorable-caiman-638": 901411, + "necessary-possum-721": 901412, + "fastidious-starfish-71": 901417, + "enchanted-goldfinch-610": 901418, + "shocking-hippopotamus-632": 901418, + "valuable-sturgeon-304": 901451, + "sincere-seal-848": 901452, + "stoic-setter-362": 901461, + "deafening-hummingbird-845": 901462, + "optimistic-sardine-822": 901467, + "judicious-dog-843": 901468, + "shocking-lapwing-444": 901468, + "rosy-mandrill-151": 901550, + "calculating-starling-286": 901551, + "fantastic-ermine-622": 901555, + "rightful-wolverine-325": 901556, + "enchanted-quail-689": 901640, + "shiny-ibex-75": 901641, + "majestic-squirrel-903": 901667, + "sincere-gnu-113": 901668, + "industrious-lobster-85": 901668, + "colorful-fox-904": 901674, + "clean-crab-681": 901675, + "befitting-dragon-119": 901683, + "scintillating-raven-654": 901684, + "fearless-horse-678": 901693, + "confident-trout-230": 901694, + "frugal-moose-449": 901701, + "amicable-panda-146": 901702, + "cautious-opossum-925": 901705, + "tangible-terrier-124": 901707, + "wooden-marlin-155": 901707, + "hidden-puma-906": 901735, + "exuberant-warbler-350": 901736, + "rare-retriever-219": 901758, + "dapper-weasel-829": 901759, + "hushed-ladybug-797": 901786, + "cautious-fennec-36": 901787, + "valiant-bird-353": 901787, + "perceptive-wolverine-706": 901808, + "insightful-beagle-332": 901809, + "earnest-leopard-785": 901852, + "secret-pony-969": 901853, + "grandiose-ostrich-145": 901950, + "quirky-hummingbird-379": 901951, + "colorful-raven-871": 901971, + "moonlit-cow-563": 901972, + "outgoing-sturgeon-937": 902008, + "fearless-eel-228": 902009, + "bright-peacock-389": 902070, + "veracious-raven-106": 902072, + "wary-barracuda-351": 902089, + "successful-dalmatian-919": 902093, + "quaint-dragon-543": 902111, + "brave-gull-866": 902112, + "small-caterpillar-876": 902116, + "insightful-camel-339": 902117, + "festive-ram-548": 902204, + "tremendous-rook-326": 902206, + "usable-crocodile-669": 902211, + "tangible-condor-205": 902212, + "wandering-egret-212": 902221, + "grateful-parakeet-0": 902223, + "hardy-crane-87": 902235, + "fantastic-kookabura-62": 902236, + "academic-mongoose-353": 902291, + "giddy-magpie-324": 902292, + "fast-viper-146": 902310, + "harmless-porpoise-208": 902311, + "youthful-penguin-827": 902336, + "marvelous-rooster-102": 902337, + "zealous-mole-559": 902338, + "adamant-frog-783": 902339, + "fabulous-butterfly-726": 902344, + "effervescent-tern-201": 902345, + "brave-albatross-988": 902355, + "zany-lobster-847": 902356, + "agreeable-boar-914": 902356, + "effervescent-gazelle-936": 902387, + "chatty-hyena-384": 902388, + "cheerful-buffalo-639": 902474, + "fearless-ant-402": 902476, + "focused-iguana-676": 902566, + "outgoing-koala-507": 902567, + "posh-gecko-463": 902601, + "rightful-ibex-362": 902602, + "quiet-newt-239": 902626, + "kindhearted-starling-544": 902628, + "patient-parakeet-265": 902652, + "courteous-albatross-199": 902653, + "jovial-chickadee-769": 902697, + "scrupulous-fox-73": 902698, + "zealous-badger-176": 902698, + "fast-duck-705": 902705, + "moonlit-dodo-219": 902706, + "clean-wolverine-579": 902810, + "diligent-mole-316": 902812, + "rare-pelican-278": 902853, + "blissful-capybara-290": 902854, + "colorful-kangaroo-104": 902939, + "fiery-echidna-168": 902940, + "unique-robin-890": 903000, + "cheerful-boar-979": 903002, + "festive-aardvark-450": 903033, + "formal-gerbil-804": 903034, + "bright-horse-173": 903034, + "adept-cormorant-155": 903043, + "elegant-panda-552": 903044, + "impressive-condor-79": 903044, + "clever-hamster-942": 903063, + "mellow-goshawk-21": 903064, + "third-oyster-465": 903064, + "little-bulldog-160": 903079, + "opulent-alligator-210": 903081, + "optimistic-pigeon-816": 903098, + "rightful-lobster-576": 903099, + "notable-fly-776": 903115, + "exuberant-kiwi-154": 903116, + "quick-egret-122": 903211, + "glad-meerkat-105": 903213, + "astute-horse-878": 903217, + "basic-ibex-959": 903218, + "small-corgi-262": 903221, + "rosy-panther-140": 903222, + "befitting-gazelle-236": 903236, + "hip-meadowlark-627": 903237, + "fiery-yak-409": 903293, + "scintillating-horse-933": 903294, + "calculating-loris-385": 903334, + "determined-monitor-741": 903335, + "bright-ferret-27": 903354, + "beloved-octopus-639": 903356, + "impartial-lemur-521": 903368, + "healthy-cat-509": 903371, + "neighborly-wombat-312": 903481, + "notable-raven-735": 903482, + "tidy-goat-92": 903506, + "effervescent-okapi-297": 903507, + "hearty-sandpiper-160": 903576, + "dapper-giraffe-990": 903577, + "astute-sheep-494": 903624, + "amicable-goldfinch-167": 903625, + "tacit-spider-607": 903625, + "loyal-rat-39": 903710, + "ideal-koala-306": 903711, + "jovial-gull-124": 903749, + "bold-gazelle-216": 903769, + "fine-rooster-881": 903770, + "dashing-partridge-420": 903777, + "artful-grasshopper-370": 903778, + "adamant-minnow-127": 903819, + "impartial-ptarmigan-496": 903820, + "efficient-scorpion-620": 903867, + "flippant-woodpecker-900": 903868, + "different-salmon-933": 903888, + "formal-antelope-933": 903889, + "fastidious-bison-652": 903912, + "hip-ant-933": 903913, + "valiant-kangaroo-539": 903990, + "content-egret-260": 903991, + "dusty-basilisk-393": 903991, + "artful-dragon-968": 904156, + "disciplined-dinosaur-572": 904226, + "fiery-eagle-177": 904228, + "optimistic-echidna-687": 904299, + "beloved-wombat-78": 904300, + "fast-ptarmigan-356": 904303, + "polished-mule-993": 904304, + "mild-horse-904": 904331, + "ideal-antelope-444": 904333, + "nautical-sparrow-841": 904350, + "giddy-sturgeon-296": 904351, + "savory-chinchilla-736": 904384, + "aware-hippopotamus-857": 904385, + "spotted-ladybug-883": 904554, + "clever-crow-290": 904556, + "decisive-mink-146": 904738, + "compassionate-ant-757": 904780, + "famous-bison-588": 904918, + "flippant-goldfish-798": 904919, + "robust-basilisk-946": 904919, + "pastel-manatee-79": 904922, + "decisive-salmon-419": 904923, + "kindred-rooster-978": 904923, + "grateful-ram-201": 905063, + "groovy-butterfly-701": 905064, + "compassionate-ladybug-3": 905064, + "friendly-greyhound-591": 905255, + "ideal-magpie-845": 905256, + "original-weasel-782": 905342, + "optimistic-seal-37": 905343, + "ideal-sparrow-826": 905566, + "calm-gnat-433": 905567, + "superb-axolotl-674": 905567, + "sleek-tortoise-580": 905605, + "superb-donkey-338": 905606, + "groovy-parakeet-529": 905627, + "artful-anaconda-804": 905628, + "expert-civet-470": 905634, + "expert-partridge-816": 905635, + "agreeable-quail-422": 905643, + "formal-perch-583": 905644, + "usable-magpie-118": 905644, + "tidy-nightingale-252": 905663, + "clean-dogfish-387": 905664, + "successful-dotterel-471": 905664, + "adept-shrimp-817": 905673, + "precious-toucan-954": 905674, + "knowing-eel-215": 905684, + "prestigious-grasshopper-361": 905685, + "nautical-bat-777": 905700, + "robust-bat-189": 905701, + "dapper-crocodile-596": 905715, + "oceanic-basilisk-452": 905716, + "proficient-dogfish-310": 905716, + "descriptive-hippopotamus-364": 905724, + "resilient-leopard-481": 905725, + "next-stingray-361": 905963, + "colorful-starfish-311": 905964, + "savory-greyhound-387": 906034, + "earnest-bee-325": 906035, + "proper-bandicoot-668": 906041, + "brilliant-akita-922": 906042, + "neighborly-oriole-232": 906053, + "graceful-kudu-191": 906054, + "ideal-ladybug-563": 906059, + "intent-tortoise-1": 906060, + "blissful-cricket-917": 906062, + "astute-wildebeest-486": 906063, + "next-walrus-926": 906066, + "fast-ferret-619": 906146, + "admired-snake-700": 906147, + "quaint-hornet-999": 906163, + "hip-mockingbird-51": 906165, + "admired-yak-552": 906179, + "perfect-bulldog-402": 906180, + "bold-pony-775": 906204, + "glad-cricket-242": 906205, + "calm-goshawk-10": 906205, + "mellow-aardvark-25": 906236, + "quick-hornet-673": 906237, + "scintillating-gerbil-960": 906295, + "cool-ram-418": 906296, + "qualified-seahorse-919": 906368, + "wonderful-rabbit-124": 906369, + "whimsical-terrier-761": 906369, + "astute-puffin-186": 906378, + "compassionate-boar-181": 906380, + "resilient-wren-169": 906386, + "rugged-koala-855": 906387, + "shocking-cod-912": 906403, + "scintillating-loris-271": 906404, + "adventurous-tiger-879": 906433, + "polite-tapir-131": 906434, + "silent-coyote-106": 906438, + "fearless-guineapig-116": 906440, + "warmhearted-dog-117": 906452, + "majestic-weasel-476": 906453, + "knowing-mule-804": 906475, + "ceaseless-ostrich-142": 906476, + "rapid-wildcat-469": 906476, + "little-seal-474": 906512, + "adept-stingray-319": 906513, + "disciplined-cat-966": 906529, + "perfect-wildebeest-200": 906530, + "intent-mouse-441": 906549, + "dynamic-scorpion-412": 906550, + "whimsical-beagle-216": 906591, + "adamant-antelope-247": 906592, + "befitting-otter-51": 906602, + "energetic-goldfinch-672": 906603, + "rare-axolotl-872": 906603, + "healthy-jay-857": 906688, + "limitless-cod-861": 906690, + "necessary-chickadee-406": 906716, + "earnest-stingray-910": 906717, + "groovy-lemming-8": 906718, + "blissful-kangaroo-813": 906719, + "dapper-lyrebird-56": 906739, + "opulent-dodo-649": 906740, + "aromatic-bandicoot-420": 906806, + "perceptive-duck-525": 906807, + "healthy-chameleon-446": 906933, + "cheery-dove-783": 906935, + "giddy-monitor-947": 906939, + "youthful-dachshund-442": 906940, + "glad-lion-513": 906952, + "blissful-setter-29": 906954, + "calculating-dog-617": 907001, + "moonlit-lapwing-992": 907002, + "blessed-koala-830": 907054, + "exciting-squirrel-179": 907055, + "knowing-bird-409": 907090, + "calm-clownfish-642": 907093, + "adamant-chameleon-557": 907104, + "intent-fox-735": 907105, + "warmhearted-ant-452": 907105, + "outstanding-quail-143": 907157, + "gregarious-basilisk-154": 907158, + "fortunate-goldfish-359": 907167, + "limitless-emu-491": 907168, + "shiny-jellyfish-876": 907181, + "reliable-mastiff-302": 907182, + "capable-porcupine-674": 907222, + "first-fox-969": 907223, + "scintillating-whale-776": 907276, + "beloved-hare-917": 907279, + "spotted-narwhal-514": 907349, + "brainy-blackbird-892": 907350, + "dependable-squid-239": 907351, + "effervescent-kookabura-640": 907352, + "flexible-loris-633": 907353, + "beloved-goshawk-669": 907354, + "nautical-squid-699": 907371, + "enchanted-opossum-230": 907372, + "optimistic-anaconda-893": 907458, + "wry-egret-903": 907459, + "benevolent-chinchilla-651": 907565, + "superb-mole-66": 907566, + "elated-chickadee-937": 907609, + "quick-clownfish-282": 907610, + "expert-mink-525": 907657, + "affable-husky-27": 907658, + "ideal-oriole-593": 907659, + "honorable-goat-93": 907660, + "elegant-mole-815": 907706, + "avid-puffin-133": 907707, + "canny-goshawk-691": 907717, + "insightful-meadowlark-672": 907718, + "fleet-mole-141": 907767, + "upbeat-toucan-769": 907768, + "fast-buzzard-918": 907774, + "efficient-wombat-386": 907775, + "outstanding-hawk-644": 907810, + "proficient-pig-603": 907811, + "modest-dogfish-642": 907821, + "standing-finch-386": 907822, + "good-lynx-521": 907913, + "adjoining-shepherd-380": 907914, + "usable-dotterel-419": 907935, + "superb-cow-504": 907938, + "dazzling-quail-940": 908056, + "peaceful-pika-796": 908058, + "avid-ermine-996": 908127, + "third-reindeer-108": 908128, + "cheery-gecko-930": 908141, + "healthy-spoonbill-110": 908143, + "graceful-meerkat-220": 908149, + "clear-mule-489": 908150, + "pleasant-clam-429": 908452, + "flexible-mandrill-925": 908453, + "glad-dragon-143": 908464, + "insightful-blackbird-928": 908465, + "clever-ermine-444": 908490, + "amiable-magpie-752": 908491, + "precise-goldfish-728": 908491, + "wooden-egret-579": 908516, + "pastel-hedgehog-768": 908517, + "canny-sparrow-120": 908517, + "tangible-hummingbird-962": 908628, + "dependable-meerkat-703": 908630, + "zany-pony-988": 908643, + "upbeat-jay-87": 908644, + "adorable-buzzard-929": 908669, + "posh-ocelot-98": 908670, + "intent-squirrel-192": 908695, + "wooden-hyena-99": 908696, + "diligent-rat-760": 908698, + "handsome-parakeet-802": 908699, + "gallant-emu-3": 908711, + "different-lemur-165": 908729, + "hip-oyster-829": 908730, + "first-bobcat-545": 908730, + "upbeat-octopus-171": 908805, + "bright-lapwing-71": 908806, + "modest-penguin-287": 908922, + "neat-wolf-489": 908923, + "wandering-pelican-615": 908923, + "robust-curlew-837": 908962, + "youthful-heron-259": 908963, + "tame-ostrich-759": 908963, + "friendly-crow-95": 909006, + "uncommon-deer-552": 909007, + "courteous-sardine-102": 909075, + "brainy-iguana-230": 909077, + "blessed-panther-520": 909187, + "hip-shepherd-190": 909188, + "superb-gnat-301": 909188, + "zany-octopus-885": 909267, + "glorious-bullfrog-321": 909269, + "terrific-dachshund-179": 909329, + "marvelous-corgi-477": 909330, + "rare-parakeet-794": 909330, + "bold-nightingale-863": 909333, + "reminiscent-sandpiper-411": 909334, + "accomplished-bison-826": 909360, + "agreeable-chinchilla-432": 909364, + "silent-dalmatian-942": 909370, + "sensible-gnu-391": 909374, + "opulent-marmot-103": 909377, + "careful-goldfish-143": 909382, + "ceaseless-antelope-528": 909385, + "wry-walrus-859": 909386, + "precise-sockeye-223": 909390, + "determined-stork-260": 909393, + "capable-kiwi-825": 909394, + "canny-marten-760": 909395, + "tangible-gnat-711": 909397, + "secret-anteater-169": 909401, + "perfect-crow-322": 909402, + "rightful-marlin-593": 909404, + "groovy-alpaca-10": 909405, + "elated-badger-265": 909407, + "neighborly-lyrebird-715": 909408, + "capable-gull-434": 909410, + "reminiscent-elephant-404": 909413, + "groovy-mockingbird-354": 909415, + "grandiose-elephant-164": 909417, + "giant-rabbit-309": 909419, + "savory-avocet-293": 909424, + "loyal-caiman-267": 909427, + "colorless-okapi-550": 909429, + "incredible-peccary-71": 909436, + "accomplished-lion-238": 909439, + "befitting-goldfish-795": 909441, + "keen-iguana-530": 909444, + "fine-crane-666": 909449, + "fiery-hedgehog-338": 909454, + "fearless-mallard-38": 909456, + "brainy-cormorant-826": 909458, + "grand-pony-709": 909460, + "beloved-bobcat-818": 909463, + "impartial-echidna-58": 909465, + "rugged-sparrow-749": 909467, + "formal-impala-10": 909471, + "descriptive-rat-741": 909474, + "wonderful-otter-166": 909479, + "intent-armadillo-765": 909485, + "brilliant-chicken-297": 909486, + "lovely-echidna-22": 909488, + "aromatic-stoat-56": 909491, + "tremendous-dragon-290": 909495, + "energized-wren-337": 909498, + "aware-bee-117": 909502, + "honorable-dog-764": 909503, + "wonderful-swan-371": 909507, + "sleek-anteater-257": 909510, + "hushed-weasel-706": 909512, + "outgoing-manatee-916": 909516, + "watchful-porpoise-477": 909518, + "acoustic-bison-173": 909524, + "mild-peccary-82": 909529, + "mild-hummingbird-877": 909535, + "good-scorpion-295": 909537, + "disciplined-rook-195": 909540, + "colorful-pika-896": 909543, + "insightful-cassowary-233": 909546, + "fortunate-cow-95": 909548, + "scrupulous-shepherd-949": 909549, + "earnest-husky-642": 909554, + "dusty-basilisk-307": 909557, + "determined-finch-122": 909560, + "confident-kingfisher-691": 909692, + "clean-gopher-316": 909693, + "tangible-wren-37": 909937, + "exciting-pigeon-735": 909939, + "polished-kudu-171": 909939, + "harmless-firefly-715": 910011, + "whimsical-sparrow-837": 910012, + "fine-bee-797": 910024, + "robust-tiger-594": 910025, + "whimsical-mouse-977": 910146, + "limitless-goat-560": 910147, + "deafening-quail-219": 910147, + "expert-skunk-906": 910165, + "graceful-stork-928": 910166, + "superb-pheasant-790": 910255, + "dazzling-ant-64": 910256, + "fearless-sardine-437": 910264, + "whimsical-dinosaur-92": 910265, + "resolute-peccary-433": 910266, + "wooden-ibis-354": 910267, + "steady-sheep-341": 910285, + "adamant-tiger-792": 910286, + "little-perch-655": 910287, + "steady-hyena-79": 910372, + "qualified-eel-887": 910373, + "disciplined-perch-806": 910379, + "blessed-aardvark-188": 910380, + "small-coyote-506": 910440, + "handsome-loris-243": 910441, + "aware-dove-929": 910469, + "perfect-rabbit-665": 910470, + "silent-goldfinch-48": 910481, + "steady-beagle-528": 910482, + "bold-butterfly-846": 910485, + "descriptive-manatee-145": 910486, + "secret-ram-406": 910515, + "next-chickadee-254": 910516, + "energetic-quail-875": 910582, + "groovy-hamster-788": 910583, + "quaint-lapwing-884": 910611, + "glorious-lyrebird-648": 910612, + "enduring-sockeye-305": 910629, + "ideal-hound-378": 910630, + "enchanted-otter-194": 910640, + "focused-eel-346": 910641, + "marvelous-chickadee-522": 910647, + "adept-vulture-942": 910648, + "quaint-lyrebird-13": 910665, + "polite-ox-516": 910666, + "compassionate-hummingbird-963": 910691, + "limitless-snail-694": 910694, + "stoic-tern-728": 910777, + "shiny-goose-674": 910778, + "exuberant-partridge-142": 910835, + "tacit-lemur-310": 910836, + "good-wren-340": 911157, + "cheery-ibis-621": 911159, + "amicable-curlew-438": 911161, + "kindly-setter-880": 911162, + "adorable-ermine-155": 911196, + "flippant-sheep-34": 911197, + "tacit-moose-655": 911197, + "coordinated-yak-778": 911214, + "unique-mockingbird-438": 911217, + "woozy-chicken-75": 911306, + "earnest-bandicoot-392": 911307, + "tough-opossum-470": 911310, + "kindred-kingfisher-236": 911311, + "cool-perch-645": 911311, + "reliable-swordfish-627": 911378, + "wandering-scorpion-114": 911379, + "fortunate-rabbit-291": 911379, + "marvelous-dodo-961": 911414, + "wandering-mastiff-612": 911415, + "calm-retriever-358": 911431, + "neighborly-hamster-988": 911432, + "brainy-albatross-899": 911460, + "tremendous-starfish-235": 911461, + "lovable-owl-471": 911477, + "tangible-vulture-510": 911478, + "fleet-toucan-166": 911478, + "upbeat-lapwing-981": 911643, + "hardy-ibis-272": 911644, + "resolute-gazelle-118": 911684, + "determined-jay-462": 911685, + "joyous-eagle-371": 911793, + "unique-rhinoceros-458": 911796, + "focused-warthog-606": 911806, + "clever-trout-847": 911808, + "fearless-hummingbird-628": 911808, + "adjoining-porpoise-290": 911959, + "festive-gazelle-818": 911960, + "scrupulous-bat-61": 911960, + "standing-antelope-280": 912000, + "earnest-fox-678": 912001, + "amiable-squid-950": 912017, + "quirky-possum-484": 912018, + "vibrant-schnauzer-221": 912036, + "insightful-tiger-319": 912037, + "tame-cobra-458": 912127, + "adventurous-crocodile-473": 912129, + "handsome-eel-75": 912162, + "animated-tern-925": 912163, + "quixotic-jaguar-913": 912215, + "acoustic-guineapig-230": 912216, + "pleasant-whale-142": 912216, + "loyal-cat-112": 912292, + "secret-raccoon-356": 912295, + "superb-kudu-690": 912508, + "focused-mosquito-439": 912510, + "little-grasshopper-52": 912556, + "impressive-anteater-445": 912557, + "energized-rooster-421": 912570, + "next-bobcat-932": 912571, + "strong-dog-220": 912632, + "helpful-axolotl-647": 912633, + "descriptive-roadrunner-93": 912776, + "clean-hamster-819": 912777, + "pleasant-ibex-533": 912818, + "scintillating-elk-944": 912819, + "adjoining-koala-439": 912819, + "uncommon-sardine-791": 913019, + "affable-mandrill-673": 913020, + "notable-caiman-318": 913033, + "artful-elephant-44": 913035, + "outgoing-lemur-144": 913035, + "colorful-pony-411": 913144, + "agile-lyrebird-893": 913145, + "impressive-octopus-475": 913148, + "posh-bee-621": 913150, + "trustworthy-hyena-524": 913151, + "abundant-sturgeon-749": 913153, + "energetic-opossum-526": 913155, + "brainy-badger-636": 913161, + "proper-cardinal-471": 913223, + "loyal-crocodile-49": 913224, + "merry-tern-904": 913243, + "patient-scorpion-719": 913244, + "colorless-peccary-190": 913254, + "modest-wolf-713": 913256, + "fastidious-hippopotamus-748": 913256, + "earnest-marlin-920": 913286, + "agreeable-ocelot-15": 913287, + "resolute-bat-235": 913287, + "majestic-alpaca-203": 913302, + "dutiful-gazelle-768": 913303, + "upbeat-newt-985": 913303, + "academic-squirrel-774": 913304, + "wonderful-raven-923": 913305, + "neat-peacock-120": 913334, + "graceful-stingray-871": 913335, + "benevolent-flamingo-178": 913362, + "warmhearted-fennec-84": 913363, + "cautious-manatee-543": 913382, + "notable-bandicoot-324": 913383, + "knowing-pigeon-137": 913433, + "hidden-lemming-693": 913468, + "abundant-caribou-125": 913469, + "lovely-horse-866": 913502, + "fabulous-cassowary-723": 913503, + "perfect-peacock-844": 913533, + "fearless-lion-416": 913559, + "tame-guanaco-916": 913560, + "cheery-wolverine-222": 913560, + "nautical-swordfish-715": 913567, + "rare-hyena-311": 913568, + "clever-condor-353": 913570, + "lovely-shepherd-636": 913571, + "hip-dachshund-445": 913573, + "keen-poodle-512": 913583, + "chatty-firefly-925": 913584, + "neighborly-crane-364": 913764, + "healthy-whale-170": 913765, + "scrupulous-dove-827": 913765, + "beloved-clownfish-462": 913785, + "ceaseless-caterpillar-893": 913786, + "accurate-shark-790": 913786, + "successful-bee-849": 913841, + "amicable-scorpion-66": 913842, + "proper-bear-529": 913847, + "focused-goat-168": 913849, + "notable-antelope-928": 913878, + "original-marten-814": 913879, + "strong-bobcat-435": 913889, + "perceptive-clam-859": 913890, + "descriptive-meerkat-414": 913913, + "compassionate-stork-289": 913915, + "abundant-fish-309": 913925, + "standing-swordfish-778": 913926, + "kindly-grouse-82": 913935, + "uncommon-firefly-658": 913936, + "superb-marmot-265": 913951, + "amicable-chicken-137": 913952, + "hushed-gerbil-83": 914013, + "gallant-pika-981": 914014, + "amiable-meerkat-406": 914065, + "artful-mosquito-16": 914066, + "affable-swordfish-538": 914070, + "decisive-raccoon-71": 914071, + "resilient-dachshund-489": 914196, + "stoic-stoat-741": 914197, + "spotted-poodle-715": 914275, + "hallowed-emu-878": 914276, + "steady-stingray-921": 914278, + "unique-stork-299": 914279, + "resolute-falcon-854": 914281, + "focused-anaconda-512": 914282, + "accomplished-akita-234": 914282, + "exciting-dragon-752": 914294, + "agreeable-egret-887": 914296, + "adventurous-flamingo-847": 914296, + "ceaseless-mouse-792": 914356, + "valuable-kiwi-3": 914357, + "content-cormorant-988": 914357, + "hidden-alpaca-258": 914375, + "cheerful-lark-554": 914376, + "fastidious-alpaca-521": 914376, + "agile-elephant-141": 914566, + "animated-marten-810": 914567, + "quirky-chinchilla-109": 914591, + "grand-retriever-217": 914592, + "basic-labrador-980": 914592, + "first-lynx-195": 914600, + "rugged-penguin-282": 914601, + "affable-grouse-770": 914603, + "hidden-rook-383": 914604, + "original-partridge-463": 914639, + "handsome-sockeye-491": 914640, + "impressive-raven-664": 914646, + "fiery-deer-752": 914647, + "elegant-ostrich-333": 914650, + "greedy-sparrow-345": 914651, + "optimistic-pony-661": 914698, + "cool-ibex-932": 914699, + "exciting-bee-582": 914728, + "veracious-gecko-963": 914729, + "graceful-kiwi-713": 914739, + "energetic-sardine-192": 914740, + "impartial-cardinal-177": 914745, + "graceful-mouse-958": 914746, + "flexible-wren-9": 914746, + "warmhearted-sockeye-299": 914758, + "friendly-hare-571": 914759, + "strong-magpie-685": 914759, + "bright-llama-916": 914777, + "groovy-possum-46": 914778, + "effervescent-spider-99": 914828, + "astute-lynx-441": 914829, + "calm-guineapig-265": 914874, + "affable-swan-242": 914876, + "adorable-bandicoot-190": 914885, + "accurate-clownfish-490": 914886, + "watchful-woodpecker-609": 914893, + "industrious-owl-230": 914894, + "descriptive-goldfish-936": 914910, + "friendly-rook-658": 914911, + "calm-hamster-865": 915017, + "exciting-magpie-378": 915019, + "friendly-wren-670": 915026, + "quick-viper-758": 915027, + "agile-hyena-202": 915036, + "lovable-pelican-860": 915037, + "hearty-wombat-430": 915077, + "bold-manatee-732": 915078, + "trustworthy-panther-276": 915078, + "coordinated-dogfish-895": 915096, + "grandiose-okapi-845": 915097, + "wonderful-clam-758": 915097, + "scrupulous-quail-676": 915127, + "scrupulous-hyena-810": 915128, + "glad-terrier-825": 915131, + "utmost-boar-151": 915133, + "silent-bulldog-887": 915133, + "optimistic-tortoise-401": 915166, + "energized-toucan-117": 915167, + "lovely-hamster-678": 915167, + "joyous-lyrebird-470": 915171, + "grandiose-cat-130": 915172, + "flippant-bobcat-63": 915177, + "tough-mammoth-599": 915178, + "ideal-nightingale-464": 915190, + "fast-capybara-816": 915191, + "giant-mockingbird-288": 915195, + "doting-cuttlefish-1": 915196, + "good-dodo-928": 915197, + "secret-salmon-460": 915198, + "oceanic-kangaroo-301": 915205, + "gregarious-otter-909": 915206, + "judicious-husky-361": 915246, + "knowing-gopher-269": 915247, + "outstanding-gnu-257": 915247, + "affable-panda-87": 915253, + "ardent-cow-790": 915254, + "avid-pheasant-330": 915275, + "woozy-jackal-74": 915278, + "tacit-goldfinch-639": 915288, + "vibrant-bulldog-990": 915289, + "proficient-tortoise-243": 915374, + "hallowed-snail-429": 915375, + "polite-bloodhound-415": 915378, + "elegant-swordfish-599": 915379, + "expert-civet-650": 915387, + "diligent-bee-295": 915388, + "dusty-pony-496": 915395, + "striped-dogfish-271": 915396, + "fabulous-orca-774": 915428, + "merry-condor-838": 915429, + "ideal-ibex-979": 915458, + "steady-mammoth-286": 915459, + "acoustic-kookabura-60": 915484, + "charming-bulldog-707": 915485, + "enchanted-perch-280": 915490, + "grand-leopard-279": 915491, + "spotted-wildcat-701": 915517, + "pastel-armadillo-206": 915518, + "usable-elk-949": 915525, + "aromatic-rhinoceros-151": 915526, + "admired-frog-553": 915553, + "quiet-antelope-124": 915554, + "tangible-mammoth-56": 915587, + "neat-marten-365": 915589, + "adamant-quail-78": 915736, + "enduring-canary-301": 915737, + "curious-goldfish-376": 915756, + "warmhearted-trout-357": 915757, + "lovely-cow-403": 915757, + "careful-lemming-47": 915760, + "hip-falcon-360": 915761, + "warmhearted-sandpiper-379": 915773, + "opulent-bison-139": 915774, + "upbeat-caiman-945": 915811, + "pastel-koala-905": 915813, + "incredible-mongoose-461": 915828, + "cheerful-shepherd-837": 915829, + "shocking-shark-494": 915829, + "youthful-wolf-904": 915866, + "rare-pigeon-339": 915867, + "blissful-meerkat-475": 915868, + "academic-tortoise-141": 915869, + "fearless-cobra-992": 915877, + "dapper-grasshopper-694": 915878, + "proper-goshawk-593": 915891, + "charming-lemur-326": 915892, + "rosy-hummingbird-432": 915932, + "elated-seahorse-495": 915934, + "enchanted-squirrel-798": 916013, + "clear-moose-81": 916014, + "small-guineapig-690": 916093, + "basic-leopard-768": 916094, + "careful-jaguar-282": 916094, + "mild-gazelle-504": 916104, + "exuberant-hound-255": 916105, + "tremendous-guanaco-84": 916148, + "kindred-heron-636": 916149, + "precise-skunk-89": 916151, + "necessary-possum-118": 916153, + "laudable-bullfrog-871": 916153, + "disciplined-buzzard-51": 916178, + "benevolent-bullfrog-991": 916179, + "avid-finch-910": 916195, + "valuable-beagle-618": 916196, + "dashing-cricket-718": 916210, + "aware-hare-45": 916211, + "moonlit-roadrunner-151": 916211, + "doting-buzzard-52": 916214, + "precious-marten-292": 916216, + "youthful-capybara-935": 916253, + "strong-toucan-214": 916254, + "blessed-penguin-436": 916254, + "acoustic-meerkat-358": 916447, + "adamant-squirrel-77": 916451, + "accomplished-antelope-769": 916496, + "watchful-reindeer-967": 916497, + "insightful-cheetah-281": 916503, + "bright-dalmatian-973": 916504, + "adept-robin-507": 916609, + "combative-bullfrog-393": 916611, + "uncommon-raccoon-198": 916628, + "focused-panda-631": 916629, + "brazen-butterfly-721": 916703, + "posh-pony-299": 916705, + "elated-labrador-294": 916714, + "accurate-hamster-474": 916715, + "energetic-lynx-894": 916744, + "clean-weasel-641": 916887, + "veracious-bandicoot-203": 916888, + "posh-magpie-196": 916915, + "quick-kookabura-292": 916916, + "terrific-orca-572": 916945, + "wry-ant-272": 916946, + "laudable-bison-739": 916959, + "proficient-dove-945": 916960, + "cheerful-coyote-182": 916975, + "adjoining-ermine-603": 916977, + "reliable-gopher-809": 916990, + "agreeable-starfish-298": 916991, + "deafening-goose-421": 917051, + "secret-ox-601": 917052, + "kindly-goldfinch-735": 917052, + "dazzling-gnu-75": 917149, + "reminiscent-basilisk-224": 917150, + "befitting-monitor-635": 917151, + "clean-gerbil-320": 917153, + "bold-bandicoot-359": 917303, + "rugged-hippopotamus-614": 917304, + "accomplished-kudu-51": 917313, + "tidy-starling-873": 917314, + "intent-minnow-65": 917386, + "disciplined-mammoth-84": 917387, + "quixotic-spider-888": 917412, + "dynamic-dove-256": 917413, + "resolute-chicken-70": 917413, + "dynamic-marlin-374": 917446, + "flexible-deer-640": 917448, + "wry-wildcat-630": 917466, + "notable-malamute-219": 917468, + "peaceful-skunk-830": 917468, + "modest-hamster-523": 917514, + "kindhearted-spider-472": 917515, + "optimistic-heron-769": 917542, + "tacit-nightingale-227": 917543, + "dutiful-cheetah-666": 917543, + "opulent-ox-952": 917558, + "abundant-gecko-942": 917560, + "combative-mule-103": 917590, + "colorful-quail-970": 917591, + "animated-stingray-375": 917607, + "scrupulous-reindeer-879": 917608, + "beaming-roadrunner-390": 917643, + "scrupulous-yak-88": 917657, + "uncommon-tiger-538": 917658, + "intent-hornet-185": 917679, + "colorful-oriole-978": 917684, + "proficient-frog-716": 917685, + "quiet-hornet-883": 917685, + "tough-fly-171": 917734, + "formal-sardine-415": 917735, + "enchanted-okapi-396": 917755, + "loyal-rooster-451": 917756, + "expert-bulldog-521": 917767, + "outgoing-wren-889": 917768, + "moonlit-mockingbird-803": 917837, + "neat-lion-319": 917838, + "perceptive-puffin-871": 917838, + "nautical-meadowlark-560": 917852, + "cheery-goose-51": 917853, + "optimistic-clam-456": 917891, + "admired-weasel-259": 917892, + "gallant-bird-93": 917907, + "posh-cow-97": 917908, + "tame-goldfish-417": 917917, + "standing-schnauzer-45": 917918, + "aware-civet-952": 917919, + "clear-finch-64": 917919, + "charming-cormorant-338": 917928, + "quixotic-lobster-693": 917929, + "dutiful-armadillo-830": 917932, + "terrific-malamute-794": 917933, + "upbeat-mallard-894": 917941, + "zany-bloodhound-34": 917942, + "mild-barracuda-66": 917980, + "chatty-ferret-280": 917981, + "jovial-orca-925": 917992, + "lovable-deer-883": 917993, + "resolute-gazelle-593": 917999, + "brave-curlew-316": 918000, + "tame-okapi-342": 918000, + "enduring-warbler-875": 918020, + "sleek-coyote-196": 918021, + "precious-ostrich-607": 918026, + "descriptive-reindeer-470": 918027, + "dusty-peacock-273": 918078, + "charming-albatross-977": 918079, + "pleasant-ocelot-882": 918116, + "rare-squid-576": 918117, + "calm-swan-532": 918122, + "descriptive-ferret-156": 918123, + "harmless-bat-448": 918153, + "neighborly-mole-781": 918154, + "astute-ibex-307": 918167, + "grandiose-ptarmigan-550": 918168, + "trustworthy-trout-825": 918168, + "dapper-echidna-205": 918231, + "sensible-canary-384": 918232, + "brave-jackal-919": 918238, + "knowing-seal-554": 918239, + "sincere-echidna-868": 918240, + "wooden-dachshund-245": 918241, + "impressive-pig-920": 918248, + "reliable-egret-491": 918249, + "fine-rook-798": 918249, + "avid-albatross-584": 918256, + "combative-cricket-977": 918257, + "combative-bee-508": 918266, + "kindhearted-penguin-575": 918267, + "fabulous-peccary-23": 918300, + "strong-terrier-777": 918302, + "perceptive-raccoon-215": 918310, + "reliable-kiwi-387": 918311, + "giant-bison-732": 918320, + "beaming-mastiff-314": 918321, + "upbeat-spaniel-520": 918321, + "dutiful-civet-272": 918326, + "artful-malamute-645": 918327, + "judicious-goose-865": 918327, + "strong-heron-29": 918334, + "reliable-weasel-424": 918335, + "ceaseless-mammoth-86": 918336, + "agreeable-marten-704": 918337, + "canny-salmon-107": 918342, + "friendly-pelican-591": 918343, + "warmhearted-mammoth-388": 918351, + "formal-finch-29": 918352, + "shocking-canary-960": 918352, + "fastidious-toad-24": 918433, + "polished-ostrich-361": 918435, + "tangible-blackbird-142": 918535, + "amiable-giraffe-761": 918536, + "shocking-jackal-849": 918537, + "affable-scorpion-266": 918538, + "fastidious-opossum-175": 918554, + "adamant-antelope-289": 918555, + "secret-hamster-908": 918578, + "sensible-penguin-751": 918579, + "nautical-grasshopper-491": 918607, + "opulent-skunk-505": 918608, + "exuberant-pelican-641": 918608, + "perceptive-donkey-68": 918609, + "efficient-pelican-539": 918610, + "adjoining-magpie-765": 918704, + "neighborly-magpie-980": 918705, + "coordinated-cow-618": 918736, + "exuberant-grouse-462": 918737, + "steady-pig-116": 918742, + "patient-hornet-884": 918743, + "optimistic-flamingo-562": 918774, + "cautious-raccoon-914": 918775, + "animated-hound-39": 918874, + "confident-moose-147": 918875, + "zealous-magpie-460": 918911, + "exuberant-monitor-948": 918912, + "marvelous-cobra-748": 918924, + "giddy-magpie-85": 918925, + "cool-bee-592": 918927, + "accomplished-buffalo-123": 918928, + "charming-boar-558": 918950, + "neighborly-monitor-166": 918951, + "calm-shepherd-313": 919104, + "animated-puma-903": 919106, + "ideal-akita-954": 919138, + "effervescent-clam-405": 919139, + "moonlit-basilisk-477": 919167, + "cheerful-tiger-456": 919168, + "earnest-starling-214": 919183, + "abundant-salamander-459": 919184, + "grand-warthog-783": 919189, + "incredible-lobster-591": 919190, + "intent-canary-510": 919190, + "first-minnow-20": 919204, + "keen-fish-118": 919205, + "dynamic-llama-554": 919213, + "effervescent-monitor-943": 919214, + "reliable-malamute-523": 919224, + "grandiose-oriole-619": 919225, + "clean-fly-629": 919285, + "abundant-crab-159": 919286, + "grandiose-mink-182": 919300, + "glad-roadrunner-274": 919301, + "watchful-alligator-316": 919317, + "disciplined-bird-773": 919318, + "colorless-emu-351": 919318, + "pastel-caterpillar-902": 919342, + "cautious-ptarmigan-740": 919343, + "oceanic-crab-204": 919379, + "enduring-sockeye-69": 919380, + "earnest-oyster-447": 919431, + "benevolent-guanaco-747": 919432, + "next-ox-715": 919458, + "artful-marten-751": 919459, + "agile-chinchilla-731": 919466, + "hallowed-eel-655": 919467, + "scintillating-pigeon-894": 919519, + "shocking-pigeon-761": 919520, + "valuable-bird-382": 919520, + "decisive-beagle-832": 919572, + "cheerful-malamute-263": 919573, + "dapper-albatross-845": 919608, + "benevolent-ferret-261": 919609, + "impartial-rabbit-287": 919646, + "small-stoat-426": 919647, + "festive-armadillo-261": 919832, + "flexible-echidna-826": 919833, + "agreeable-grasshopper-962": 919888, + "clever-roadrunner-125": 919889, + "hardy-woodpecker-578": 919916, + "harmless-stoat-880": 919917, + "aromatic-crocodile-344": 919931, + "dynamic-pheasant-765": 919932, + "kindred-clownfish-183": 919954, + "adventurous-albatross-672": 919955, + "amicable-platypus-101": 919968, + "insightful-bear-485": 919975, + "clean-trout-964": 919976, + "earnest-koala-884": 919976, + "cautious-barracuda-263": 919987, + "tough-lemming-856": 919988, + "scintillating-minnow-230": 920003, + "effervescent-ladybug-889": 920032, + "brilliant-rooster-559": 920033, + "capable-mastiff-643": 920053, + "accomplished-quail-855": 920054, + "first-clam-844": 920054, + "precious-sheep-412": 920105, + "resolute-stingray-926": 920106, + "avid-ibis-383": 920135, + "ardent-dodo-611": 920137, + "qualified-frog-883": 920162, + "standing-toucan-203": 920186, + "friendly-kudu-796": 920197, + "focused-kookabura-513": 920198, + "groovy-squirrel-861": 920203, + "superb-alpaca-865": 920238, + "doting-snake-288": 920239, + "acoustic-tapir-737": 920282, + "optimistic-ferret-361": 920284, + "wonderful-wombat-350": 920294, + "festive-bison-273": 920295, + "mild-swordfish-755": 920297, + "valiant-ibis-449": 920298, + "acrobatic-opossum-428": 920354, + "artful-turtle-262": 920355, + "amicable-salmon-939": 920375, + "valiant-raccoon-960": 920376, + "grand-hippopotamus-120": 920380, + "first-horse-967": 920381, + "diligent-gopher-633": 920389, + "secret-toad-836": 920390, + "tidy-ibis-488": 920405, + "third-turtle-679": 920406, + "ideal-ferret-645": 920407, + "sleek-lion-461": 920408, + "whimsical-weasel-478": 920412, + "impressive-hound-449": 920413, + "wary-pheasant-103": 920451, + "proficient-firefly-312": 920452, + "rapid-schnauzer-354": 920452, + "posh-horse-143": 920466, + "quirky-octopus-743": 920467, + "valuable-rooster-668": 920471, + "dependable-peacock-307": 920472, + "exciting-bass-400": 920488, + "cautious-narwhal-877": 920489, + "beloved-marlin-869": 920506, + "wandering-echidna-324": 920507, + "reminiscent-vulture-656": 920511, + "diligent-herring-363": 920512, + "flexible-lion-942": 920565, + "little-chicken-739": 920566, + "keen-skunk-370": 920566, + "vivid-woodpecker-592": 920568, + "impartial-pelican-218": 920569, + "quaint-toucan-784": 920569, + "flippant-goldfish-84": 920592, + "chatty-ibex-407": 920593, + "wandering-meerkat-167": 920600, + "posh-giraffe-363": 920601, + "fortunate-fennec-803": 920660, + "basic-rooster-35": 920661, + "glad-goose-320": 920670, + "optimistic-viper-290": 920671, + "qualified-dogfish-346": 920710, + "fine-finch-666": 920711, + "uncommon-salmon-445": 920765, + "perceptive-fox-707": 920766, + "sensible-curlew-33": 920766, + "quirky-woodpecker-99": 920829, + "first-goshawk-863": 920831, + "spotted-pony-544": 920866, + "rightful-cricket-745": 920867, + "moonlit-mongoose-895": 920883, + "cautious-corgi-912": 920884, + "combative-albatross-474": 920950, + "sensible-axolotl-818": 920951, + "majestic-lion-870": 920967, + "dutiful-mosquito-813": 920969, + "charming-goat-842": 921072, + "honorable-caribou-827": 921074, + "hardy-bobcat-263": 921135, + "cautious-setter-813": 921136, + "energetic-hyena-493": 921156, + "energetic-ermine-280": 921157, + "warmhearted-toad-941": 921178, + "grand-anaconda-53": 921180, + "brilliant-peccary-78": 921231, + "formal-koala-257": 921232, + "deafening-mallard-416": 921238, + "greedy-crane-451": 921239, + "ardent-hyena-542": 921242, + "successful-dolphin-334": 921243, + "useful-schnauzer-762": 921243, + "sleek-hedgehog-780": 921249, + "incredible-viper-93": 921250, + "frugal-chinchilla-613": 921269, + "tidy-spaniel-901": 921270, + "grateful-hippopotamus-113": 921284, + "courteous-snake-546": 921285, + "honorable-ocelot-938": 921286, + "groovy-platypus-590": 921287, + "hip-cod-622": 921288, + "majestic-firefly-10": 921289, + "courteous-gnat-996": 921291, + "cool-lyrebird-702": 921292, + "perfect-finch-358": 921293, + "aromatic-bobcat-681": 921293, + "elated-bass-186": 921294, + "descriptive-bison-209": 921299, + "next-sparrow-115": 921300, + "stoic-chihuahua-470": 921300, + "flexible-gazelle-885": 921305, + "different-mongoose-279": 921306, + "next-setter-769": 921306, + "first-sheep-887": 921308, + "precious-grouse-347": 921309, + "bold-swan-222": 921322, + "elated-sandpiper-666": 921323, + "unique-panda-822": 921323, + "vibrant-loris-112": 921332, + "admired-bloodhound-252": 921333, + "lovely-crocodile-231": 921334, + "beaming-seal-564": 921337, + "first-seahorse-978": 921337, + "chatty-buzzard-332": 921361, + "effervescent-retriever-988": 921362, + "curious-bat-829": 921381, + "fearless-cardinal-325": 921382, + "rapid-mammoth-421": 921391, + "limitless-sparrow-644": 921392, + "aromatic-gnat-131": 921493, + "kindred-vole-741": 921494, + "aromatic-kangaroo-506": 921581, + "strong-dove-263": 921582, + "kindred-possum-992": 921590, + "fantastic-sardine-642": 921591, + "valuable-finch-844": 921612, + "perfect-quail-663": 921613, + "knowing-avocet-553": 921613, + "effervescent-chameleon-407": 921618, + "utmost-squirrel-463": 921619, + "prestigious-kangaroo-169": 921663, + "perfect-firefly-330": 921664, + "content-oriole-285": 921669, + "bold-yak-972": 921670, + "enchanted-emu-968": 921670, + "shocking-mammoth-787": 921727, + "loyal-mosquito-385": 921728, + "careful-lapwing-603": 921787, + "amiable-crane-953": 921788, + "tremendous-wren-82": 921824, + "woozy-axolotl-841": 921825, + "beaming-corgi-396": 921832, + "ceaseless-ferret-240": 921833, + "watchful-crow-735": 921833, + "focused-salmon-658": 921834, + "insightful-alligator-683": 921836, + "dashing-donkey-812": 921850, + "elated-dinosaur-962": 921851, + "admired-civet-905": 921864, + "lovely-ostrich-243": 921866, + "unique-iguana-75": 921866, + "vivid-toucan-759": 921934, + "nautical-corgi-768": 921935, + "flexible-aardvark-628": 921936, + "groovy-mole-604": 921937, + "proficient-starfish-792": 921937, + "adept-blackbird-447": 921938, + "kindly-ocelot-253": 921939, + "fine-vole-465": 921963, + "graceful-manatee-215": 921964, + "posh-pigeon-960": 921964, + "harmless-warbler-588": 922085, + "accomplished-snake-919": 922086, + "vibrant-kingfisher-196": 922086, + "upbeat-trout-349": 922109, + "kindly-raccoon-857": 922110, + "tame-rat-438": 922173, + "accomplished-dogfish-739": 922174, + "canny-penguin-502": 922174, + "efficient-iguana-163": 922189, + "doting-opossum-625": 922190, + "beloved-ram-180": 922227, + "ceaseless-peacock-884": 922228, + "kindred-magpie-82": 922312, + "sincere-dolphin-502": 922313, + "qualified-monitor-404": 922404, + "determined-whale-46": 922405, + "astute-crane-949": 922412, + "rapid-llama-994": 922413, + "adorable-ladybug-372": 922416, + "disciplined-wren-642": 922418, + "shiny-trout-114": 922418, + "grandiose-hummingbird-936": 922446, + "neighborly-mockingbird-236": 922447, + "posh-elephant-594": 922605, + "patient-retriever-573": 922606, + "lovable-ocelot-130": 922610, + "artful-butterfly-365": 922611, + "whimsical-bison-808": 922619, + "little-crow-682": 922620, + "tame-shark-817": 922631, + "quirky-capybara-187": 922632, + "hallowed-koala-164": 922779, + "kindly-penguin-484": 922780, + "mellow-lemming-173": 922782, + "adorable-axolotl-310": 922783, + "brave-bobcat-400": 922783, + "befitting-herring-768": 922843, + "keen-raccoon-403": 922844, + "helpful-shark-394": 922874, + "silent-tortoise-837": 922875, + "cautious-gnu-747": 922905, + "affable-wren-562": 922906, + "gallant-cod-863": 922934, + "accomplished-emu-606": 922935, + "peaceful-eel-551": 922941, + "lovable-minnow-583": 922943, + "merry-kangaroo-399": 922946, + "silent-starling-34": 922947, + "polite-stork-238": 923019, + "energized-jaguar-486": 923052, + "dependable-puma-788": 923053, + "determined-parrot-794": 923053, + "shiny-chameleon-662": 923081, + "rapid-rook-947": 923082, + "effervescent-setter-922": 923082, + "agile-ram-719": 923231, + "handsome-setter-867": 923232, + "hip-gull-52": 923232, + "confident-dove-42": 923251, + "gallant-bloodhound-113": 923252, + "lovable-snake-386": 923252, + "next-salamander-875": 923258, + "majestic-perch-484": 923259, + "reminiscent-mastiff-393": 923259, + "compassionate-fox-690": 923261, + "lovable-guineapig-92": 923262, + "wonderful-mosquito-207": 923262, + "hip-minnow-591": 923290, + "pleasant-anteater-815": 923291, + "agreeable-herring-621": 923357, + "hushed-akita-578": 923358, + "woozy-parrot-344": 923592, + "diligent-buzzard-312": 923593, + "sleek-bird-776": 923658, + "fine-yak-149": 923659, + "curious-ibex-36": 923725, + "precious-pig-725": 923726, + "perceptive-bird-594": 923752, + "ceaseless-blackbird-153": 923753, + "agile-beagle-699": 923753, + "ideal-lark-180": 923766, + "oceanic-opossum-747": 923767, + "rightful-chihuahua-188": 923794, + "neat-terrier-874": 923795, + "grandiose-quail-815": 923849, + "grand-dotterel-535": 923851, + "academic-cuttlefish-2": 923856, + "mild-clam-359": 923857, + "cautious-goat-373": 923888, + "expert-mandrill-171": 923889, + "reliable-oyster-591": 923889, + "rugged-salmon-911": 923898, + "fantastic-snake-931": 923899, + "resolute-condor-689": 923914, + "frugal-spider-974": 923915, + "small-canary-189": 923939, + "standing-bee-645": 923940, + "brainy-stork-55": 923978, + "elegant-canary-211": 923979, + "vivid-grouse-349": 924052, + "agreeable-beagle-375": 924053, + "joyous-finch-693": 924146, + "avid-chinchilla-752": 924147, + "superb-corgi-530": 924154, + "graceful-scorpion-594": 924155, + "neighborly-fly-603": 924166, + "formal-bear-613": 924167, + "oceanic-cobra-708": 924283, + "frugal-dodo-19": 924284, + "ceaseless-trout-741": 924375, + "scintillating-schnauzer-727": 924377, + "insightful-tiger-23": 924397, + "fleet-dachshund-30": 924398, + "greedy-elephant-357": 924398, + "kindhearted-narwhal-995": 924472, + "impartial-perch-452": 924474, + "hidden-canary-514": 924486, + "whimsical-squid-826": 924487, + "quixotic-pony-74": 924505, + "adorable-eel-871": 924506, + "opulent-wildebeest-168": 924511, + "clean-crocodile-863": 924512, + "little-cardinal-335": 924570, + "impressive-loris-929": 924571, + "cautious-puma-885": 924609, + "dusty-dinosaur-849": 924610, + "neighborly-cow-508": 924626, + "brazen-herring-787": 924627, + "resolute-koala-541": 924637, + "useful-chihuahua-42": 924638, + "spotted-cat-551": 924767, + "acoustic-albatross-245": 924769, + "modest-lemming-634": 924806, + "silent-leopard-72": 924807, + "neat-echidna-859": 924836, + "industrious-whale-22": 924837, + "healthy-panda-290": 924904, + "different-curlew-519": 924905, + "agile-puma-598": 924905, + "impartial-toucan-52": 924906, + "fine-possum-326": 924907, + "sincere-condor-705": 924921, + "pastel-chameleon-542": 924922, + "giddy-anaconda-347": 924936, + "cautious-otter-460": 924937, + "determined-iguana-478": 924950, + "academic-tortoise-812": 924951, + "patient-hare-881": 925076, + "fine-crane-451": 925078, + "charming-hamster-461": 925085, + "determined-octopus-430": 925086, + "spotted-clownfish-55": 925086, + "glorious-caribou-110": 925099, + "dynamic-lark-616": 925101, + "fine-badger-808": 925130, + "brilliant-rhinoceros-287": 925131, + "lovable-parrot-809": 925131, + "hushed-stingray-601": 925133, + "outstanding-blackbird-58": 925134, + "veracious-coyote-623": 925158, + "modest-chickadee-451": 925159, + "wandering-wolf-176": 925161, + "befitting-wren-747": 925163, + "brazen-squid-376": 925186, + "colorless-iguana-176": 925187, + "impressive-flamingo-232": 925207, + "shiny-mallard-884": 925208, + "vibrant-peccary-316": 925212, + "adventurous-nightingale-52": 925213, + "cheerful-dinosaur-372": 925232, + "vivid-chickadee-60": 925233, + "fabulous-ptarmigan-500": 925241, + "kindred-wolf-125": 925242, + "honorable-ibis-339": 925250, + "brave-llama-510": 925251, + "resolute-sockeye-290": 925251, + "brave-marten-459": 925256, + "precious-terrier-646": 925257, + "scintillating-platypus-78": 925304, + "uncommon-impala-525": 925305, + "moonlit-panda-326": 925459, + "ceaseless-peccary-614": 925461, + "lovely-eagle-741": 925473, + "cheery-magpie-30": 925474, + "helpful-barracuda-711": 925474, + "scintillating-crocodile-982": 925866, + "scintillating-mandrill-259": 925867, + "kindhearted-hawk-519": 925894, + "necessary-clam-787": 925895, + "adorable-mink-171": 925895, + "intent-ladybug-609": 925947, + "frugal-lyrebird-499": 925948, + "limitless-hyena-99": 925990, + "graceful-deer-755": 925991, + "greedy-hare-757": 926022, + "small-peccary-356": 926024, + "graceful-gerbil-760": 926141, + "acoustic-shepherd-753": 926142, + "beloved-monitor-573": 926178, + "agreeable-cheetah-767": 926179, + "vibrant-chickadee-831": 926357, + "successful-mink-383": 926358, + "wandering-warthog-90": 926358, + "greedy-weasel-299": 926376, + "cool-squid-442": 926377, + "astute-toucan-194": 926377, + "grand-toucan-739": 926386, + "next-llama-82": 926387, + "lovable-boar-183": 926387, + "rugged-lyrebird-272": 926635, + "ardent-bird-552": 926636, + "handsome-goat-512": 926647, + "curious-cat-872": 926648, + "proficient-raven-458": 926648, + "perceptive-gecko-488": 926682, + "bright-bandicoot-679": 926684, + "clean-horse-963": 926684, + "different-rabbit-912": 926862, + "groovy-boar-923": 926864, + "ardent-fennec-267": 926864, + "rugged-squirrel-787": 926898, + "gallant-goldfish-119": 926899, + "judicious-snail-754": 927080, + "elated-gazelle-786": 927081, + "compassionate-chinchilla-292": 927111, + "cool-alpaca-554": 927112, + "canny-coyote-817": 927167, + "stoic-bobcat-619": 927168, + "giant-llama-124": 927172, + "third-dogfish-276": 927173, + "dusty-wolverine-819": 927175, + "tidy-seahorse-443": 927176, + "original-meadowlark-960": 927260, + "rapid-sandpiper-770": 927261, + "grand-parakeet-238": 927269, + "quiet-shepherd-940": 927270, + "robust-finch-873": 927270, + "amiable-ram-666": 927287, + "harmless-peccary-209": 927288, + "precious-civet-402": 927474, + "hearty-chinchilla-172": 927475, + "brazen-tiger-211": 927487, + "admired-shark-603": 927488, + "cheery-goshawk-692": 927509, + "confident-hippopotamus-282": 927510, + "outstanding-peacock-92": 927537, + "dusty-butterfly-636": 927538, + "honorable-camel-473": 927538, + "unique-labrador-115": 927542, + "tangible-cheetah-482": 927543, + "third-sturgeon-716": 927546, + "courteous-penguin-831": 927547, + "affable-flamingo-424": 927613, + "outstanding-lemming-223": 927615, + "silent-cobra-200": 927619, + "pleasant-rooster-809": 927621, + "tidy-penguin-135": 927673, + "cautious-minnow-318": 927675, + "valuable-heron-56": 927687, + "youthful-ptarmigan-629": 927688, + "dazzling-dinosaur-65": 927750, + "upbeat-barracuda-298": 927751, + "peaceful-flamingo-670": 927793, + "coordinated-quail-590": 927794, + "gregarious-albatross-951": 927902, + "zealous-pony-979": 927903, + "scrupulous-sheep-675": 927939, + "fast-lion-812": 927940, + "blissful-mosquito-29": 927948, + "focused-terrier-362": 927949, + "rosy-penguin-874": 928020, + "neat-cricket-991": 928021, + "lovely-bee-730": 928031, + "curious-hedgehog-982": 928068, + "confident-guineapig-530": 928069, + "standing-coyote-358": 928138, + "fleet-malamute-673": 928139, + "successful-lark-382": 928140, + "usable-ram-327": 928141, + "spotted-hummingbird-760": 928178, + "energetic-capybara-389": 928179, + "savory-tapir-210": 928179, + "elegant-ibis-209": 928198, + "quick-weasel-465": 928199, + "grandiose-salamander-41": 928322, + "silent-crab-380": 928323, + "watchful-marmot-157": 928340, + "benevolent-scorpion-320": 928341, + "utmost-heron-633": 928341, + "perceptive-alligator-319": 928371, + "calm-spider-10": 928372, + "industrious-guanaco-824": 928382, + "sensible-opossum-657": 928383, + "lovely-hawk-711": 928398, + "adventurous-bat-137": 928399, + "aware-parakeet-536": 928399, + "kindly-seahorse-945": 928401, + "standing-manatee-224": 928402, + "vibrant-dachshund-263": 928421, + "fabulous-crow-303": 928422, + "giddy-puma-792": 928464, + "terrific-kangaroo-207": 928465, + "proper-okapi-651": 928508, + "hip-capybara-321": 928509, + "intent-badger-401": 928519, + "loyal-narwhal-156": 928520, + "hip-rat-496": 928520, + "sleek-firefly-736": 928525, + "decisive-ptarmigan-81": 928526, + "bright-cricket-416": 928526, + "quixotic-porcupine-548": 928542, + "clean-mallard-990": 928543, + "avid-akita-934": 928595, + "colorless-cod-598": 928596, + "gregarious-armadillo-928": 928603, + "fine-mockingbird-476": 928604, + "small-robin-471": 928604, + "kindred-dogfish-296": 928755, + "loyal-fennec-116": 928756, + "canny-puma-746": 928808, + "sensible-hamster-905": 928809, + "oceanic-gerbil-885": 928809, + "festive-sheep-186": 928819, + "aromatic-gull-856": 928820, + "befitting-hippopotamus-371": 928838, + "reminiscent-echidna-508": 928839, + "quiet-shark-426": 928847, + "blissful-turtle-887": 928848, + "valiant-elk-421": 928935, + "gallant-greyhound-392": 928936, + "outgoing-gazelle-8": 928936, + "animated-civet-869": 928938, + "joyous-bobcat-354": 928939, + "fabulous-cricket-740": 928939, + "zany-chickadee-560": 928990, + "brainy-anaconda-629": 928991, + "decisive-civet-40": 929001, + "agile-puffin-667": 929002, + "marvelous-raccoon-167": 929026, + "sensible-llama-124": 929027, + "pastel-cow-588": 929134, + "intent-heron-955": 929135, + "harmless-hippopotamus-387": 929157, + "proper-gazelle-435": 929158, + "energetic-retriever-310": 929178, + "striped-peccary-393": 929179, + "prestigious-butterfly-173": 929211, + "compassionate-dachshund-156": 929212, + "precise-albatross-838": 929212, + "proper-peacock-904": 929252, + "elegant-weasel-894": 929253, + "admired-kingfisher-33": 929287, + "dapper-toucan-336": 929288, + "strong-gazelle-517": 929297, + "focused-stingray-346": 929304, + "fast-impala-397": 929304, + "elated-fox-5": 929322, + "pastel-hummingbird-90": 929323, + "dependable-minnow-349": 929339, + "majestic-warbler-543": 929340, + "blessed-hamster-579": 929354, + "sleek-kiwi-463": 929355, + "abundant-hound-261": 929388, + "dusty-ermine-219": 929389, + "pastel-sparrow-497": 929497, + "ceaseless-egret-897": 929498, + "spotted-goat-304": 929498, + "hearty-dotterel-943": 929555, + "coordinated-meadowlark-795": 929556, + "prestigious-ant-235": 929615, + "colorful-goat-416": 929617, + "merry-dove-331": 929642, + "adventurous-possum-601": 929643, + "cheery-caribou-941": 929643, + "mellow-penguin-557": 929751, + "insightful-eagle-868": 929752, + "intent-minnow-368": 929853, + "hidden-wren-611": 929854, + "famous-kookabura-424": 929951, + "stoic-pheasant-251": 929952, + "nautical-bullfrog-197": 929955, + "confident-barracuda-973": 929958, + "standing-capybara-663": 929959, + "quiet-dinosaur-798": 929960, + "tremendous-chickadee-610": 929961, + "helpful-meerkat-46": 929966, + "quirky-eel-266": 929970, + "befitting-weasel-541": 929971, + "wooden-condor-18": 929971, + "trustworthy-hornet-772": 929976, + "affable-octopus-219": 930010, + "combative-starfish-509": 930011, + "watchful-alpaca-830": 930011, + "polite-chinchilla-514": 930084, + "benevolent-alligator-779": 930085, + "confident-squid-254": 930085, + "adventurous-falcon-981": 930088, + "successful-labrador-401": 930089, + "adventurous-jaguar-123": 930090, + "robust-rooster-671": 930090, + "lovely-jaguar-278": 930091, + "blissful-hedgehog-845": 930092, + "grand-oriole-296": 930094, + "rightful-chihuahua-459": 930094, + "perceptive-lion-109": 930160, + "hallowed-guanaco-817": 930161, + "little-panther-193": 930161, + "mild-sandpiper-674": 930186, + "stoic-pigeon-891": 930187, + "affable-turtle-642": 930225, + "helpful-possum-176": 930226, + "rightful-oriole-45": 930273, + "wonderful-ant-380": 930274, + "formal-herring-842": 930308, + "pastel-antelope-386": 930309, + "curious-jackal-955": 930312, + "brazen-rabbit-61": 930313, + "dapper-flamingo-768": 930313, + "uncommon-shepherd-449": 930338, + "rare-starfish-690": 930339, + "perfect-fish-314": 930339, + "proficient-reindeer-881": 930364, + "superb-kookabura-133": 930366, + "outgoing-caterpillar-349": 930403, + "vibrant-fennec-413": 930404, + "strong-sturgeon-889": 930404, + "grandiose-possum-569": 930424, + "hallowed-shark-549": 930425, + "mellow-alligator-23": 930425, + "deafening-dolphin-587": 930447, + "friendly-starling-191": 930448, + "trustworthy-gazelle-669": 930448, + "wary-manatee-118": 930456, + "festive-okapi-563": 930456, + "earnest-boar-14": 930457, + "modest-goat-291": 930457, + "brave-bear-60": 930491, + "wooden-cormorant-13": 930492, + "amiable-marmot-787": 930492, + "intent-snail-516": 930525, + "blessed-rabbit-596": 930525, + "blessed-parrot-2": 930526, + "sensible-wildebeest-94": 930526, + "honorable-pika-580": 930530, + "calm-bear-523": 930530, + "enduring-antelope-626": 930531, + "trustworthy-fox-512": 930531, + "opulent-stingray-713": 930542, + "modest-dolphin-898": 930542, + "amiable-salmon-662": 930576, + "adventurous-cormorant-228": 930577, + "judicious-marten-578": 930577, + "amiable-warbler-538": 930581, + "cheerful-pigeon-450": 930581, + "fast-mammoth-235": 930582, + "exuberant-parakeet-719": 930582, + "astute-perch-296": 930588, + "abundant-ocelot-553": 930588, + "proper-corgi-804": 930589, + "neat-avocet-682": 930589, + "confident-impala-201": 930619, + "jovial-sockeye-951": 930619, + "fine-sparrow-424": 930620, + "rosy-firefly-427": 930620, + "energetic-roadrunner-997": 930639, + "cautious-panther-829": 930639, + "academic-marmot-716": 930640, + "pleasant-donkey-3": 930640, + "famous-grouse-201": 930707, + "dynamic-walrus-916": 930707, + "peaceful-shepherd-314": 930708, + "original-peacock-933": 930708, + "honorable-alpaca-449": 930748, + "original-pika-992": 930748, + "clever-crab-534": 930749, + "fiery-nightingale-759": 930749, + "aware-pika-701": 930832, + "brave-goldfinch-312": 930832, + "artful-pika-325": 930833, + "outstanding-tern-239": 930833, + "grandiose-retriever-19": 930930, + "scintillating-turtle-398": 930930, + "helpful-giraffe-907": 930931, + "gallant-malamute-10": 930931, + "descriptive-husky-722": 930977, + "adventurous-canary-737": 930977, + "colorless-falcon-613": 930978, + "robust-cat-943": 930978, + "modest-lark-797": 930999, + "flippant-gerbil-515": 930999, + "perceptive-ostrich-677": 931064, + "watchful-marten-506": 931064, + "exciting-retriever-541": 931080, + "valiant-wildcat-143": 931080, + "ardent-perch-180": 931081, + "standing-mallard-25": 931081, + "necessary-basilisk-611": 931205, + "deafening-kingfisher-66": 931206, + "cautious-ostrich-720": 931206, + "enchanted-corgi-645": 931212, + "efficient-horse-873": 931212, + "cautious-sandpiper-462": 931213, + "strong-sardine-226": 931213, + "enduring-guineapig-750": 931226, + "stoic-raccoon-737": 931226, + "outgoing-turtle-754": 931227, + "polite-quail-967": 931250, + "aromatic-tern-445": 931251, + "kindhearted-opossum-728": 931251, + "original-pheasant-309": 931308, + "tangible-loris-647": 931308, + "qualified-orca-658": 931309, + "dynamic-dolphin-855": 931309, + "hardy-wolf-611": 931369, + "clear-okapi-620": 931369, + "youthful-cormorant-39": 931370, + "fastidious-kangaroo-179": 931380, + "astute-warbler-129": 931380, + "clever-antelope-760": 931382, + "little-mule-496": 931382, + "aromatic-dotterel-439": 931516, + "proper-duck-340": 931516, + "frugal-rook-55": 931517, + "lovely-yak-33": 931517, + "clear-labrador-153": 931529, + "avid-badger-200": 931529, + "limitless-starfish-507": 931530, + "striped-scorpion-179": 931530, + "giddy-chipmunk-645": 931554, + "graceful-oriole-138": 931554, + "greedy-peacock-926": 931555, + "wooden-llama-183": 931555, + "brave-cassowary-784": 931560, + "zealous-gull-755": 931560, + "kindly-husky-510": 931562, + "superb-wombat-891": 931562, + "secret-bullfrog-9": 931628, + "brainy-albatross-125": 931628, + "befitting-bulldog-57": 931629, + "quaint-bear-905": 931629, + "sleek-capybara-269": 931678, + "benevolent-dove-731": 931678, + "content-trout-226": 931679, + "warmhearted-cricket-635": 931679, + "vivid-cuttlefish-17": 931775, + "frugal-pig-957": 931775, + "good-grouse-293": 931776, + "warmhearted-meerkat-692": 931776, + "bold-bird-168": 931790, + "tough-caterpillar-482": 931790, + "careful-dolphin-852": 931793, + "grand-cod-575": 931793, + "perfect-penguin-396": 931823, + "avid-pelican-554": 931823, + "amiable-sandpiper-792": 931824, + "accomplished-bandicoot-668": 931824, + "admired-raven-240": 931830, + "outstanding-buzzard-164": 931830, + "neighborly-rhinoceros-728": 931831, + "unique-kookabura-818": 931831, + "confident-platypus-43": 931865, + "vibrant-giraffe-150": 931865, + "grandiose-egret-235": 931866, + "affable-toucan-600": 931866, + "lovable-echidna-974": 931899, + "kindred-minnow-458": 931899, + "good-squid-397": 931900, + "graceful-bat-867": 931900, + "ardent-llama-245": 931948, + "loyal-lobster-867": 931948, + "judicious-labrador-728": 931949, + "limitless-swordfish-866": 931949, + "mild-oriole-612": 931969, + "bold-retriever-870": 931969, + "zealous-narwhal-586": 931970, + "fine-sockeye-638": 932040, + "chatty-barracuda-545": 932040, + "optimistic-ferret-488": 932041, + "artful-goat-882": 932041, + "befitting-cuttlefish-964": 932089, + "outstanding-raven-391": 932089, + "wandering-koala-266": 932091, + "animated-lark-272": 932091, + "secret-oriole-784": 932131, + "wooden-fish-658": 932131, + "focused-dog-227": 932133, + "beaming-porpoise-992": 932133, + "wry-koala-506": 932150, + "acoustic-lion-652": 932150, + "canny-squirrel-539": 932151, + "neat-wombat-975": 932151, + "glad-kookabura-986": 932202, + "hip-lynx-144": 932202, + "focused-lyrebird-847": 932203, + "expert-goshawk-993": 932203, + "giant-grasshopper-450": 932260, + "deafening-loris-868": 932260, + "academic-crane-274": 932261, + "judicious-cassowary-509": 932261, + "expert-penguin-770": 932359, + "resolute-hippopotamus-170": 932359, + "doting-tortoise-553": 932360, + "artful-wombat-679": 932360, + "rugged-ermine-804": 932401, + "polite-oyster-937": 932401, + "patient-mosquito-277": 932402, + "ardent-bee-374": 932402, + "fabulous-sheep-793": 932448, + "graceful-reindeer-165": 932448, + "bright-ibex-879": 932449, + "shocking-partridge-788": 932449, + "cool-weasel-374": 932453, + "dusty-ferret-864": 932453, + "brave-squirrel-910": 932454, + "courteous-walrus-717": 932454, + "watchful-antelope-320": 932471, + "abundant-raven-813": 932471, + "clear-platypus-552": 932473, + "majestic-stingray-728": 932473, + "original-bloodhound-161": 932484, + "sensible-peccary-371": 932484, + "clever-chameleon-988": 932486, + "secret-peccary-288": 932486, + "amiable-grouse-968": 932489, + "wary-curlew-642": 932489, + "industrious-mongoose-403": 932490, + "elated-pig-149": 932490, + "careful-chinchilla-345": 932571, + "frugal-jackal-948": 932571, + "quaint-kudu-191": 932572, + "quixotic-heron-576": 932572, + "tacit-mink-121": 932630, + "wandering-ant-769": 932630, + "wooden-eagle-618": 932631, + "pleasant-marten-661": 932631, + "silent-octopus-605": 932726, + "qualified-jay-385": 932726, + "decisive-salmon-501": 932727, + "woozy-blackbird-324": 932727, + "resolute-cuttlefish-280": 932778, + "dutiful-kingfisher-482": 932779, + "academic-avocet-251": 932779, + "whimsical-otter-999": 932874, + "fortunate-alligator-176": 932874, + "honorable-anaconda-848": 932875, + "sleek-heron-72": 932875, + "colorful-partridge-582": 932932, + "clear-orca-350": 932932, + "disciplined-kangaroo-804": 932933, + "diligent-tern-624": 932933, + "next-sockeye-377": 933170, + "tidy-malamute-941": 933170, + "brilliant-jellyfish-57": 933220, + "beaming-clownfish-192": 933233, + "whimsical-canary-358": 933236, + "industrious-crocodile-404": 933356, + "curious-albatross-527": 933382, + "cheery-wildcat-267": 933419, + "quaint-ram-684": 933420, + "fleet-newt-137": 933475, + "pastel-goshawk-979": 933476, + "neighborly-panther-592": 933532, + "basic-giraffe-791": 933534, + "zealous-capybara-681": 933579, + "polished-stork-368": 933580, + "befitting-panda-137": 933661, + "tangible-hippopotamus-610": 933662, + "ceaseless-tapir-30": 933675, + "tame-buzzard-359": 933676, + "mellow-koala-702": 933702, + "dusty-bison-908": 933703, + "glad-aardvark-867": 933934, + "basic-bird-570": 933936, + "affable-kangaroo-937": 933983, + "energetic-weasel-907": 933984, + "first-coyote-490": 934021, + "capable-sparrow-970": 934023, + "deafening-capybara-399": 934253, + "small-mink-248": 934254, + "festive-bison-98": 934284, + "fearless-newt-483": 934285, + "woozy-basilisk-852": 934288, + "effervescent-whale-955": 934289, + "veracious-raccoon-278": 934292, + "prestigious-puffin-336": 934293, + "resilient-cardinal-364": 934325, + "precise-butterfly-509": 934326, + "adept-poodle-452": 934339, + "fastidious-sardine-238": 934340, + "pleasant-wildcat-189": 934340, + "standing-manatee-488": 934432, + "agreeable-narwhal-713": 934455, + "neighborly-eel-856": 934456, + "acrobatic-clownfish-475": 934462, + "polite-ram-386": 934463, + "neat-possum-682": 934521, + "perfect-gazelle-436": 934536, + "brilliant-chameleon-264": 934537, + "spotted-goldfish-207": 934637, + "uncommon-robin-347": 934638, + "outstanding-snail-957": 934724, + "agile-yak-408": 934725, + "robust-bass-263": 934780, + "sincere-pelican-930": 934781, + "kindred-buffalo-670": 934781, + "flexible-manatee-489": 934798, + "earnest-avocet-755": 934799, + "utmost-dinosaur-73": 934800, + "avid-dotterel-199": 934801, + "rosy-kookabura-137": 934858, + "unique-porcupine-978": 934859, + "dynamic-goat-36": 934859, + "impressive-goldfinch-968": 934866, + "precise-vulture-941": 934867, + "upbeat-gopher-742": 934872, + "cool-mosquito-457": 934873, + "whimsical-crane-647": 934873, + "dutiful-swordfish-338": 934881, + "silent-rat-604": 934882, + "proper-turtle-376": 934898, + "zealous-peacock-522": 934899, + "kindly-guanaco-156": 934901, + "sleek-buffalo-891": 934902, + "resilient-ermine-719": 934913, + "usable-starfish-477": 934914, + "amicable-dove-175": 934914, + "fearless-hamster-803": 934922, + "dynamic-weasel-897": 934923, + "courteous-rook-386": 934923, + "polite-gnat-254": 934943, + "little-shrimp-514": 934944, + "modest-starfish-358": 934945, + "affable-whale-447": 934946, + "adamant-crane-139": 934958, + "flippant-viper-262": 934959, + "majestic-chicken-138": 934959, + "giant-bass-289": 934973, + "pleasant-goldfish-379": 934974, + "giddy-blackbird-81": 934976, + "terrific-fish-520": 934977, + "dashing-caribou-794": 934987, + "outgoing-trout-998": 934988, + "clean-guineapig-458": 934993, + "patient-lion-937": 934994, + "cool-snake-246": 934994, + "rare-antelope-179": 935013, + "standing-beagle-757": 935014, + "perfect-warbler-483": 935015, + "sleek-goat-462": 935016, + "posh-fly-430": 935019, + "industrious-marten-958": 935020, + "fortunate-flamingo-659": 935030, + "shocking-kiwi-127": 935031, + "marvelous-cobra-325": 935031, + "qualified-dragon-253": 935040, + "polite-mallard-195": 935041, + "academic-vole-564": 935046, + "wandering-giraffe-276": 935047, + "disciplined-starling-502": 935052, + "capable-egret-854": 935053, + "valuable-rhinoceros-747": 935053, + "ideal-dodo-608": 935069, + "rare-newt-505": 935071, + "beloved-loris-993": 935078, + "precise-seal-829": 935079, + "efficient-stork-488": 935080, + "notable-shepherd-401": 935082, + "resilient-ocelot-734": 935085, + "reliable-bat-926": 935086, + "efficient-rooster-978": 935088, + "canny-greyhound-637": 935089, + "giant-oriole-603": 935091, + "loyal-finch-574": 935092, + "zany-horse-796": 935119, + "stoic-manatee-831": 935121, + "bold-cardinal-22": 935121, + "mild-elephant-29": 935129, + "hushed-ibex-953": 935130, + "patient-dog-814": 935138, + "fast-gerbil-772": 935139, + "rugged-pigeon-409": 935141, + "basic-swan-586": 935142, + "intent-mongoose-420": 935144, + "harmless-dogfish-546": 935146, + "energetic-marten-907": 935159, + "cheerful-goose-272": 935160, + "curious-mink-170": 935172, + "dusty-kiwi-593": 935173, + "zealous-salmon-415": 935174, + "clever-tortoise-576": 935175, + "decisive-warbler-543": 935180, + "bold-trout-256": 935181, + "formal-pelican-520": 935209, + "agreeable-goshawk-345": 935211, + "grateful-weasel-972": 935212, + "acrobatic-herring-6": 935213, + "hardy-hedgehog-541": 935213, + "capable-oyster-690": 935226, + "laudable-fox-574": 935227, + "reliable-egret-804": 935230, + "amiable-caterpillar-3": 935231, + "quirky-possum-682": 935233, + "giddy-starfish-467": 935235, + "shocking-bullfrog-579": 935241, + "stoic-mule-985": 935242, + "first-ptarmigan-455": 935257, + "tangible-mallard-306": 935258, + "rare-ostrich-784": 935258, + "valuable-squirrel-360": 935268, + "rugged-terrier-275": 935269, + "sleek-bass-610": 935279, + "resolute-vole-968": 935280, + "little-peacock-13": 935281, + "first-fly-108": 935282, + "warmhearted-marmot-10": 935282, + "adamant-fish-166": 935292, + "healthy-grouse-550": 935293, + "loyal-manatee-461": 935308, + "kindhearted-aardvark-280": 935309, + "next-hedgehog-313": 935332, + "resolute-narwhal-683": 935333, + "wry-pelican-778": 935344, + "kindly-antelope-336": 935345, + "precious-warbler-843": 935345, + "adamant-wildcat-775": 935346, + "successful-cormorant-583": 935347, + "decisive-partridge-862": 935348, + "helpful-ladybug-979": 935350, + "incredible-dog-437": 935357, + "hardy-duck-105": 935358, + "polished-fish-188": 935361, + "first-buffalo-710": 935362, + "ardent-bullfrog-818": 935375, + "hip-elephant-16": 935376, + "valuable-rat-89": 935386, + "precise-coyote-102": 935387, + "disciplined-gnu-477": 935417, + "strong-ram-821": 935418, + "next-dolphin-988": 935422, + "cautious-perch-150": 935423, + "cheerful-okapi-655": 935423, + "fantastic-cow-655": 935429, + "honorable-viper-69": 935430, + "flexible-snake-492": 935446, + "expert-spider-747": 935447, + "small-parakeet-905": 935447, + "resilient-avocet-924": 935464, + "reliable-clownfish-94": 935465, + "mild-wren-193": 935470, + "fortunate-leopard-181": 935471, + "dutiful-grasshopper-957": 935471, + "hearty-wolf-788": 935509, + "tough-wildebeest-967": 935510, + "keen-mastiff-435": 935513, + "notable-dinosaur-633": 935514, + "good-loris-822": 935515, + "proficient-gnu-254": 935517, + "watchful-mallard-100": 935518, + "elated-kudu-274": 935519, + "pastel-dachshund-115": 935524, + "nautical-starfish-843": 935525, + "patient-buffalo-267": 935525, + "outgoing-marlin-904": 935534, + "knowing-sardine-243": 935538, + "ardent-beagle-14": 935540, + "precious-scorpion-932": 935541, + "fastidious-dinosaur-428": 935542, + "lovable-swordfish-922": 935543, + "hidden-gnat-982": 935550, + "energetic-ocelot-193": 935552, + "flexible-puffin-834": 935555, + "sensible-gopher-927": 935556, + "optimistic-anteater-628": 935582, + "harmless-impala-8": 935583, + "ceaseless-okapi-689": 935584, + "brilliant-weasel-395": 935585, + "whimsical-owl-144": 935585, + "sincere-parrot-347": 935592, + "original-barracuda-863": 935594, + "trustworthy-starfish-203": 935594, + "different-starfish-42": 935600, + "combative-kookabura-44": 935603, + "standing-penguin-4": 935607, + "decisive-clam-64": 935609, + "zany-mammoth-574": 935618, + "acoustic-civet-516": 935619, + "hallowed-wombat-498": 935622, + "helpful-marmot-134": 935623, + "limitless-wildcat-715": 935626, + "efficient-squirrel-361": 935628, + "scintillating-pika-544": 935633, + "dapper-rhinoceros-405": 935634, + "wooden-chicken-733": 935666, + "vibrant-zebra-310": 935667, + "woozy-mockingbird-719": 935670, + "vivid-ant-256": 935671, + "resilient-gopher-558": 935671, + "beloved-goldfish-713": 935674, + "disciplined-okapi-751": 935675, + "blessed-falcon-445": 935675, + "pastel-goshawk-81": 935682, + "formal-stoat-605": 935683, + "upbeat-hawk-370": 935683, + "quiet-gecko-108": 935698, + "hip-retriever-835": 935699, + "prestigious-mandrill-223": 935712, + "mild-fox-311": 935713, + "loyal-goose-43": 935724, + "laudable-curlew-280": 935725, + "hidden-parakeet-577": 935732, + "wooden-starling-951": 935733, + "formal-anteater-130": 935734, + "acoustic-buffalo-478": 935735, + "flippant-bullfrog-23": 935750, + "glad-fox-56": 935751, + "lovely-butterfly-39": 935751, + "fastidious-fly-664": 935753, + "scintillating-goshawk-982": 935754, + "coordinated-anaconda-984": 935763, + "impartial-stingray-947": 935764, + "watchful-firefly-679": 935773, + "brilliant-rat-415": 935774, + "frugal-dogfish-75": 935774, + "adorable-egret-12": 935784, + "fortunate-blackbird-627": 935785, + "wry-hornet-253": 935789, + "proficient-meerkat-648": 935790, + "utmost-condor-542": 935790, + "scrupulous-kangaroo-430": 935797, + "colorless-stork-472": 935798, + "fastidious-guanaco-153": 935814, + "elated-hornet-367": 935815, + "robust-mastiff-808": 935820, + "vibrant-wren-965": 935821, + "spotted-otter-565": 935826, + "wonderful-magpie-863": 935827, + "dazzling-weasel-216": 935828, + "calm-possum-293": 935830, + "acrobatic-mammoth-896": 935867, + "benevolent-dog-4": 935869, + "admired-weasel-755": 935875, + "befitting-sparrow-518": 935876, + "hushed-stoat-958": 935876, + "prestigious-chihuahua-845": 935954, + "chatty-greyhound-950": 935955, + "dapper-stingray-637": 935962, + "avid-sheep-242": 935963, + "adventurous-shark-636": 935980, + "ardent-loris-719": 935981, + "dutiful-platypus-58": 935981, + "shiny-beagle-644": 935987, + "charming-guineapig-51": 935988, + "oceanic-sparrow-548": 936001, + "superb-dotterel-852": 936003, + "neighborly-spider-987": 936017, + "rosy-gnu-347": 936019, + "tidy-fox-113": 936019, + "deafening-akita-516": 936022, + "efficient-hornet-568": 936023, + "wry-mole-740": 936023, + "merry-otter-603": 936035, + "small-walrus-620": 936036, + "avid-panda-101": 936037, + "shiny-armadillo-413": 936038, + "hidden-stoat-770": 936038, + "original-spaniel-705": 936047, + "rapid-toad-766": 936048, + "coordinated-oyster-270": 936048, + "hip-sparrow-305": 936058, + "peaceful-fox-112": 936060, + "combative-goshawk-706": 936094, + "silent-hummingbird-661": 936095, + "doting-sheep-490": 936174, + "enchanted-hare-350": 936175, + "dependable-jackal-389": 936175, + "earnest-tapir-108": 936176, + "jovial-ram-824": 936177, + "small-tiger-994": 936191, + "silent-ram-93": 936192, + "grandiose-dotterel-373": 936197, + "grandiose-trout-789": 936198, + "quiet-jay-235": 936206, + "perceptive-partridge-974": 936208, + "elated-woodpecker-270": 936219, + "judicious-lobster-964": 936220, + "rare-ostrich-516": 936224, + "zealous-grasshopper-712": 936225, + "wonderful-rabbit-306": 936232, + "rosy-bullfrog-834": 936233, + "deafening-lion-376": 936233, + "little-poodle-168": 936236, + "dusty-sockeye-745": 936237, + "adamant-chipmunk-759": 936240, + "insightful-cricket-691": 936241, + "mild-llama-324": 936241, + "elated-hedgehog-127": 936248, + "proper-hamster-222": 936249, + "keen-meadowlark-605": 936269, + "honorable-crab-776": 936273, + "perceptive-bulldog-888": 936279, + "nautical-impala-416": 936280, + "tremendous-perch-383": 936301, + "artful-cassowary-363": 936302, + "gregarious-elk-17": 936336, + "terrific-quail-716": 936338, + "diligent-kangaroo-128": 936339, + "calculating-guanaco-972": 936348, + "qualified-chickadee-578": 936351, + "abundant-crow-623": 936378, + "hip-lemming-882": 936379, + "silent-pig-659": 936385, + "trustworthy-scorpion-353": 936386, + "gregarious-whale-953": 936390, + "healthy-cod-916": 936391, + "cautious-parrot-72": 936397, + "blessed-kingfisher-879": 936398, + "fastidious-marlin-233": 936400, + "posh-viper-75": 936402, + "perfect-firefly-882": 936405, + "moonlit-hamster-52": 936406, + "polite-fish-385": 936406, + "prestigious-koala-745": 936419, + "effervescent-cassowary-674": 936421, + "nautical-retriever-283": 936432, + "vibrant-magpie-439": 936433, + "acrobatic-skunk-502": 936433, + "reliable-shrimp-292": 936435, + "glad-barracuda-789": 936436, + "terrific-viper-271": 936439, + "wooden-swan-807": 936440, + "wry-finch-943": 936443, + "impartial-walrus-991": 936444, + "grandiose-basilisk-581": 936447, + "upbeat-vole-302": 936451, + "pastel-salmon-490": 936452, + "dusty-chickadee-83": 936466, + "little-wildebeest-908": 936468, + "benevolent-okapi-246": 936486, + "gallant-zebra-808": 936487, + "handsome-owl-858": 936488, + "prestigious-alligator-668": 936490, + "striped-heron-111": 936497, + "rosy-mink-501": 936498, + "quaint-pig-193": 936500, + "hip-nightingale-794": 936501, + "friendly-fox-456": 936502, + "befitting-raven-985": 936503, + "disciplined-otter-100": 936536, + "uncommon-peccary-167": 936537, + "optimistic-toucan-363": 936543, + "fast-bullfrog-195": 936544, + "calm-platypus-779": 936544, + "effervescent-rook-276": 936548, + "tremendous-chickadee-846": 936549, + "scintillating-swan-52": 936552, + "diligent-schnauzer-654": 936553, + "kindhearted-goldfinch-192": 936581, + "greedy-seal-586": 936582, + "pleasant-scorpion-287": 936582, + "energetic-whale-570": 936596, + "resolute-penguin-895": 936597, + "wooden-magpie-881": 936608, + "hip-parakeet-829": 936609, + "accurate-stoat-647": 936609, + "pastel-guanaco-798": 936636, + "modest-shepherd-75": 936637, + "tangible-mink-575": 936653, + "groovy-squid-988": 936654, + "abundant-marten-613": 936664, + "useful-octopus-505": 936665, + "basic-mockingbird-455": 936671, + "glorious-squid-527": 936673, + "tremendous-butterfly-940": 936673, + "brainy-salmon-336": 936674, + "groovy-bloodhound-366": 936675, + "diligent-ocelot-399": 936682, + "canny-owl-143": 936683, + "savory-gnu-282": 936685, + "tremendous-cobra-885": 936686, + "zany-gazelle-885": 936686, + "tangible-kangaroo-334": 936692, + "hearty-weasel-232": 936693, + "colorful-panda-514": 936702, + "fleet-hyena-384": 936702, + "cheerful-grouse-488": 936704, + "uncommon-sockeye-364": 936705, + "reminiscent-goat-302": 936705, + "quaint-ferret-793": 936707, + "kindly-penguin-171": 936709, + "basic-ocelot-210": 936727, + "adorable-cheetah-224": 936728, + "hallowed-tapir-670": 936728, + "tidy-capybara-973": 936738, + "agile-iguana-910": 936739, + "hardy-parakeet-239": 936740, + "clever-possum-403": 936741, + "quaint-bison-811": 936744, + "chatty-yak-106": 936745, + "benevolent-sockeye-701": 936768, + "vivid-terrier-923": 936769, + "agreeable-goshawk-91": 936770, + "marvelous-setter-990": 936771, + "vibrant-spaniel-75": 936771, + "qualified-koala-426": 936789, + "colorless-fish-412": 936790, + "reminiscent-mule-232": 936815, + "rapid-wildebeest-271": 936816, + "fearless-oriole-782": 936820, + "proficient-stingray-337": 936821, + "rapid-sandpiper-207": 936831, + "majestic-mosquito-843": 936832, + "posh-alligator-38": 936873, + "dapper-bloodhound-509": 936874, + "striped-lark-920": 936899, + "original-hippopotamus-646": 936900, + "hallowed-retriever-474": 936927, + "incredible-mosquito-31": 936928, + "abundant-clam-544": 936945, + "reminiscent-poodle-226": 936946, + "handsome-poodle-279": 936961, + "usable-fish-914": 936962, + "jovial-porpoise-97": 936985, + "healthy-quail-528": 936986, + "third-ptarmigan-133": 936988, + "friendly-walrus-349": 936989, + "opulent-gerbil-31": 936994, + "terrific-grouse-800": 936995, + "frugal-porcupine-783": 936997, + "adept-chihuahua-693": 937000, + "animated-narwhal-78": 937008, + "usable-goose-951": 937009, + "zany-mosquito-261": 937009, + "quiet-gazelle-287": 937046, + "fabulous-wildcat-472": 937047, + "keen-toucan-892": 937060, + "compassionate-jellyfish-750": 937061, + "valuable-husky-185": 937061, + "intent-mink-159": 937099, + "formal-boar-243": 937101, + "valuable-lynx-819": 937122, + "proficient-yak-108": 937125, + "different-lemur-777": 937132, + "brave-hawk-713": 937133, + "cheerful-loris-981": 937165, + "content-partridge-936": 937166, + "cool-shepherd-546": 937167, + "little-dragon-773": 937168, + "dusty-wolverine-460": 937168, + "original-oyster-909": 937177, + "useful-stork-495": 937178, + "basic-nightingale-993": 937183, + "chatty-starling-263": 937185, + "beloved-anaconda-298": 937185, + "honorable-cod-564": 937188, + "disciplined-goshawk-199": 937199, + "charming-herring-732": 937201, + "different-rabbit-141": 937209, + "ideal-husky-477": 937211, + "agile-boar-753": 937213, + "silent-tortoise-71": 937215, + "aromatic-otter-513": 937219, + "admired-orca-925": 937220, + "reliable-lynx-495": 937230, + "zealous-curlew-641": 937231, + "quixotic-orca-137": 937239, + "doting-dolphin-402": 937241, + "brave-retriever-880": 937242, + "colorful-corgi-470": 937243, + "shiny-loris-283": 937247, + "curious-squid-496": 937248, + "upbeat-bulldog-411": 937289, + "focused-wren-729": 937290, + "vivid-ferret-923": 937292, + "shocking-spoonbill-468": 937293, + "gregarious-spoonbill-423": 937353, + "zealous-armadillo-551": 937354, + "aware-woodpecker-485": 937464, + "content-hippopotamus-339": 937465, + "first-anteater-295": 937465, + "quirky-fox-600": 937472, + "kindred-loris-816": 937473, + "animated-antelope-211": 937502, + "knowing-sardine-923": 937504, + "decisive-cat-529": 937504, + "intent-fly-812": 937526, + "flexible-seal-480": 937527, + "brave-mandrill-172": 937531, + "tame-rhinoceros-182": 937532, + "glorious-salmon-424": 937543, + "rosy-eel-838": 937544, + "standing-shepherd-170": 937545, + "quirky-blackbird-28": 937546, + "quirky-scorpion-211": 937546, + "coordinated-retriever-659": 937564, + "lovable-condor-66": 937587, + "enchanted-wolverine-437": 937588, + "energized-mongoose-90": 937626, + "affable-platypus-263": 937628, + "exciting-hare-251": 937633, + "modest-shrimp-349": 937634, + "notable-roadrunner-234": 937652, + "lovable-labrador-19": 937653, + "knowing-otter-810": 937673, + "hearty-crocodile-926": 937674, + "hip-lark-132": 937676, + "academic-marmot-62": 937678, + "sensible-shepherd-867": 937678, + "flexible-dogfish-213": 937679, + "confident-crab-843": 937681, + "adjoining-camel-537": 937759, + "friendly-aardvark-138": 937760, + "incredible-lemming-497": 937821, + "exuberant-duck-36": 937822, + "blissful-guineapig-782": 937822, + "warmhearted-lark-511": 937828, + "mild-starling-2": 937829, + "befitting-crow-447": 937829, + "quiet-echidna-848": 937834, + "astute-penguin-511": 937835, + "brilliant-roadrunner-274": 937837, + "canny-civet-463": 937839, + "acrobatic-shark-360": 937844, + "fearless-owl-473": 937845, + "giddy-lapwing-0": 937867, + "incredible-dogfish-248": 937868, + "chatty-sockeye-145": 937929, + "careful-shepherd-918": 937930, + "incredible-opossum-626": 937961, + "basic-porpoise-979": 937962, + "honorable-lark-816": 937995, + "marvelous-mockingbird-417": 937996, + "polite-warbler-325": 938018, + "giant-warbler-311": 938019, + "groovy-falcon-983": 938030, + "good-retriever-185": 938031, + "polished-gecko-68": 938031, + "modest-clownfish-99": 938049, + "spotted-ermine-681": 938050, + "merry-goose-694": 938059, + "abundant-gull-125": 938061, + "merry-mongoose-400": 938081, + "notable-dotterel-256": 938081, + "trustworthy-shrimp-920": 938082, + "exciting-vole-355": 938112, + "resilient-wolverine-139": 938113, + "groovy-frog-579": 938122, + "incredible-mink-31": 938123, + "hip-mongoose-335": 938158, + "affable-dinosaur-628": 938160, + "fleet-axolotl-669": 938160, + "reminiscent-porpoise-197": 938173, + "majestic-roadrunner-232": 938174, + "dapper-robin-48": 938182, + "fearless-chameleon-400": 938183, + "precise-chameleon-211": 938183, + "hip-rabbit-52": 938187, + "marvelous-pika-887": 938187, + "greedy-crab-258": 938217, + "dependable-hound-387": 938218, + "brazen-panda-695": 938218, + "festive-meerkat-503": 938233, + "calm-bird-265": 938276, + "marvelous-robin-272": 938279, + "rosy-deer-849": 938342, + "content-magpie-275": 938343, + "quixotic-lyrebird-667": 938358, + "sincere-porcupine-769": 938359, + "earnest-kangaroo-865": 938361, + "grateful-goldfish-706": 938362, + "woozy-guineapig-316": 938371, + "marvelous-goldfinch-195": 938372, + "rugged-seal-949": 938391, + "merry-cormorant-380": 938392, + "brilliant-shark-134": 938405, + "glad-crocodile-971": 938407, + "quaint-husky-187": 938409, + "ardent-trout-264": 938410, + "dependable-buffalo-924": 938413, + "small-impala-51": 938414, + "utmost-retriever-488": 938433, + "efficient-gazelle-777": 938434, + "academic-dotterel-811": 938434, + "cool-mandrill-238": 938442, + "rosy-fennec-162": 938443, + "energized-weasel-329": 938458, + "pleasant-gecko-422": 938459, + "glad-opossum-277": 938463, + "limitless-whale-470": 938464, + "effervescent-lemming-945": 938466, + "proficient-guineapig-826": 938467, + "hearty-mallard-166": 938501, + "hallowed-starling-705": 938502, + "agile-swordfish-819": 938514, + "resolute-poodle-462": 938515, + "superb-grouse-370": 938518, + "original-oyster-228": 938519, + "confident-lion-993": 938520, + "merry-pigeon-762": 938522, + "rightful-parakeet-449": 938546, + "energetic-schnauzer-699": 938547, + "gallant-chicken-969": 938547, + "agile-lobster-696": 938555, + "secret-setter-649": 938556, + "graceful-porcupine-301": 938560, + "mild-chipmunk-634": 938561, + "enchanted-shrimp-143": 938561, + "fantastic-bat-347": 938566, + "hip-minnow-71": 938567, + "neat-ptarmigan-770": 938574, + "adorable-akita-110": 938575, + "enchanted-crane-699": 938576, + "quiet-eagle-293": 938577, + "famous-chipmunk-742": 938594, + "brave-lemur-409": 938595, + "tame-oyster-979": 938595, + "trustworthy-giraffe-6": 938609, + "earnest-kingfisher-958": 938610, + "joyous-dodo-688": 938610, + "enduring-ocelot-815": 938621, + "notable-chipmunk-519": 938622, + "blissful-lemur-680": 938633, + "cheerful-loris-803": 938634, + "scintillating-woodpecker-757": 938634, + "formal-ocelot-873": 938637, + "colorless-fly-87": 938638, + "mellow-caiman-80": 938658, + "cautious-anteater-156": 938659, + "exuberant-cod-495": 938662, + "combative-barracuda-653": 938663, + "tidy-goldfish-96": 938675, + "graceful-oyster-375": 938676, + "hip-horse-79": 938682, + "expert-mole-36": 938683, + "wooden-caribou-553": 938700, + "calm-mastiff-968": 938701, + "frugal-beagle-127": 938702, + "friendly-stork-667": 938703, + "befitting-curlew-383": 938704, + "acrobatic-lark-833": 938705, + "astute-echidna-385": 938706, + "third-marlin-847": 938707, + "patient-puffin-745": 938708, + "perfect-stoat-906": 938709, + "polished-vole-706": 938710, + "warmhearted-fly-642": 938710, + "tremendous-lyrebird-312": 938711, + "vibrant-snail-129": 938711, + "accurate-eel-417": 938712, + "neat-pelican-177": 938712, + "fine-terrier-78": 938713, + "friendly-crocodile-272": 938714, + "compassionate-chihuahua-853": 938714, + "fearless-pika-16": 938715, + "ardent-chameleon-856": 938716, + "spotted-manatee-211": 938717, + "rosy-gopher-812": 938718, + "neighborly-donkey-981": 938719, + "quixotic-scorpion-450": 938720, + "robust-emu-599": 938721, + "accurate-boar-470": 938722, + "steady-butterfly-882": 938723, + "jovial-hedgehog-153": 938725, + "greedy-sandpiper-266": 938726, + "scrupulous-gull-218": 938726, + "outstanding-mallard-564": 938730, + "spotted-ibis-385": 938731, + "spotted-hedgehog-14": 938738, + "original-civet-309": 938739, + "scintillating-condor-54": 938740, + "diligent-poodle-249": 938741, + "scrupulous-coyote-317": 938741, + "tidy-chameleon-879": 938742, + "bright-meerkat-184": 938744, + "fleet-partridge-115": 938767, + "acrobatic-platypus-28": 938768, + "hip-cheetah-99": 938771, + "joyous-mole-813": 938772, + "hallowed-eel-565": 938787, + "hardy-dodo-905": 938788, + "cheery-chinchilla-616": 938797, + "kindred-kookabura-642": 938799, + "reliable-spaniel-631": 938822, + "affable-chipmunk-722": 938823, + "vivid-lark-8": 938826, + "patient-caiman-743": 938827, + "kindhearted-sandpiper-201": 938828, + "hearty-parrot-716": 938829, + "notable-dragon-854": 938829, + "amicable-cassowary-808": 938918, + "marvelous-goose-557": 938919, + "adventurous-lemur-469": 938945, + "fast-lemur-302": 938946, + "giant-mink-264": 938960, + "flippant-ermine-314": 938961, + "watchful-lynx-774": 938964, + "standing-lion-791": 938965, + "astute-kingfisher-832": 938972, + "quiet-wildebeest-212": 938973, + "vivid-swan-925": 938975, + "harmless-octopus-454": 938976, + "amicable-moose-605": 938976, + "robust-wolf-557": 938991, + "upbeat-finch-851": 938992, + "watchful-frog-763": 938993, + "perfect-mandrill-701": 938994, + "acoustic-scorpion-494": 939002, + "artful-ptarmigan-600": 939003, + "dynamic-impala-324": 939074, + "adamant-clownfish-343": 939075, + "first-albatross-996": 939168, + "fleet-dachshund-536": 939169, + "acoustic-tern-884": 939177, + "doting-hippopotamus-380": 939178, + "beloved-gnat-416": 939180, + "hushed-pika-782": 939181, + "jovial-sardine-795": 939190, + "small-lion-980": 939191, + "kindhearted-nightingale-870": 939193, + "tangible-koala-57": 939194, + "resolute-okapi-96": 939196, + "upbeat-corgi-841": 939197, + "quirky-narwhal-216": 939202, + "accomplished-firefly-693": 939203, + "patient-kookabura-329": 939212, + "beloved-lynx-973": 939213, + "warmhearted-sparrow-278": 939214, + "calm-squid-511": 939215, + "grateful-meadowlark-977": 939233, + "whimsical-jay-964": 939234, + "calm-bulldog-12": 939235, + "zany-buzzard-948": 939236, + "colorless-sandpiper-721": 939242, + "flippant-goat-819": 939243, + "agile-armadillo-351": 939251, + "giant-goldfinch-636": 939252, + "famous-shepherd-605": 939264, + "mild-egret-480": 939265, + "outstanding-warthog-356": 939266, + "kindly-echidna-611": 939268, + "avid-curlew-128": 939277, + "dynamic-boar-461": 939278, + "enduring-mole-467": 939362, + "handsome-shepherd-881": 939363, + "enchanted-seahorse-561": 939373, + "effervescent-cuttlefish-618": 939374, + "merry-grasshopper-718": 939374, + "beloved-bulldog-519": 939387, + "hidden-squirrel-510": 939388, + "incredible-shepherd-129": 939395, + "savory-clam-490": 939398, + "quixotic-woodpecker-107": 939427, + "ardent-puffin-438": 939428, + "sincere-puma-184": 939428, + "original-koala-177": 939450, + "little-egret-893": 939451, + "usable-marten-175": 939451, + "wandering-manatee-744": 939501, + "bright-mammoth-251": 939502, + "gallant-gopher-111": 939504, + "valuable-hyena-307": 939505, + "efficient-mastiff-690": 939523, + "resolute-bear-39": 939525, + "pleasant-kookabura-460": 939580, + "cautious-axolotl-306": 939581, + "oceanic-cheetah-857": 939587, + "mellow-frog-872": 939588, + "joyous-walrus-813": 939595, + "amiable-rabbit-635": 939596, + "adventurous-jaguar-887": 939596, + "affable-dodo-841": 939632, + "greedy-hippopotamus-566": 939633, + "groovy-lark-353": 939660, + "adamant-basilisk-979": 939661, + "basic-toad-628": 939679, + "adorable-kingfisher-355": 939681, + "focused-scorpion-258": 939689, + "descriptive-malamute-71": 939690, + "original-alpaca-923": 939696, + "scrupulous-monitor-888": 939697, + "beaming-axolotl-550": 939719, + "perfect-fox-245": 939720, + "rare-oyster-867": 939720, + "scintillating-jay-572": 939799, + "reliable-bass-276": 939801, + "perfect-turtle-525": 939801, + "quirky-akita-174": 939815, + "wry-raven-283": 939816, + "brilliant-finch-893": 939837, + "tremendous-sheep-45": 939838, + "precise-hippopotamus-260": 939838, + "healthy-starling-942": 939846, + "majestic-partridge-320": 939847, + "scrupulous-turtle-671": 939869, + "vivid-bloodhound-794": 939870, + "little-seal-663": 939900, + "rightful-stingray-336": 939901, + "kindly-yak-496": 939983, + "brave-ibis-732": 939984, + "unique-bulldog-863": 939984, + "adorable-pelican-807": 940079, + "jovial-setter-411": 940080, + "fiery-wildcat-400": 940110, + "gregarious-tiger-373": 940111, + "handsome-pigeon-668": 940111, + "calculating-raven-403": 940117, + "proper-lemur-353": 940118, + "spotted-lapwing-542": 940127, + "sensible-chickadee-1": 940129, + "enchanted-hamster-143": 940171, + "festive-gazelle-214": 940173, + "academic-schnauzer-529": 940212, + "posh-ram-975": 940213, + "secret-partridge-396": 940218, + "astute-guineapig-533": 940219, + "hearty-barracuda-808": 940235, + "tame-jaguar-320": 940236, + "next-platypus-908": 940236, + "courteous-chipmunk-522": 940237, + "usable-corgi-48": 940239, + "wry-porpoise-576": 940240, + "upbeat-reindeer-377": 940240, + "reminiscent-dolphin-247": 940241, + "academic-elk-562": 940241, + "intent-blackbird-32": 940267, + "efficient-lemming-96": 940268, + "adjoining-kiwi-162": 940289, + "gregarious-seahorse-352": 940293, + "resilient-kookabura-906": 940293, + "majestic-cassowary-368": 940303, + "abundant-terrier-95": 940304, + "content-blackbird-716": 940317, + "helpful-peccary-599": 940318, + "neat-anteater-377": 940318, + "energized-fox-750": 940325, + "silent-bear-913": 940327, + "brilliant-bear-3": 940335, + "upbeat-peacock-454": 940336, + "helpful-panther-851": 940348, + "earnest-swan-907": 940349, + "striped-bulldog-522": 940359, + "fast-ostrich-733": 940373, + "merry-tern-220": 940377, + "dynamic-meerkat-822": 940417, + "festive-gazelle-320": 940418, + "exciting-wombat-207": 940489, + "quixotic-terrier-42": 940490, + "coordinated-condor-159": 940493, + "agile-warbler-612": 940494, + "reminiscent-penguin-554": 940494, + "brilliant-lyrebird-711": 940502, + "wonderful-spider-312": 940504, + "groovy-swan-898": 940508, + "veracious-ferret-14": 940509, + "handsome-swordfish-45": 940511, + "fantastic-cheetah-189": 940512, + "adjoining-warthog-242": 940537, + "tough-ladybug-794": 940538, + "whimsical-turtle-608": 940548, + "grand-jellyfish-107": 940549, + "flexible-kudu-844": 940555, + "terrific-pigeon-241": 940556, + "canny-crane-468": 940573, + "helpful-coyote-774": 940575, + "graceful-pigeon-148": 940619, + "rare-dog-195": 940620, + "dutiful-shrimp-848": 940623, + "spotted-terrier-827": 940624, + "successful-gnu-339": 940638, + "giddy-guineapig-378": 940639, + "handsome-whale-516": 940685, + "limitless-fox-509": 940686, + "brazen-pigeon-134": 940721, + "fleet-grasshopper-676": 940723, + "different-mole-569": 940763, + "next-condor-483": 940764, + "grandiose-stingray-773": 940792, + "ardent-iguana-11": 940793, + "peaceful-yak-897": 940839, + "sleek-kookabura-602": 940840, + "sleek-lemur-853": 940876, + "hardy-dodo-790": 940877, + "brazen-gazelle-815": 940926, + "aware-spoonbill-464": 940930, + "third-mockingbird-999": 940930, + "precise-kiwi-373": 940941, + "acoustic-jay-481": 940942, + "pleasant-lapwing-144": 940942, + "adventurous-cheetah-219": 940948, + "content-roadrunner-678": 940949, + "academic-dodo-872": 940968, + "hip-tortoise-578": 940969, + "amiable-iguana-832": 940985, + "flexible-cod-89": 940987, + "fine-corgi-365": 940993, + "keen-capybara-885": 940995, + "usable-chihuahua-444": 941007, + "proper-sandpiper-362": 941008, + "resilient-guanaco-873": 941026, + "sincere-minnow-944": 941028, + "resolute-eagle-444": 941028, + "loyal-cat-111": 941029, + "astute-puffin-18": 941030, + "incredible-crow-311": 941037, + "reminiscent-kookabura-68": 941038, + "tangible-walrus-886": 941089, + "rightful-hippopotamus-802": 941090, + "clean-goose-260": 941090, + "zany-perch-53": 941103, + "harmless-spaniel-908": 941104, + "notable-pony-540": 941104, + "harmless-puffin-108": 941157, + "strong-flamingo-724": 941158, + "standing-minnow-686": 941210, + "friendly-sparrow-674": 941211, + "rare-monitor-637": 941213, + "gallant-hippopotamus-401": 941214, + "dazzling-frog-478": 941214, + "precious-mastiff-330": 941261, + "tame-sardine-452": 941262, + "scrupulous-setter-319": 941308, + "impressive-shepherd-982": 941309, + "vivid-monitor-509": 941322, + "dusty-octopus-556": 941324, + "polished-cormorant-873": 941324, + "superb-ladybug-605": 941329, + "outstanding-impala-404": 941330, + "cheerful-rat-951": 941335, + "trustworthy-narwhal-374": 941336, + "greedy-bear-762": 941366, + "befitting-starfish-547": 941367, + "clear-chickadee-735": 941402, + "brazen-dove-544": 941403, + "fantastic-hippopotamus-646": 941626, + "perceptive-seal-726": 941627, + "moonlit-avocet-490": 941662, + "youthful-fish-277": 941664, + "elated-canary-940": 941686, + "vivid-roadrunner-550": 941687, + "content-chinchilla-82": 941743, + "groovy-clownfish-745": 941745, + "rugged-chinchilla-313": 941745, + "kindred-starfish-833": 941755, + "combative-avocet-127": 941756, + "agile-goldfish-342": 941756, + "earnest-bass-350": 941779, + "whimsical-jackal-188": 941780, + "groovy-fly-166": 941862, + "blissful-trout-763": 941863, + "confident-ram-898": 941881, + "rugged-whale-704": 941882, + "jovial-dog-979": 941886, + "glorious-spoonbill-922": 941887, + "brave-wildcat-336": 941892, + "joyous-partridge-504": 941893, + "amicable-falcon-705": 941929, + "aware-peacock-147": 941930, + "greedy-squid-162": 941930, + "next-fly-340": 941931, + "capable-gerbil-173": 941932, + "third-dog-78": 941932, + "outgoing-sardine-200": 941940, + "kindhearted-retriever-830": 941941, + "little-marten-99": 941943, + "shocking-rooster-70": 941944, + "artful-ostrich-328": 941955, + "brazen-curlew-242": 941956, + "original-cod-782": 941956, + "nautical-bee-610": 942025, + "zany-peccary-712": 942026, + "giddy-ocelot-707": 942032, + "giddy-ocelot-241": 942033, + "brainy-tapir-996": 942044, + "quirky-flamingo-891": 942045, + "acrobatic-minnow-39": 942083, + "optimistic-tortoise-402": 942084, + "proper-sparrow-679": 942084, + "calculating-bison-295": 942087, + "incredible-dogfish-603": 942088, + "blessed-boar-250": 942146, + "bright-dogfish-758": 942147, + "trustworthy-canary-413": 942173, + "stoic-bullfrog-791": 942174, + "resolute-dalmatian-72": 942197, + "impressive-labrador-509": 942198, + "beloved-newt-599": 942203, + "festive-pig-895": 942204, + "glorious-terrier-39": 942210, + "confident-rooster-884": 942211, + "zany-shepherd-947": 942227, + "impressive-guineapig-438": 942228, + "precise-horse-643": 942232, + "kindly-buffalo-672": 942233, + "adept-penguin-696": 942247, + "dusty-ptarmigan-720": 942249, + "keen-alpaca-546": 942251, + "moonlit-dinosaur-666": 942252, + "industrious-wildebeest-245": 942272, + "shocking-grouse-861": 942273, + "mellow-tortoise-210": 942290, + "brazen-monitor-590": 942291, + "canny-squid-386": 942291, + "stoic-sockeye-903": 942294, + "effervescent-badger-231": 942295, + "clear-minnow-977": 942307, + "trustworthy-grasshopper-907": 942308, + "charming-dinosaur-845": 942309, + "tremendous-snake-340": 942310, + "avid-horse-345": 942322, + "quiet-camel-493": 942324, + "abundant-viper-611": 942324, + "exuberant-goshawk-402": 942347, + "adjoining-mockingbird-226": 942348, + "adjoining-mammoth-681": 942370, + "graceful-lobster-570": 942371, + "sincere-scorpion-203": 942371, + "sensible-opossum-569": 942393, + "befitting-wolverine-645": 942394, + "cool-partridge-448": 942485, + "zany-dog-171": 942486, + "valiant-possum-171": 942497, + "vivid-wombat-610": 942498, + "elegant-tortoise-64": 942565, + "beaming-llama-250": 942566, + "courteous-echidna-952": 942591, + "efficient-marmot-235": 942592, + "capable-sardine-795": 942619, + "careful-hedgehog-55": 942620, + "energetic-vole-585": 942621, + "helpful-rooster-330": 942622, + "quixotic-newt-131": 942622, + "decisive-mongoose-636": 942657, + "agreeable-eagle-947": 942658, + "enchanted-ant-438": 942661, + "brazen-octopus-57": 942662, + "wonderful-panda-314": 942686, + "charming-labrador-919": 942687, + "exciting-marmot-719": 942713, + "compassionate-avocet-910": 942715, + "precious-kingfisher-332": 942724, + "silent-woodpecker-618": 942725, + "acoustic-bat-512": 942741, + "youthful-salmon-169": 942742, + "flexible-wolverine-950": 942744, + "giddy-parakeet-951": 942745, + "intent-otter-967": 942754, + "different-lemming-133": 942755, + "festive-kiwi-434": 942761, + "silent-falcon-342": 942762, + "groovy-nightingale-280": 942810, + "outstanding-jackal-830": 942811, + "expert-chickadee-503": 942812, + "perfect-chinchilla-63": 942812, + "accomplished-tiger-394": 942813, + "cool-chipmunk-519": 942829, + "successful-squid-301": 942830, + "academic-egret-709": 942850, + "resolute-tapir-158": 942851, + "tacit-caiman-660": 942851, + "glad-fish-149": 942917, + "dutiful-cricket-679": 942918, + "rapid-dachshund-556": 942920, + "dependable-rat-453": 942922, + "precise-mole-768": 942922, + "good-kingfisher-957": 942938, + "intent-clam-481": 942939, + "knowing-stingray-749": 942939, + "qualified-rhinoceros-184": 942981, + "small-bass-831": 942982, + "posh-fish-610": 942982, + "unique-barracuda-349": 942999, + "different-eel-616": 943000, + "tough-cricket-331": 943037, + "rightful-seahorse-112": 943038, + "clean-camel-558": 943045, + "polite-kookabura-365": 943046, + "grandiose-rook-335": 943061, + "dashing-bass-265": 943063, + "useful-weasel-312": 943078, + "sensible-otter-252": 943079, + "flexible-weasel-946": 943079, + "wary-cobra-107": 943081, + "brainy-mosquito-923": 943082, + "small-dalmatian-745": 943097, + "healthy-poodle-983": 943098, + "charming-crow-699": 943165, + "reminiscent-seal-950": 943167, + "gregarious-duck-534": 943169, + "judicious-lyrebird-948": 943170, + "confident-robin-84": 943193, + "sincere-ermine-568": 943194, + "abundant-marlin-606": 943194, + "majestic-eel-60": 943205, + "polite-penguin-329": 943206, + "wary-kiwi-974": 943215, + "fast-anteater-260": 943216, + "pastel-bass-676": 943303, + "oceanic-robin-0": 943304, + "quirky-stingray-844": 943326, + "impressive-skunk-728": 943327, + "fantastic-sturgeon-197": 943327, + "beaming-pigeon-829": 943337, + "energized-hummingbird-755": 943338, + "ideal-starfish-704": 943338, + "laudable-crow-345": 943362, + "expert-skunk-592": 943363, + "glad-frog-394": 943370, + "colorful-blackbird-586": 943371, + "calculating-pig-161": 943393, + "original-rook-226": 943395, + "handsome-tiger-349": 943473, + "secret-viper-253": 943474, + "quick-chipmunk-377": 943501, + "mild-hummingbird-272": 943502, + "sensible-newt-145": 943510, + "fast-vulture-360": 943512, + "youthful-lemur-853": 943521, + "scrupulous-sparrow-675": 943522, + "dynamic-lynx-527": 943522, + "outgoing-goose-493": 943555, + "hearty-basilisk-986": 943557, + "hallowed-lapwing-772": 943596, + "wooden-lark-99": 943605, + "hushed-penguin-265": 943606, + "pastel-marlin-452": 943606, + "decisive-skunk-372": 943615, + "woozy-guineapig-231": 943616, + "fiery-ibis-584": 943617, + "mellow-jellyfish-997": 943618, + "pleasant-mole-727": 943619, + "little-firefly-15": 943620, + "unique-lapwing-781": 943621, + "amiable-octopus-356": 943622, + "qualified-finch-168": 943623, + "cautious-bird-571": 943624, + "qualified-hedgehog-577": 943625, + "academic-fish-416": 943626, + "accomplished-chihuahua-62": 943627, + "hip-ram-636": 943628, + "strong-gnu-817": 943629, + "academic-aardvark-601": 943630, + "marvelous-trout-353": 943631, + "glorious-firefly-35": 943632, + "abundant-minnow-276": 943633, + "quixotic-otter-159": 943634, + "dependable-owl-709": 943636, + "rightful-dove-779": 943637, + "moonlit-retriever-192": 943644, + "kindly-dinosaur-17": 943645, + "fine-gerbil-211": 943645, + "gregarious-crow-690": 943687, + "capable-kangaroo-62": 943688, + "hidden-axolotl-0": 943730, + "determined-ferret-738": 943731, + "hearty-axolotl-873": 943741, + "quiet-nightingale-119": 943742, + "fast-ibis-957": 943742, + "curious-panther-258": 943797, + "clear-butterfly-544": 943798, + "secret-firefly-209": 943798, + "peaceful-swan-336": 943893, + "basic-narwhal-66": 943894, + "hidden-tortoise-389": 943942, + "patient-wolverine-787": 943945, + "loyal-firefly-172": 943975, + "fiery-civet-806": 943977, + "dashing-herring-704": 943992, + "harmless-chipmunk-155": 943993, + "frugal-civet-67": 943993, + "proper-penguin-914": 943994, + "enchanted-terrier-954": 943995, + "hushed-sturgeon-154": 944003, + "tremendous-guineapig-577": 944004, + "elegant-bear-750": 944033, + "spotted-pheasant-330": 944034, + "usable-swordfish-89": 944076, + "outgoing-rabbit-967": 944077, + "hip-dragon-601": 944086, + "grandiose-trout-750": 944087, + "fantastic-tiger-777": 944100, + "shiny-lemur-453": 944101, + "optimistic-butterfly-234": 944113, + "wonderful-goat-801": 944114, + "aromatic-ptarmigan-915": 944146, + "admired-goldfish-676": 944147, + "nautical-gopher-776": 944216, + "agreeable-salmon-590": 944218, + "exciting-snail-837": 944324, + "energetic-cassowary-764": 944325, + "fine-octopus-633": 944367, + "fantastic-goldfish-455": 944368, + "cheery-starling-718": 944414, + "glad-kingfisher-78": 944415, + "beaming-wildcat-164": 944430, + "strong-otter-827": 944451, + "jovial-dodo-268": 944452, + "hushed-schnauzer-118": 944463, + "pastel-bass-620": 944464, + "helpful-cod-835": 944465, + "content-peacock-134": 944466, + "adamant-chipmunk-458": 944490, + "blessed-minnow-646": 944491, + "precious-sockeye-67": 944494, + "helpful-deer-133": 944495, + "clever-ferret-992": 944523, + "ceaseless-sardine-317": 944524, + "beloved-hamster-295": 944531, + "keen-chihuahua-478": 944545, + "disciplined-gull-754": 944546, + "compassionate-roadrunner-605": 944617, + "curious-buzzard-136": 944618, + "abundant-oriole-231": 944618, + "handsome-jackal-409": 944633, + "utmost-vole-678": 944634, + "tacit-bandicoot-531": 944634, + "diligent-koala-725": 944636, + "coordinated-newt-235": 944639, + "standing-bulldog-221": 944682, + "disciplined-reindeer-845": 944683, + "quirky-hedgehog-248": 944710, + "aware-rhinoceros-237": 944711, + "rapid-oriole-974": 944728, + "useful-porcupine-598": 944729, + "grandiose-frog-948": 944771, + "mellow-beagle-167": 944821, + "youthful-narwhal-719": 944822, + "fortunate-beagle-728": 944822, + "dynamic-dragon-856": 944873, + "agile-anaconda-55": 944874, + "quixotic-lobster-442": 944876, + "nautical-ptarmigan-888": 944877, + "little-walrus-195": 944917, + "useful-snake-961": 944918, + "adventurous-narwhal-540": 944918, + "pleasant-whale-559": 944972, + "rare-porpoise-440": 944973, + "bright-peccary-966": 944973, + "robust-porpoise-203": 944994, + "striped-tortoise-497": 944995, + "curious-yak-408": 945014, + "necessary-salamander-484": 945015, + "abundant-mule-964": 945020, + "harmless-kookabura-955": 945021, + "reliable-swan-357": 945021, + "kindly-fish-34": 945026, + "amiable-zebra-486": 945027, + "dapper-goldfinch-227": 945031, + "adept-cardinal-701": 945032, + "colorful-sandpiper-18": 945032, + "resolute-goshawk-826": 945057, + "opulent-duck-602": 945058, + "valuable-possum-68": 945058, + "superb-jellyfish-746": 945064, + "optimistic-tapir-802": 945065, + "scintillating-nightingale-960": 945087, + "agile-ram-405": 945088, + "patient-pelican-497": 945088, + "posh-crocodile-13": 945127, + "combative-stoat-727": 945128, + "next-elephant-896": 945141, + "glorious-parakeet-532": 945142, + "quiet-newt-581": 945260, + "neighborly-chihuahua-921": 945261, + "tremendous-hedgehog-566": 945284, + "mild-peccary-981": 945285, + "intent-wildebeest-477": 945291, + "confident-bee-170": 945293, + "accomplished-roadrunner-382": 945293, + "adventurous-capybara-65": 945294, + "prestigious-mandrill-727": 945296, + "blessed-impala-328": 945302, + "notable-badger-826": 945303, + "dapper-rat-180": 945340, + "wonderful-owl-306": 945341, + "shocking-alligator-215": 945343, + "aromatic-seal-19": 945369, + "combative-camel-536": 945370, + "expert-tiger-942": 945378, + "charming-wombat-853": 945379, + "hardy-moose-626": 945390, + "little-labrador-498": 945391, + "merry-poodle-612": 945397, + "glorious-otter-772": 945398, + "wandering-peacock-181": 945399, + "impartial-sturgeon-314": 945400, + "proper-narwhal-450": 945417, + "chatty-guanaco-71": 945418, + "cautious-sparrow-94": 945418, + "fleet-manatee-889": 945431, + "artful-dotterel-908": 945432, + "fiery-bloodhound-894": 945457, + "capable-warbler-202": 945458, + "harmless-toucan-858": 945463, + "uncommon-lynx-983": 945464, + "brainy-kangaroo-206": 945465, + "gregarious-cod-431": 945466, + "exuberant-zebra-755": 945468, + "expert-ladybug-77": 945469, + "usable-horse-126": 945491, + "necessary-corgi-723": 945492, + "exciting-turtle-198": 945497, + "moonlit-pheasant-62": 945498, + "vibrant-tapir-585": 945502, + "glad-otter-435": 945503, + "ardent-walrus-321": 945571, + "quick-sturgeon-323": 945572, + "proper-spaniel-929": 945608, + "graceful-dove-153": 945609, + "dazzling-rook-908": 945609, + "tidy-civet-144": 945623, + "superb-kudu-679": 945624, + "helpful-marlin-415": 945624, + "exuberant-toucan-37": 945628, + "scintillating-dolphin-338": 945629, + "quixotic-warbler-506": 945629, + "affable-horse-95": 945660, + "clever-lobster-499": 945661, + "giddy-poodle-597": 945703, + "dutiful-cormorant-825": 945704, + "uncommon-dove-166": 945714, + "lovable-ram-100": 945716, + "admired-falcon-623": 945737, + "perceptive-butterfly-686": 945738, + "careful-gerbil-300": 945764, + "formal-chameleon-311": 945765, + "handsome-clownfish-746": 945804, + "colorful-mink-697": 945805, + "benevolent-alpaca-135": 945854, + "gallant-chicken-647": 945855, + "precious-mink-698": 945856, + "moonlit-finch-931": 945857, + "ardent-guineapig-458": 945864, + "insightful-bandicoot-664": 945865, + "formal-chameleon-78": 945865, + "third-ocelot-856": 945867, + "loyal-nightingale-159": 945868, + "groovy-antelope-710": 945907, + "intent-pigeon-996": 945909, + "compassionate-falcon-157": 945909, + "original-buffalo-706": 945983, + "kindly-condor-863": 945984, + "curious-cuttlefish-196": 946000, + "calculating-tortoise-971": 946001, + "robust-dove-534": 946085, + "dynamic-coyote-571": 946087, + "graceful-lobster-601": 946090, + "hidden-peccary-343": 946091, + "polished-goat-107": 946110, + "loyal-crane-57": 946111, + "focused-badger-395": 946140, + "notable-caiman-915": 946141, + "honorable-turtle-172": 946142, + "bold-mongoose-266": 946143, + "acoustic-ram-760": 946143, + "industrious-shark-42": 946192, + "whimsical-goldfish-350": 946193, + "original-gull-315": 946193, + "graceful-starling-212": 946200, + "polite-barracuda-684": 946201, + "peaceful-fox-420": 946202, + "adept-bullfrog-17": 946203, + "cheerful-viper-309": 946279, + "strong-manatee-552": 946280, + "watchful-robin-751": 946288, + "original-porpoise-451": 946290, + "veracious-mockingbird-883": 946290, + "exciting-wolf-509": 946309, + "strong-bass-834": 946310, + "flexible-vulture-436": 946344, + "standing-lobster-940": 946345, + "formal-fennec-803": 946348, + "grandiose-opossum-811": 946349, + "dazzling-lynx-318": 946361, + "neighborly-echidna-722": 946362, + "qualified-leopard-398": 946384, + "next-yak-773": 946385, + "lovable-bird-76": 946439, + "fiery-hippopotamus-242": 946440, + "loyal-salmon-772": 946446, + "tidy-frog-52": 946447, + "rosy-whale-984": 946447, + "colorless-walrus-586": 946453, + "optimistic-lyrebird-554": 946454, + "vibrant-sandpiper-517": 946459, + "aromatic-kookabura-567": 946460, + "tough-tiger-299": 946470, + "aromatic-puffin-676": 946471, + "frugal-toucan-487": 946472, + "tough-gull-680": 946473, + "rugged-shrimp-678": 946493, + "shiny-skunk-20": 946494, + "outgoing-crane-514": 946535, + "tangible-puffin-638": 946536, + "flippant-guanaco-131": 946536, + "steady-ferret-946": 946548, + "vibrant-dalmatian-254": 946549, + "merry-poodle-970": 946585, + "posh-spider-246": 946588, + "quaint-dog-840": 946588, + "affable-sheep-286": 946727, + "fine-mockingbird-158": 946728, + "incredible-orca-276": 946735, + "woozy-whale-379": 946736, + "canny-cod-172": 946750, + "aromatic-dragon-474": 946752, + "industrious-dove-29": 946752, + "compassionate-rabbit-699": 946778, + "acoustic-boar-662": 946779, + "healthy-kingfisher-567": 946789, + "kindred-guineapig-479": 946790, + "dusty-aardvark-677": 946792, + "capable-pigeon-493": 946793, + "content-basilisk-163": 946793, + "enchanted-fennec-602": 946866, + "groovy-mockingbird-284": 946867, + "tame-egret-567": 946867, + "industrious-hare-697": 946934, + "brave-tortoise-525": 946935, + "useful-fox-485": 946935, + "tangible-stingray-98": 946942, + "fleet-falcon-569": 946943, + "quiet-setter-698": 947062, + "dapper-puma-269": 947064, + "steady-lyrebird-193": 947065, + "zealous-tiger-793": 947066, + "helpful-goose-345": 947075, + "original-elk-673": 947162, + "kindred-dove-829": 947163, + "elegant-hound-192": 947218, + "accomplished-boar-599": 947219, + "focused-bass-735": 947246, + "posh-peacock-583": 947247, + "hushed-armadillo-674": 947410, + "different-toucan-134": 947411, + "befitting-llama-655": 947416, + "accurate-alligator-156": 947417, + "canny-otter-992": 947420, + "fearless-corgi-637": 947421, + "descriptive-ant-289": 947423, + "quiet-sardine-736": 947424, + "brilliant-cardinal-182": 947435, + "giddy-tortoise-5": 947436, + "basic-egret-944": 947474, + "kindly-malamute-880": 947475, + "curious-crocodile-542": 947475, + "impressive-rooster-651": 947482, + "necessary-tiger-167": 947483, + "vibrant-nightingale-958": 947485, + "qualified-emu-787": 947486, + "tacit-butterfly-584": 947497, + "blissful-caribou-362": 947498, + "quiet-pig-892": 947502, + "veracious-mallard-452": 947503, + "usable-gecko-454": 947514, + "impressive-mink-554": 947515, + "merry-blackbird-242": 947515, + "sensible-elk-562": 947524, + "compassionate-chameleon-691": 947525, + "quirky-ram-533": 947547, + "blessed-wildcat-897": 947548, + "peaceful-rooster-515": 947577, + "nautical-peccary-952": 947578, + "ceaseless-malamute-797": 947587, + "quick-finch-399": 947588, + "rapid-seahorse-230": 947618, + "scrupulous-terrier-290": 947628, + "glad-husky-628": 947629, + "outstanding-warbler-595": 947654, + "chatty-axolotl-495": 947655, + "hushed-fox-584": 947665, + "clean-rabbit-55": 947666, + "resolute-sheep-838": 947748, + "fastidious-tortoise-135": 947752, + "famous-spider-739": 947752, + "formal-cassowary-569": 947761, + "fastidious-armadillo-819": 947762, + "tidy-swordfish-436": 947777, + "focused-buzzard-368": 947778, + "proficient-bee-367": 947808, + "deafening-hare-974": 947811, + "fantastic-ferret-251": 947863, + "flippant-sheep-302": 947864, + "resilient-goldfinch-622": 947886, + "rightful-ladybug-57": 947887, + "knowing-koala-353": 947907, + "amiable-loris-206": 947908, + "curious-snake-211": 947911, + "dashing-manatee-485": 947912, + "avid-spider-864": 947922, + "fantastic-condor-356": 947923, + "striped-rabbit-876": 947945, + "third-shrimp-786": 947946, + "brazen-mongoose-889": 948059, + "dynamic-bandicoot-717": 948060, + "hearty-terrier-552": 948060, + "hip-okapi-182": 948086, + "lovely-robin-31": 948087, + "lovely-tapir-81": 948102, + "patient-caribou-903": 948103, + "polished-cuttlefish-435": 948114, + "handsome-ant-909": 948115, + "accomplished-mouse-205": 948134, + "savory-whale-155": 948149, + "uncommon-setter-61": 948150, + "fearless-dachshund-833": 948153, + "earnest-zebra-625": 948154, + "judicious-panther-989": 948157, + "patient-seahorse-14": 948158, + "helpful-goat-673": 948221, + "calm-condor-915": 948222, + "groovy-hedgehog-368": 948254, + "astute-skunk-971": 948255, + "brave-camel-994": 948287, + "adorable-salamander-574": 948288, + "curious-tortoise-723": 948311, + "outgoing-parrot-817": 948313, + "striped-panther-204": 948348, + "pastel-caiman-92": 948349, + "amiable-grasshopper-97": 948355, + "hearty-echidna-355": 948356, + "kindhearted-meerkat-411": 948356, + "quirky-hummingbird-123": 948411, + "grandiose-newt-172": 948412, + "fabulous-yak-343": 948412, + "grandiose-pig-866": 948451, + "jovial-alpaca-63": 948452, + "wooden-finch-405": 948463, + "beloved-goldfish-961": 948464, + "enduring-dodo-500": 948464, + "energized-porpoise-166": 948471, + "mild-cheetah-645": 948472, + "clear-bass-887": 948487, + "fastidious-ermine-691": 948488, + "cheerful-albatross-124": 948497, + "posh-quail-77": 948498, + "resilient-anaconda-932": 948569, + "enduring-viper-270": 948570, + "dapper-wombat-440": 948571, + "diligent-rhinoceros-990": 948572, + "grand-iguana-29": 948643, + "hallowed-crocodile-427": 948644, + "rare-possum-180": 948654, + "abundant-civet-945": 948655, + "adjoining-spaniel-529": 948656, + "kindhearted-cat-442": 948657, + "quirky-malamute-654": 948682, + "cheery-swordfish-708": 948684, + "gregarious-vulture-190": 948686, + "determined-pig-858": 948688, + "hardy-crow-491": 948698, + "charming-lapwing-621": 948699, + "superb-pelican-114": 948699, + "marvelous-swan-255": 948735, + "blissful-chameleon-420": 948736, + "whimsical-herring-92": 948743, + "courteous-dolphin-781": 948744, + "cheery-bass-300": 948750, + "stoic-anaconda-321": 948751, + "famous-dachshund-240": 948801, + "laudable-guineapig-695": 948802, + "confident-curlew-364": 948818, + "wonderful-narwhal-695": 948819, + "neighborly-grasshopper-819": 948842, + "greedy-ladybug-183": 948843, + "adjoining-shrimp-456": 948843, + "mellow-buffalo-640": 948852, + "accomplished-cat-429": 948854, + "lovable-canary-160": 948872, + "acrobatic-snake-844": 948873, + "resilient-stork-532": 948898, + "handsome-crane-74": 948899, + "hip-snake-62": 948949, + "fine-tortoise-105": 948950, + "aromatic-orca-209": 948968, + "brave-clam-597": 948969, + "stoic-lyrebird-196": 949007, + "strong-ptarmigan-299": 949009, + "quiet-cuttlefish-66": 949009, + "aromatic-crane-981": 949111, + "tacit-magpie-5": 949112, + "terrific-toucan-444": 949169, + "cheery-lobster-937": 949170, + "colorless-raven-907": 949172, + "shocking-egret-245": 949173, + "quiet-rooster-311": 949181, + "effervescent-hare-15": 949182, + "neat-marmot-868": 949182, + "scrupulous-ibis-139": 949197, + "reminiscent-antelope-87": 949198, + "fantastic-lark-839": 949212, + "mild-coyote-412": 949213, + "dapper-toad-581": 949213, + "courteous-mouse-721": 949283, + "aromatic-moose-770": 949284, + "precious-civet-420": 949311, + "admired-stork-663": 949313, + "chatty-penguin-974": 949314, + "greedy-eel-391": 949315, + "disciplined-herring-365": 949367, + "quick-magpie-933": 949368, + "unique-gazelle-394": 949376, + "graceful-mongoose-974": 949377, + "benevolent-mole-281": 949395, + "brilliant-corgi-808": 949396, + "sleek-akita-484": 949396, + "clean-zebra-621": 949427, + "little-llama-460": 949428, + "tough-platypus-442": 949499, + "zealous-beagle-886": 949500, + "opulent-hare-366": 949500, + "oceanic-walrus-627": 949540, + "diligent-bat-432": 949540, + "little-peccary-422": 949557, + "quick-alligator-882": 949558, + "hushed-leopard-261": 949651, + "decisive-bass-401": 949653, + "cheery-squid-187": 949740, + "dutiful-magpie-100": 949741, + "clear-narwhal-580": 949741, + "determined-goldfish-81": 949750, + "befitting-guanaco-626": 949752, + "grateful-cricket-825": 949752, + "first-gull-816": 949755, + "colorless-malamute-517": 949756, + "earnest-stoat-122": 949759, + "agreeable-dachshund-820": 949760, + "compassionate-finch-385": 949945, + "exuberant-chipmunk-969": 949946, + "scintillating-donkey-830": 949948, + "dazzling-owl-820": 949949, + "efficient-squid-17": 949949, + "groovy-sheep-469": 949950, + "determined-buzzard-786": 949952, + "useful-starfish-311": 949952, + "next-perch-999": 949971, + "oceanic-meerkat-334": 949972, + "content-salamander-360": 949972, + "reliable-otter-599": 949979, + "proper-bison-112": 949980, + "robust-civet-222": 950017, + "canny-condor-690": 950018, + "combative-rooster-491": 950044, + "necessary-hedgehog-835": 950045, + "scrupulous-wildcat-688": 950095, + "brilliant-canary-299": 950097, + "prestigious-caterpillar-120": 950232, + "dashing-mosquito-244": 950233, + "joyous-cheetah-945": 950234, + "tame-rat-409": 950235, + "scrupulous-newt-937": 950236, + "amicable-raven-163": 950237, + "animated-squirrel-933": 950241, + "reliable-giraffe-219": 950252, + "clean-warthog-87": 950253, + "zealous-minnow-350": 950271, + "animated-lobster-504": 950272, + "useful-avocet-613": 950285, + "expert-coyote-547": 950286, + "dashing-axolotl-710": 950289, + "zealous-gnu-758": 950290, + "shiny-ptarmigan-812": 950294, + "useful-chicken-701": 950295, + "outgoing-chihuahua-948": 950295, + "clean-beagle-721": 950313, + "calm-tapir-216": 950314, + "keen-manatee-343": 950314, + "wry-duck-116": 950449, + "descriptive-egret-476": 950450, + "proficient-lemming-912": 950451, + "earnest-barracuda-866": 950452, + "glad-woodpecker-784": 950461, + "gallant-snail-442": 950468, + "handsome-frog-588": 950469, + "scintillating-ant-291": 950511, + "amicable-alligator-720": 950517, + "enchanted-gopher-764": 950518, + "agile-stoat-654": 950523, + "hearty-opossum-31": 950524, + "judicious-starfish-542": 950530, + "earnest-chipmunk-32": 950531, + "hip-fly-807": 950531, + "cheerful-chihuahua-339": 950550, + "fine-hippopotamus-767": 950551, + "quaint-sturgeon-856": 950579, + "fast-tiger-50": 950580, + "neighborly-crocodile-629": 950645, + "warmhearted-mink-467": 950647, + "blissful-lapwing-639": 950652, + "fearless-dinosaur-859": 950653, + "confident-bullfrog-898": 950661, + "beloved-mockingbird-957": 950662, + "knowing-kookabura-575": 950662, + "accurate-malamute-194": 950692, + "compassionate-bass-203": 950693, + "giant-hippopotamus-843": 950709, + "industrious-lyrebird-138": 950710, + "exuberant-porcupine-759": 950710, + "knowing-buzzard-519": 950711, + "watchful-spider-593": 950712, + "befitting-condor-609": 950755, + "sincere-falcon-538": 950756, + "tough-skunk-787": 950834, + "accurate-newt-160": 950835, + "affable-cardinal-860": 950866, + "lovely-meerkat-807": 950867, + "dusty-zebra-948": 950867, + "admired-seal-428": 950870, + "fast-bird-650": 950872, + "dapper-cheetah-607": 950895, + "handsome-terrier-824": 950896, + "bright-squirrel-638": 950896, + "veracious-canary-895": 950912, + "precious-shrimp-49": 950913, + "capable-mosquito-977": 950934, + "rare-bird-72": 950935, + "diligent-dragon-569": 950948, + "confident-canary-116": 950949, + "pastel-lyrebird-860": 950950, + "honorable-lobster-304": 950951, + "hushed-opossum-0": 950956, + "first-retriever-157": 950957, + "opulent-crocodile-369": 950971, + "reminiscent-bulldog-261": 950972, + "aware-octopus-119": 951002, + "impressive-wolf-191": 951003, + "polite-hyena-906": 951020, + "neat-barracuda-469": 951021, + "accomplished-falcon-546": 951067, + "wooden-pheasant-471": 951068, + "earnest-fox-218": 951133, + "veracious-mandrill-368": 951135, + "optimistic-coyote-688": 951136, + "ideal-shrimp-923": 951137, + "bright-ostrich-433": 951138, + "harmless-civet-421": 951140, + "quixotic-mole-305": 951142, + "careful-greyhound-895": 951145, + "knowing-pig-867": 951146, + "different-caribou-860": 951153, + "dashing-dodo-72": 951155, + "enduring-crow-341": 951156, + "ardent-elephant-542": 951157, + "dusty-lobster-550": 951166, + "dependable-bullfrog-911": 951167, + "decisive-scorpion-968": 951191, + "outgoing-toad-396": 951193, + "vivid-lark-255": 951206, + "tough-ermine-384": 951208, + "quiet-mallard-357": 951236, + "cool-crocodile-903": 951237, + "canny-kangaroo-271": 951274, + "posh-caribou-376": 951276, + "uncommon-horse-280": 951289, + "amicable-ladybug-390": 951290, + "charming-ram-152": 951290, + "dynamic-elephant-114": 951298, + "joyous-egret-349": 951299, + "combative-terrier-388": 951300, + "quaint-bullfrog-207": 951301, + "focused-malamute-569": 951307, + "clear-bear-549": 951308, + "rapid-magpie-566": 951351, + "clever-mammoth-608": 951352, + "small-bird-71": 951352, + "ceaseless-narwhal-143": 951375, + "resolute-llama-616": 951376, + "quixotic-ptarmigan-717": 951378, + "wooden-hedgehog-140": 951379, + "vibrant-badger-199": 951417, + "fast-gazelle-868": 951418, + "polite-chicken-291": 951476, + "adventurous-porpoise-965": 951477, + "scrupulous-swordfish-2": 951487, + "efficient-ostrich-72": 951488, + "fast-raccoon-831": 951489, + "amiable-butterfly-969": 951490, + "useful-cardinal-363": 951495, + "modest-pig-49": 951496, + "polished-cassowary-123": 951497, + "robust-seahorse-436": 951499, + "loyal-wombat-145": 951537, + "kindred-vole-475": 951538, + "notable-jay-463": 951539, + "quick-eagle-557": 951540, + "flexible-hare-361": 951616, + "zany-puma-456": 951617, + "tidy-spoonbill-641": 951635, + "grateful-basilisk-54": 951636, + "robust-clam-843": 951673, + "adamant-condor-432": 951674, + "incredible-mallard-515": 951734, + "usable-platypus-633": 951735, + "moonlit-jackal-460": 951735, + "stoic-possum-554": 951790, + "polite-wren-88": 951791, + "successful-hare-414": 951851, + "charming-snail-837": 951852, + "silent-whale-727": 951864, + "amiable-otter-208": 951865, + "robust-canary-225": 951874, + "earnest-canary-571": 951875, + "joyous-tiger-51": 951881, + "different-possum-365": 951882, + "adept-marmot-777": 951889, + "pastel-clam-567": 951890, + "hushed-cassowary-667": 951932, + "sensible-ostrich-63": 951933, + "uncommon-ant-467": 951934, + "brazen-poodle-192": 951935, + "limitless-condor-806": 951947, + "coordinated-goshawk-981": 951949, + "patient-sheep-863": 951958, + "majestic-crane-468": 951960, + "rare-firefly-631": 951969, + "energized-donkey-104": 951970, + "energetic-peacock-934": 951993, + "outstanding-puma-385": 951994, + "charming-oyster-64": 951998, + "abundant-fish-441": 951999, + "tremendous-tortoise-730": 951999, + "compassionate-guineapig-8": 952041, + "moonlit-hippopotamus-726": 952042, + "groovy-eel-812": 952042, + "striped-akita-844": 952083, + "formal-chickadee-487": 952084, + "compassionate-elk-87": 952134, + "shocking-goshawk-250": 952135, + "lovely-coyote-939": 952195, + "good-rook-21": 952196, + "pastel-swordfish-94": 952199, + "zany-goldfish-320": 952200, + "grateful-raven-228": 952241, + "formal-kudu-201": 952242, + "capable-gopher-111": 952250, + "combative-lion-687": 952251, + "pleasant-nightingale-855": 952266, + "exciting-tapir-294": 952267, + "affable-rabbit-595": 952267, + "sleek-antelope-756": 952296, + "proper-flamingo-42": 952297, + "fiery-oyster-324": 952306, + "astute-ant-194": 952307, + "aware-stoat-749": 952524, + "adjoining-akita-707": 952525, + "content-hamster-564": 952525, + "agreeable-meadowlark-684": 952588, + "aware-pelican-222": 952589, + "grandiose-llama-349": 952589, + "utmost-rooster-645": 952620, + "tidy-barracuda-547": 952621, + "sincere-bird-610": 952634, + "peaceful-duck-58": 952635, + "usable-rook-921": 952661, + "prestigious-bird-79": 952662, + "superb-partridge-503": 952677, + "perceptive-herring-853": 952678, + "fabulous-gecko-471": 952715, + "usable-anteater-134": 952717, + "kindred-woodpecker-834": 952742, + "giddy-reindeer-861": 952743, + "acrobatic-goose-263": 952764, + "calculating-orca-796": 952766, + "lovely-mastiff-213": 952772, + "harmless-hyena-746": 952773, + "amiable-ptarmigan-908": 952790, + "cheerful-sparrow-180": 952791, + "impartial-chameleon-93": 952801, + "famous-avocet-457": 952802, + "polished-pelican-137": 952831, + "gregarious-ptarmigan-866": 952832, + "famous-pig-587": 952845, + "brilliant-hippopotamus-486": 952860, + "warmhearted-chicken-430": 952861, + "savory-clam-693": 952864, + "determined-greyhound-149": 952865, + "sensible-axolotl-77": 952892, + "little-grouse-517": 952893, + "brave-falcon-877": 952893, + "woozy-swan-490": 952912, + "insightful-seahorse-397": 952913, + "tame-eagle-250": 952923, + "handsome-okapi-617": 952925, + "tough-magpie-29": 952937, + "next-hornet-727": 952938, + "ideal-ocelot-504": 952938, + "industrious-rook-134": 952962, + "acoustic-crab-180": 952963, + "admired-ferret-232": 953000, + "judicious-narwhal-994": 953001, + "groovy-goldfish-785": 953024, + "incredible-ferret-946": 953025, + "blissful-donkey-353": 953027, + "notable-possum-938": 953028, + "acrobatic-meadowlark-675": 953041, + "academic-eel-153": 953042, + "doting-rabbit-478": 953088, + "tangible-mammoth-148": 953089, + "youthful-wolf-528": 953096, + "first-sparrow-22": 953097, + "judicious-spider-496": 953106, + "outstanding-walrus-355": 953107, + "dashing-rat-357": 953114, + "energized-partridge-873": 953115, + "coordinated-hawk-292": 953122, + "accurate-crane-64": 953123, + "hidden-falcon-316": 953151, + "acoustic-alligator-691": 953152, + "chatty-goldfinch-178": 953195, + "sleek-firefly-404": 953197, + "cheerful-axolotl-179": 953218, + "compassionate-bandicoot-899": 953220, + "majestic-greyhound-109": 953224, + "glorious-ram-17": 953225, + "exciting-turtle-944": 953267, + "uncommon-zebra-119": 953269, + "gallant-jackal-463": 953291, + "grateful-viper-431": 953292, + "veracious-jackal-266": 953339, + "oceanic-curlew-950": 953340, + "veracious-rat-831": 953347, + "quaint-dotterel-58": 953348, + "giant-stoat-638": 953349, + "precise-labrador-126": 953350, + "earnest-herring-413": 953351, + "dashing-narwhal-230": 953352, + "chatty-elk-978": 953356, + "superb-dove-357": 953357, + "first-labrador-269": 953374, + "laudable-meadowlark-181": 953375, + "exciting-pigeon-810": 953382, + "different-marmot-954": 953384, + "helpful-pheasant-137": 953392, + "neighborly-bullfrog-17": 953393, + "determined-crocodile-98": 953401, + "posh-crocodile-407": 953402, + "upbeat-blackbird-628": 953402, + "fastidious-fly-742": 953406, + "outstanding-bandicoot-11": 953408, + "tame-gnu-954": 953411, + "mellow-caiman-838": 953412, + "clear-starling-569": 953417, + "clever-chihuahua-648": 953418, + "tough-canary-833": 953420, + "resilient-squirrel-271": 953420, + "scintillating-clownfish-903": 953421, + "gallant-caribou-722": 953425, + "cheery-tortoise-941": 953426, + "groovy-dragon-312": 953429, + "brazen-retriever-190": 953429, + "helpful-emu-844": 953460, + "hip-crow-421": 953461, + "energetic-caiman-433": 953482, + "moonlit-hedgehog-570": 953483, + "notable-hedgehog-874": 953503, + "honorable-eagle-63": 953504, + "resilient-cassowary-458": 953507, + "majestic-magpie-704": 953508, + "scrupulous-meerkat-26": 953535, + "modest-pika-117": 953536, + "rare-jellyfish-564": 953558, + "dutiful-stork-555": 953559, + "coordinated-cormorant-2": 953567, + "dusty-koala-435": 953568, + "coordinated-squid-83": 953578, + "ideal-seahorse-250": 953579, + "kindly-marlin-502": 953579, + "capable-buzzard-345": 953580, + "confident-viper-199": 953581, + "descriptive-frog-696": 953586, + "valiant-crow-60": 953587, + "enchanted-cheetah-329": 953592, + "perceptive-butterfly-105": 953593, + "healthy-pigeon-700": 953595, + "laudable-stingray-590": 953596, + "terrific-butterfly-195": 953617, + "hushed-beagle-295": 953618, + "little-chameleon-949": 953624, + "pastel-horse-434": 953625, + "calculating-manatee-704": 953647, + "courteous-frog-224": 953656, + "grateful-clam-299": 953657, + "veracious-dinosaur-329": 953678, + "tough-peccary-847": 953680, + "dutiful-mink-176": 953689, + "striped-perch-331": 953690, + "usable-goose-505": 953691, + "next-pika-356": 953692, + "striped-sockeye-262": 953693, + "resolute-frog-460": 953694, + "fiery-flamingo-601": 953695, + "exciting-clownfish-674": 953696, + "cool-dove-603": 953697, + "dazzling-hound-816": 953698, + "decisive-weasel-702": 953702, + "first-wolverine-504": 953703, + "dependable-mallard-907": 953704, + "grand-echidna-783": 953705, + "zany-bat-751": 953706, + "flexible-bass-707": 953708, + "proper-dalmatian-778": 953709, + "polished-ox-183": 953709, + "quixotic-viper-598": 953712, + "successful-grouse-402": 953713, + "academic-eel-659": 953715, + "trustworthy-stoat-756": 953716, + "industrious-civet-468": 953717, + "colorless-chihuahua-802": 953726, + "veracious-seal-208": 953727, + "rapid-bulldog-543": 953732, + "fastidious-squirrel-578": 953733, + "hearty-sandpiper-240": 953743, + "dutiful-platypus-621": 953744, + "lovable-meadowlark-192": 953750, + "opulent-echidna-94": 953751, + "academic-wildebeest-287": 953752, + "fortunate-echidna-352": 953753, + "elated-dodo-810": 953767, + "warmhearted-aardvark-109": 953768, + "flexible-marmot-759": 953783, + "tacit-gnu-928": 953784, + "cheerful-walrus-806": 953790, + "abundant-goldfish-155": 953791, + "ardent-sardine-933": 953806, + "glorious-pheasant-443": 953807, + "superb-hummingbird-502": 953808, + "fiery-cardinal-193": 953809, + "polite-fox-652": 953820, + "judicious-turtle-36": 953821, + "graceful-anteater-993": 953830, + "qualified-platypus-266": 953831, + "secret-pelican-703": 953852, + "tacit-rabbit-941": 953854, + "fabulous-rooster-62": 953868, + "standing-toucan-897": 953869, + "nautical-basilisk-148": 953891, + "calculating-bee-769": 953892, + "sleek-buffalo-109": 953905, + "superb-chipmunk-923": 953906, + "ideal-blackbird-473": 953908, + "disciplined-rhinoceros-682": 953909, + "amicable-dove-121": 953913, + "warmhearted-hornet-826": 953915, + "vibrant-lynx-353": 953915, + "incredible-alligator-374": 953917, + "merry-weasel-392": 953918, + "glad-antelope-849": 953928, + "decisive-starling-239": 953929, + "resolute-bee-843": 953936, + "famous-butterfly-368": 953937, + "expert-robin-133": 953947, + "brilliant-cardinal-345": 953949, + "fabulous-chipmunk-297": 953974, + "bright-bulldog-307": 953975, + "acoustic-yak-88": 953981, + "descriptive-barracuda-745": 953982, + "perfect-spaniel-928": 953985, + "fantastic-dragon-185": 953986, + "brainy-condor-154": 953999, + "dependable-elk-218": 954000, + "third-hamster-330": 954001, + "impartial-kiwi-551": 954002, + "colorful-pony-618": 954020, + "wonderful-deer-105": 954021, + "prestigious-manatee-755": 954028, + "energized-rhinoceros-860": 954029, + "sleek-marlin-713": 954049, + "wry-hawk-641": 954050, + "dusty-camel-320": 954067, + "oceanic-orca-474": 954068, + "little-swan-192": 954068, + "patient-barracuda-219": 954079, + "majestic-lyrebird-101": 954080, + "healthy-parrot-244": 954083, + "sincere-porcupine-602": 954084, + "youthful-crow-308": 954099, + "elegant-armadillo-323": 954100, + "fastidious-ox-73": 954108, + "sleek-egret-439": 954109, + "sleek-crow-907": 954127, + "shiny-basilisk-467": 954128, + "grandiose-herring-960": 954145, + "hearty-lion-908": 954146, + "savory-armadillo-516": 954154, + "woozy-possum-439": 954155, + "upbeat-lobster-542": 954176, + "joyous-lemur-920": 954177, + "astute-dragon-743": 954177, + "adorable-stingray-411": 954188, + "veracious-pheasant-925": 954190, + "loyal-finch-7": 954191, + "healthy-impala-75": 954192, + "mellow-goldfinch-72": 954193, + "joyous-flamingo-728": 954194, + "laudable-monitor-863": 954195, + "proper-chicken-858": 954201, + "enduring-salmon-582": 954202, + "zany-akita-130": 954203, + "flippant-elk-406": 954204, + "beloved-lemur-207": 954206, + "tough-hound-336": 954207, + "hallowed-seahorse-16": 954216, + "trustworthy-bass-655": 954218, + "third-camel-82": 954219, + "pastel-lemming-355": 954220, + "calm-lemur-257": 954221, + "befitting-lemur-270": 954222, + "lovable-goldfinch-320": 954225, + "sensible-platypus-959": 954226, + "determined-jackal-570": 954231, + "combative-buffalo-858": 954232, + "accomplished-chinchilla-275": 954241, + "proper-hamster-354": 954242, + "impartial-bass-272": 954246, + "sensible-pheasant-784": 954247, + "ardent-roadrunner-487": 954252, + "calm-ptarmigan-347": 954253, + "expert-rabbit-337": 954257, + "kindred-curlew-527": 954258, + "dazzling-greyhound-172": 954259, + "perfect-sheep-397": 954260, + "wary-spider-742": 954265, + "fast-cardinal-456": 954266, + "youthful-kookabura-92": 954267, + "resolute-bulldog-19": 954268, + "impartial-kookabura-628": 954289, + "outstanding-ant-525": 954290, + "handsome-viper-177": 954294, + "focused-manatee-730": 954295, + "tacit-barracuda-636": 954295, + "utmost-moose-642": 954298, + "neighborly-sturgeon-489": 954299, + "cheerful-hound-197": 954305, + "polished-warthog-434": 954306, + "neat-octopus-17": 954308, + "calm-bobcat-575": 954309, + "rugged-cricket-350": 954324, + "tidy-lapwing-912": 954325, + "cheery-fish-698": 954326, + "combative-sturgeon-260": 954326, + "impartial-buffalo-177": 954327, + "modest-mammoth-51": 954330, + "blessed-bass-592": 954331, + "brazen-anaconda-974": 954356, + "blissful-butterfly-796": 954357, + "trustworthy-penguin-913": 954365, + "aromatic-guineapig-779": 954367, + "secret-cheetah-185": 954373, + "clever-starfish-881": 954374, + "blessed-hyena-530": 954376, + "zany-camel-455": 954377, + "adjoining-leopard-528": 954377, + "uncommon-vole-329": 954383, + "greedy-wombat-955": 954384, + "brainy-yak-230": 954416, + "kindly-salmon-570": 954417, + "knowing-starfish-976": 954428, + "strong-sardine-964": 954429, + "accomplished-narwhal-438": 954468, + "admired-raccoon-914": 954469, + "adjoining-meadowlark-773": 954499, + "outstanding-pony-776": 954500, + "opulent-parakeet-276": 954507, + "mild-puffin-298": 954508, + "first-stoat-979": 954511, + "frugal-ox-192": 954512, + "flexible-tapir-411": 954528, + "elegant-firefly-593": 954529, + "hearty-mule-516": 954553, + "woozy-bobcat-722": 954554, + "patient-woodpecker-921": 954564, + "animated-gazelle-170": 954565, + "gregarious-fox-266": 954574, + "avid-squid-254": 954575, + "abundant-hawk-66": 954607, + "polite-aardvark-629": 954608, + "proper-crow-160": 954630, + "majestic-lobster-724": 954631, + "sleek-hare-304": 954655, + "healthy-raccoon-860": 954656, + "descriptive-elk-219": 954666, + "hearty-dolphin-522": 954669, + "disciplined-dolphin-403": 954669, + "fast-shrimp-285": 954682, + "qualified-tiger-745": 954683, + "glad-deer-999": 954741, + "ideal-wildcat-892": 954742, + "greedy-clownfish-296": 954745, + "frugal-bandicoot-520": 954746, + "agile-mammoth-215": 954750, + "famous-shrimp-14": 954751, + "cautious-perch-973": 954765, + "precious-tapir-744": 954766, + "tangible-salamander-243": 954771, + "judicious-hedgehog-123": 954772, + "laudable-snail-288": 954780, + "fabulous-raccoon-767": 954781, + "first-gerbil-585": 954794, + "rosy-marten-166": 954795, + "neat-cricket-246": 954818, + "watchful-dachshund-467": 954819, + "tacit-capybara-108": 954843, + "calculating-lobster-659": 954844, + "perfect-butterfly-941": 954878, + "coordinated-crocodile-664": 954879, + "dusty-cobra-61": 954880, + "calm-snail-437": 954881, + "careful-bandicoot-672": 954911, + "wooden-impala-71": 954912, + "perceptive-blackbird-348": 954912, + "warmhearted-spoonbill-315": 954919, + "aromatic-greyhound-686": 954921, + "harmless-tern-955": 954940, + "kindred-boar-8": 954941, + "brazen-pika-493": 954955, + "helpful-crow-229": 954956, + "incredible-wolf-381": 954960, + "first-cobra-940": 954961, + "mild-basilisk-282": 954979, + "secret-caiman-834": 954980, + "admired-mosquito-210": 954982, + "fast-goldfish-797": 954983, + "amiable-capybara-961": 955006, + "kindhearted-panda-727": 955007, + "marvelous-fly-120": 955008, + "descriptive-wolf-390": 955010, + "modest-lynx-392": 955029, + "utmost-whale-778": 955030, + "valuable-cassowary-48": 955037, + "adjoining-cormorant-223": 955038, + "reminiscent-grasshopper-834": 955051, + "successful-dove-404": 955055, + "small-oyster-20": 955055, + "judicious-buffalo-255": 955059, + "rapid-hare-926": 955060, + "wandering-vulture-445": 955061, + "rapid-whale-247": 955062, + "hearty-peccary-70": 955063, + "chatty-gopher-949": 955064, + "rare-clownfish-885": 955076, + "abundant-cormorant-507": 955077, + "amicable-camel-307": 955090, + "kindhearted-minnow-117": 955091, + "groovy-hedgehog-462": 955091, + "first-vulture-188": 955097, + "earnest-guanaco-134": 955099, + "colorful-donkey-944": 955110, + "rare-cardinal-701": 955111, + "kindred-kiwi-904": 955128, + "moonlit-curlew-7": 955129, + "admired-squid-493": 955139, + "animated-mandrill-253": 955140, + "unique-cheetah-93": 955187, + "bright-mole-672": 955189, + "impartial-buffalo-66": 955202, + "impartial-parakeet-211": 955203, + "tough-narwhal-542": 955203, + "valiant-coyote-715": 955226, + "fortunate-giraffe-871": 955227, + "affable-kangaroo-78": 955233, + "friendly-reindeer-963": 955235, + "aromatic-orca-614": 955235, + "clean-bison-751": 955269, + "beaming-chameleon-106": 955271, + "energetic-puma-437": 955300, + "impartial-hedgehog-809": 955301, + "honorable-giraffe-74": 955304, + "industrious-kingfisher-508": 955305, + "quirky-mink-656": 955305, + "industrious-ermine-427": 955315, + "courteous-tern-488": 955316, + "content-eagle-998": 955317, + "famous-jaguar-973": 955318, + "brave-pika-100": 955321, + "scintillating-sardine-16": 955322, + "opulent-manatee-855": 955325, + "qualified-ptarmigan-549": 955326, + "trustworthy-hare-526": 955332, + "prestigious-woodpecker-775": 955333, + "tremendous-mockingbird-574": 955342, + "resolute-mosquito-256": 955343, + "amiable-bear-943": 955345, + "dusty-curlew-219": 955346, + "aromatic-warthog-639": 955346, + "adept-orca-764": 955347, + "oceanic-sockeye-824": 955348, + "perfect-tern-95": 955388, + "uncommon-tern-808": 955389, + "kindhearted-lobster-615": 955405, + "limitless-ibex-529": 955407, + "rightful-cassowary-798": 955429, + "pleasant-cod-329": 955430, + "whimsical-narwhal-50": 955438, + "polite-mouse-24": 955439, + "dapper-fly-601": 955443, + "rugged-ferret-548": 955444, + "unique-panther-570": 955466, + "keen-cuttlefish-525": 955467, + "ardent-emu-499": 955467, + "formal-chameleon-756": 955517, + "agile-robin-553": 955518, + "rosy-scorpion-788": 955522, + "fastidious-chickadee-463": 955524, + "grandiose-koala-292": 955542, + "bright-pig-971": 955543, + "neighborly-wren-706": 955568, + "accomplished-walrus-853": 955569, + "notable-wren-20": 955579, + "rightful-lynx-742": 955580, + "fabulous-parrot-678": 955594, + "friendly-axolotl-182": 955596, + "hip-viper-582": 955602, + "utmost-bass-485": 955603, + "bold-dodo-759": 955604, + "diligent-perch-297": 955605, + "harmless-guineapig-550": 955606, + "gregarious-ocelot-278": 955607, + "beloved-crow-380": 955610, + "oceanic-snake-1": 955611, + "amiable-goat-620": 955626, + "curious-goshawk-325": 955627, + "impartial-pheasant-445": 955679, + "vivid-hummingbird-537": 955681, + "outstanding-dove-467": 955691, + "grand-iguana-398": 955692, + "hallowed-anteater-370": 955741, + "brainy-grouse-578": 955742, + "dusty-caterpillar-809": 955811, + "fastidious-kudu-190": 955812, + "different-grasshopper-31": 955824, + "knowing-gerbil-763": 955825, + "different-viper-579": 955857, + "youthful-loris-306": 955858, + "different-goldfinch-331": 955868, + "bold-puffin-672": 955869, + "woozy-hare-649": 955869, + "helpful-chameleon-843": 955874, + "greedy-clownfish-944": 955875, + "sleek-dodo-866": 955890, + "decisive-meerkat-378": 955891, + "patient-ant-880": 955932, + "kindly-bird-866": 955933, + "little-warthog-324": 955975, + "honorable-gopher-1": 955976, + "standing-dogfish-464": 955988, + "efficient-hawk-332": 955989, + "focused-meadowlark-983": 955989, + "peaceful-mosquito-51": 956009, + "fiery-wildcat-364": 956010, + "agreeable-bullfrog-987": 956018, + "reminiscent-goose-556": 956020, + "cheery-goshawk-106": 956049, + "dashing-gazelle-860": 956051, + "exuberant-swordfish-601": 956056, + "focused-pelican-176": 956058, + "charming-clam-753": 956063, + "adventurous-clam-410": 956064, + "compassionate-dotterel-396": 956064, + "vivid-ram-212": 956070, + "sincere-vulture-832": 956071, + "kindly-schnauzer-326": 956074, + "cautious-porcupine-419": 956075, + "focused-caribou-587": 956077, + "grand-swordfish-24": 956078, + "adjoining-albatross-784": 956080, + "glorious-labrador-246": 956119, + "efficient-bloodhound-340": 956120, + "watchful-labrador-185": 956123, + "steady-firefly-395": 956125, + "confident-peccary-690": 956126, + "rapid-snake-71": 956127, + "calm-grasshopper-726": 956132, + "accomplished-starling-460": 956133, + "graceful-aardvark-33": 956140, + "enchanted-anaconda-863": 956141, + "giddy-dogfish-891": 956151, + "gregarious-chihuahua-822": 956152, + "famous-bird-477": 956167, + "wary-akita-240": 956168, + "decisive-dalmatian-340": 956169, + "astute-gnu-725": 956170, + "careful-salamander-742": 956172, + "resolute-goldfinch-453": 956173, + "fast-meerkat-943": 956197, + "kindred-malamute-732": 956198, + "colorful-ptarmigan-768": 956213, + "confident-jay-602": 956214, + "vivid-capybara-122": 956234, + "elegant-starling-992": 956235, + "stoic-gopher-803": 956238, + "keen-guineapig-486": 956239, + "groovy-rook-735": 956247, + "fearless-dragon-408": 956248, + "posh-porcupine-938": 956261, + "giddy-wren-7": 956263, + "rare-swan-51": 956317, + "nautical-cricket-396": 956318, + "whimsical-tiger-888": 956329, + "reminiscent-pika-159": 956330, + "clear-llama-423": 956330, + "artful-armadillo-685": 956362, + "grateful-frog-842": 956363, + "vivid-jackal-488": 956363, + "proper-fox-281": 956386, + "precise-fennec-113": 956387, + "ceaseless-malamute-343": 956388, + "original-badger-765": 956389, + "amiable-roadrunner-250": 956397, + "adventurous-goldfish-615": 956398, + "scintillating-cheetah-785": 956414, + "bold-magpie-159": 956415, + "shocking-kingfisher-231": 956420, + "original-ermine-192": 956424, + "enduring-grouse-254": 956425, + "fleet-emu-536": 956437, + "scrupulous-fish-479": 956438, + "resilient-cuttlefish-244": 956464, + "brave-ladybug-915": 956465, + "acoustic-manatee-611": 956478, + "colorless-llama-380": 956479, + "valiant-sardine-446": 956490, + "different-sturgeon-912": 956491, + "ceaseless-cheetah-788": 956511, + "unique-blackbird-375": 956511, + "proficient-boar-124": 956512, + "joyous-lobster-961": 956514, + "befitting-capybara-815": 956515, + "grand-owl-705": 956528, + "outgoing-schnauzer-284": 956529, + "doting-gnat-168": 956567, + "tame-raccoon-749": 956568, + "famous-anaconda-357": 956595, + "mild-moose-369": 956597, + "rugged-bullfrog-569": 956598, + "merry-pelican-346": 956600, + "hearty-greyhound-716": 956616, + "confident-akita-839": 956617, + "expert-blackbird-63": 956623, + "small-perch-758": 956639, + "fortunate-meadowlark-126": 956640, + "oceanic-cheetah-452": 956640, + "curious-wombat-94": 956652, + "oceanic-cormorant-763": 956653, + "adorable-horse-800": 956653, + "tremendous-aardvark-707": 956654, + "kindred-beagle-171": 956655, + "courteous-dove-728": 956708, + "striped-reindeer-961": 956709, + "greedy-tortoise-1": 956731, + "reminiscent-labrador-630": 956732, + "focused-ant-133": 956747, + "graceful-rook-598": 956748, + "zany-oriole-174": 956756, + "next-impala-685": 956757, + "formal-grouse-434": 956797, + "exciting-sardine-76": 956798, + "animated-seal-2": 956799, + "zealous-fennec-94": 956875, + "festive-hamster-196": 956917, + "silent-kiwi-185": 956918, + "colorless-hawk-336": 956930, + "flexible-lemur-220": 956931, + "greedy-weasel-336": 957018, + "secret-setter-441": 957019, + "artful-egret-487": 957065, + "oceanic-bear-827": 957066, + "amicable-leopard-463": 957115, + "combative-horse-605": 957116, + "quirky-fennec-448": 957120, + "first-donkey-138": 957121, + "impressive-hedgehog-24": 957174, + "tacit-guanaco-589": 957175, + "perceptive-toucan-224": 957214, + "sensible-sockeye-57": 957216, + "third-chicken-115": 957247, + "useful-lion-603": 957248, + "patient-mockingbird-289": 957253, + "quick-panther-886": 957254, + "outstanding-chihuahua-129": 957257, + "festive-akita-994": 957258, + "brave-pigeon-399": 957259, + "vivid-zebra-774": 957260, + "adorable-ermine-189": 957273, + "kindhearted-giraffe-472": 957274, + "successful-ox-533": 957279, + "cautious-rooster-454": 957282, + "energetic-lemming-257": 957288, + "grateful-snake-593": 957289, + "agile-bobcat-97": 957291, + "outstanding-hornet-879": 957292, + "peaceful-dinosaur-518": 957332, + "warmhearted-goldfish-451": 957333, + "dynamic-chinchilla-95": 957350, + "gallant-otter-869": 957352, + "clever-horse-139": 957379, + "grateful-boar-108": 957382, + "content-seal-150": 957383, + "frugal-squirrel-723": 957384, + "fine-ostrich-517": 957385, + "blissful-perch-471": 957387, + "courteous-quail-678": 957388, + "strong-porcupine-372": 957389, + "strong-cormorant-120": 957390, + "woozy-gnu-213": 957391, + "adamant-avocet-2": 957394, + "zany-guineapig-926": 957395, + "polite-retriever-934": 957399, + "third-goldfish-903": 957400, + "whimsical-manatee-507": 957401, + "proper-marten-379": 957402, + "aromatic-snake-42": 957405, + "insightful-corgi-378": 957406, + "flexible-cow-330": 957407, + "laudable-deer-114": 957408, + "shocking-otter-442": 957409, + "lovely-koala-182": 957410, + "gregarious-capybara-378": 957411, + "peaceful-finch-752": 957413, + "useful-cod-498": 957466, + "accomplished-meadowlark-374": 957467, + "nautical-kangaroo-647": 957491, + "handsome-magpie-20": 957492, + "aromatic-rabbit-479": 957493, + "keen-condor-48": 957502, + "modest-gnu-174": 957503, + "curious-anteater-571": 957530, + "acoustic-elephant-144": 957531, + "decisive-rhinoceros-129": 957531, + "disciplined-tiger-28": 957568, + "kindly-clam-480": 957569, + "colorless-snake-267": 957571, + "fabulous-donkey-839": 957572, + "quirky-goose-827": 957572, + "wonderful-warthog-346": 957575, + "tangible-alpaca-47": 957576, + "cheery-chinchilla-535": 957585, + "brazen-warbler-626": 957586, + "zealous-stoat-94": 957589, + "fleet-stingray-710": 957590, + "cheerful-cow-705": 957591, + "hearty-shepherd-188": 957592, + "spotted-hummingbird-811": 957609, + "formal-cricket-560": 957610, + "laudable-caiman-383": 957610, + "knowing-crab-580": 957664, + "quick-pheasant-365": 957665, + "diligent-gnu-253": 957665, + "dependable-alpaca-907": 957673, + "rosy-jackal-394": 957675, + "neighborly-flamingo-758": 957683, + "tame-labrador-45": 957684, + "tidy-porpoise-660": 957712, + "gregarious-hamster-26": 957713, + "precise-gnu-875": 957713, + "benevolent-egret-26": 957721, + "optimistic-egret-306": 957726, + "aromatic-cardinal-837": 957727, + "judicious-hummingbird-408": 957727, + "avid-bird-618": 957795, + "wonderful-gnu-929": 957796, + "animated-gecko-243": 957798, + "flexible-minnow-147": 957804, + "qualified-chinchilla-834": 957805, + "precise-salmon-66": 957805, + "adjoining-narwhal-694": 957807, + "confident-ostrich-992": 957808, + "cool-gnat-82": 957814, + "peaceful-ram-53": 957815, + "precious-cobra-712": 957818, + "dusty-goldfish-330": 957820, + "enduring-okapi-948": 957821, + "rosy-fennec-55": 957822, + "strong-iguana-21": 957824, + "next-guineapig-235": 957825, + "descriptive-egret-301": 957826, + "quixotic-trout-363": 957827, + "perceptive-mongoose-741": 957828, + "elated-cuttlefish-267": 957829, + "shiny-squid-20": 957830, + "cautious-sardine-769": 957831, + "precise-stingray-199": 957832, + "sensible-horse-588": 957833, + "cautious-peccary-430": 957835, + "resolute-chihuahua-388": 957836, + "dependable-dragon-651": 957838, + "precise-orca-682": 957839, + "handsome-weasel-9": 957842, + "abundant-ostrich-127": 957843, + "helpful-malamute-762": 957845, + "adventurous-mongoose-144": 957846, + "quixotic-sandpiper-703": 957849, + "grand-anteater-987": 957850, + "animated-donkey-678": 957851, + "earnest-ant-842": 957856, + "flexible-porcupine-495": 957857, + "superb-bird-402": 957874, + "original-oyster-390": 957875, + "harmless-caiman-619": 957888, + "trustworthy-gazelle-860": 957889, + "loyal-caribou-280": 957890, + "superb-trout-255": 957891, + "precious-antelope-700": 957893, + "warmhearted-gazelle-567": 957895, + "moonlit-mandrill-73": 957901, + "descriptive-tortoise-142": 957903, + "secret-sturgeon-897": 957904, + "modest-chickadee-412": 957908, + "cheery-possum-863": 957910, + "gregarious-parakeet-623": 957912, + "brainy-fox-691": 957913, + "kindhearted-avocet-883": 957920, + "decisive-platypus-409": 957928, + "grateful-cow-396": 957929, + "acoustic-warthog-529": 957944, + "blissful-sturgeon-944": 957950, + "diligent-ermine-135": 957951, + "determined-falcon-188": 957958, + "confident-clam-89": 957959, + "scintillating-fox-568": 957965, + "blessed-husky-650": 957966, + "glorious-kangaroo-837": 957987, + "third-pelican-42": 957990, + "effervescent-bass-707": 957997, + "opulent-fly-847": 957998, + "strong-terrier-388": 957999, + "intent-badger-606": 958000, + "quirky-guineapig-460": 958001, + "colorless-partridge-256": 958002, + "groovy-alligator-602": 958003, + "pleasant-tapir-105": 958004, + "exciting-kiwi-158": 958005, + "superb-lark-211": 958006, + "shiny-retriever-284": 958007, + "notable-reindeer-880": 958008, + "ideal-hedgehog-44": 958009, + "hallowed-alligator-667": 958010, + "standing-chinchilla-172": 958011, + "striped-mosquito-892": 958012, + "deafening-rook-70": 958013, + "famous-starling-621": 958014, + "reminiscent-chicken-447": 958015, + "first-labrador-139": 958016, + "quirky-panther-570": 958022, + "brilliant-mastiff-319": 958031, + "superb-puffin-758": 958044, + "silent-peccary-439": 958045, + "robust-basilisk-385": 958058, + "keen-falcon-969": 958059, + "acrobatic-setter-906": 958067, + "brazen-ibis-400": 958068, + "capable-snake-456": 958099, + "fleet-bandicoot-88": 958102, + "ideal-roadrunner-753": 958104, + "quaint-gnat-279": 958105, + "academic-shepherd-512": 958119, + "artful-turtle-608": 958122, + "prestigious-jackal-265": 958123, + "deafening-sturgeon-589": 958127, + "dutiful-viper-110": 958128, + "ceaseless-ibex-713": 958129, + "secret-crab-847": 958129, + "whimsical-alpaca-143": 958138, + "modest-giraffe-964": 958139, + "accomplished-lemming-276": 958141, + "artful-pheasant-850": 958142, + "third-dog-564": 958158, + "kindhearted-sparrow-382": 958159, + "effervescent-wombat-342": 958160, + "loyal-dalmatian-905": 958161, + "pleasant-cat-790": 958161, + "peaceful-grasshopper-651": 958162, + "hardy-malamute-108": 958163, + "glad-otter-895": 958166, + "giddy-porcupine-559": 958166, + "diligent-antelope-538": 958170, + "helpful-deer-15": 958171, + "quixotic-orca-473": 958172, + "vivid-octopus-511": 958175, + "harmless-heron-418": 958176, + "artful-okapi-602": 958196, + "quirky-lynx-48": 958197, + "posh-chinchilla-761": 958203, + "brazen-husky-963": 958210, + "accurate-mink-975": 958211, + "amicable-wren-382": 958265, + "quirky-stork-133": 958266, + "combative-minnow-605": 958270, + "scintillating-nightingale-431": 958271, + "quaint-bird-380": 958281, + "hallowed-mole-397": 958282, + "unique-cod-989": 958282, + "proper-mosquito-921": 958314, + "exuberant-alpaca-129": 958315, + "patient-dragon-331": 958436, + "agile-sturgeon-114": 958437, + "opulent-minnow-282": 958449, + "useful-robin-183": 958450, + "precious-alligator-482": 958462, + "giddy-hippopotamus-17": 958463, + "adept-wombat-475": 958483, + "zealous-rhinoceros-686": 958484, + "exciting-dolphin-511": 958506, + "reminiscent-axolotl-915": 958513, + "insightful-vole-578": 958515, + "useful-flamingo-689": 958609, + "ideal-cormorant-916": 958637, + "rightful-porcupine-186": 958638, + "hearty-canary-356": 958648, + "focused-lynx-710": 958649, + "dynamic-ferret-107": 958662, + "steady-vole-217": 958663, + "outgoing-hippopotamus-549": 958704, + "necessary-owl-60": 958705, + "lovable-cobra-389": 958713, + "dusty-pony-527": 958715, + "blessed-crocodile-164": 958724, + "cool-tiger-820": 958725, + "resolute-bird-195": 958749, + "notable-clam-622": 958750, + "agreeable-camel-931": 958767, + "tidy-clam-256": 958769, + "brave-marmot-94": 958783, + "intent-panda-693": 958784, + "pastel-chickadee-908": 958790, + "calculating-eel-484": 958793, + "reliable-okapi-104": 958795, + "cool-mammoth-165": 958796, + "flippant-mule-364": 958803, + "joyous-ant-201": 958804, + "nautical-monitor-534": 958806, + "amiable-dragon-465": 958806, + "wooden-antelope-787": 958819, + "chatty-orca-298": 958820, + "coordinated-jay-353": 958851, + "zany-buffalo-96": 958852, + "sensible-magpie-673": 958868, + "rosy-narwhal-414": 958869, + "reliable-eel-730": 958871, + "admired-hawk-351": 958873, + "coordinated-anteater-850": 958898, + "blessed-mallard-445": 958899, + "fastidious-greyhound-979": 958919, + "colorless-bulldog-382": 958920, + "curious-moose-453": 958941, + "perfect-hummingbird-115": 958942, + "merry-dog-220": 959018, + "terrific-alligator-682": 959080, + "graceful-sardine-354": 959081, + "tacit-kiwi-551": 959081, + "opulent-whale-560": 959090, + "impartial-marmot-301": 959091, + "blissful-bee-1": 959102, + "determined-hornet-892": 959119, + "merry-wolverine-334": 959120, + "cheery-chameleon-864": 959126, + "wandering-mouse-264": 959127, + "next-spider-633": 959128, + "hidden-pig-594": 959129, + "enchanted-lapwing-730": 959129, + "giddy-eagle-224": 959135, + "tangible-warthog-795": 959136, + "perceptive-goldfish-210": 959170, + "oceanic-bloodhound-318": 959172, + "fearless-crocodile-774": 959173, + "useful-swan-732": 959173, + "admired-giraffe-882": 959217, + "zany-sardine-21": 959219, + "modest-cow-662": 959219, + "sleek-hummingbird-116": 959233, + "beloved-blackbird-331": 959250, + "tangible-mole-615": 959266, + "rightful-cod-880": 959267, + "dapper-cod-277": 959272, + "fleet-puffin-753": 959273, + "opulent-mosquito-519": 959299, + "grateful-jaguar-7": 959300, + "zealous-oyster-173": 959300, + "unique-horse-300": 959353, + "proper-grasshopper-634": 959354, + "careful-puma-206": 959357, + "mild-wildcat-581": 959358, + "colorless-caiman-330": 959382, + "cool-lion-424": 959391, + "adamant-cheetah-236": 959392, + "animated-wildcat-750": 959415, + "quixotic-rook-46": 959416, + "hip-goldfish-700": 959436, + "hip-kookabura-924": 959437, + "dependable-pika-684": 959454, + "fine-curlew-176": 959454, + "watchful-fennec-622": 959485, + "doting-cassowary-274": 959487, + "bold-mastiff-509": 959505, + "clear-mongoose-38": 959506, + "wary-gull-223": 959657, + "curious-meadowlark-342": 959658, + "wonderful-pika-345": 959659, + "festive-lion-727": 959696, + "acoustic-viper-755": 959697, + "wandering-salmon-504": 959755, + "dutiful-horse-245": 959756, + "precise-owl-264": 959764, + "abundant-bandicoot-205": 959765, + "kindhearted-mallard-316": 959786, + "terrific-narwhal-446": 959787, + "unique-crow-28": 959800, + "perceptive-goat-644": 959801, + "deafening-goat-372": 959801, + "gallant-vole-528": 959873, + "blessed-emu-715": 959874, + "charming-clownfish-569": 959897, + "benevolent-gull-507": 959899, + "amiable-spaniel-16": 959918, + "quaint-capybara-257": 959953, + "fastidious-ladybug-191": 959954, + "jovial-camel-658": 959954, + "precious-panther-939": 959967, + "grandiose-kudu-960": 959968, + "benevolent-toucan-233": 960000, + "rare-crow-496": 960001, + "hardy-cricket-65": 960048, + "kindly-scorpion-762": 960049, + "basic-herring-198": 960049, + "warmhearted-turtle-601": 960055, + "proficient-stingray-42": 960056, + "striped-goshawk-381": 960104, + "efficient-ptarmigan-191": 960105, + "aromatic-woodpecker-379": 960138, + "blessed-rooster-237": 960139, + "honorable-rat-639": 960164, + "silent-schnauzer-632": 960165, + "courteous-oriole-204": 960195, + "sincere-mosquito-196": 960196, + "earnest-cardinal-368": 960208, + "sensible-reindeer-11": 960247, + "hallowed-zebra-591": 960248, + "combative-hamster-835": 960248, + "doting-okapi-565": 960289, + "precious-wolf-561": 960290, + "utmost-corgi-586": 960313, + "shiny-sheep-831": 960314, + "doting-basilisk-990": 960337, + "basic-aardvark-713": 960339, + "cheery-robin-518": 960355, + "clear-jaguar-122": 960356, + "handsome-porcupine-213": 960467, + "moonlit-pony-514": 960468, + "original-pig-77": 960564, + "brave-akita-197": 960565, + "uncommon-jackal-320": 960582, + "acrobatic-swordfish-902": 960584, + "handsome-marlin-341": 960585, + "reminiscent-gecko-311": 960618, + "sleek-tortoise-146": 960619, + "brazen-leopard-410": 960623, + "rare-poodle-170": 960625, + "uncommon-herring-537": 960626, + "whimsical-otter-514": 960628, + "sincere-lemming-786": 960629, + "polished-rat-371": 960630, + "sincere-hyena-701": 960631, + "confident-scorpion-0": 960634, + "keen-weasel-370": 960641, + "shiny-goose-208": 960642, + "loyal-starling-578": 960643, + "dynamic-marlin-150": 960644, + "polite-kangaroo-208": 960647, + "spotted-trout-454": 960648, + "laudable-spoonbill-614": 960652, + "majestic-dogfish-558": 960653, + "veracious-spider-928": 960655, + "third-giraffe-230": 960656, + "sensible-oyster-876": 960714, + "shocking-gopher-306": 960714, + "adamant-wombat-124": 960715, + "canny-salmon-260": 960716, + "upbeat-wombat-980": 960716, + "outgoing-hound-503": 960759, + "benevolent-mastiff-84": 960760, + "quick-bass-896": 960774, + "valuable-ox-727": 960775, + "amicable-rabbit-137": 960776, + "neighborly-gnat-540": 960819, + "rugged-shepherd-789": 960820, + "diligent-kudu-376": 960820, + "kindhearted-pelican-224": 960824, + "exciting-fly-690": 960825, + "chatty-stork-819": 960872, + "effervescent-vole-675": 960873, + "quick-porpoise-99": 960873, + "accurate-caribou-457": 960878, + "adamant-sheep-9": 960879, + "colorful-gecko-734": 960883, + "incredible-sturgeon-158": 960884, + "useful-ant-511": 960886, + "wandering-fly-704": 960887, + "rare-mule-880": 960964, + "small-opossum-203": 960966, + "befitting-raven-802": 960966, + "fearless-wolverine-340": 960967, + "rightful-chihuahua-409": 960984, + "adorable-lemur-138": 960985, + "wary-bear-72": 961001, + "curious-seal-424": 961002, + "proficient-spaniel-722": 961060, + "upbeat-sheep-447": 961061, + "vibrant-scorpion-634": 961061, + "wonderful-goldfish-141": 961073, + "agile-dinosaur-831": 961074, + "flippant-wildebeest-162": 961078, + "usable-jay-575": 961079, + "whimsical-kudu-760": 961112, + "third-meadowlark-920": 961113, + "charming-penguin-435": 961127, + "steady-stork-548": 961128, + "dependable-swordfish-642": 961135, + "insightful-platypus-223": 961136, + "joyous-ptarmigan-702": 961192, + "limitless-pheasant-445": 961193, + "little-echidna-232": 961196, + "giant-stingray-714": 961197, + "cheery-woodpecker-513": 961197, + "content-quail-102": 961212, + "honorable-warbler-262": 961215, + "vivid-parrot-625": 961215, + "woozy-mosquito-520": 961288, + "stoic-cheetah-838": 961289, + "effervescent-perch-516": 961301, + "gallant-echidna-542": 961302, + "fantastic-cat-798": 961303, + "zany-ox-45": 961304, + "beloved-lynx-723": 961305, + "accomplished-sardine-31": 961305, + "polite-kudu-57": 961306, + "next-yak-433": 961322, + "admired-jackal-233": 961323, + "enduring-bandicoot-609": 961325, + "ardent-perch-878": 961327, + "exciting-puma-355": 961331, + "sleek-magpie-996": 961332, + "abundant-aardvark-533": 961377, + "ideal-impala-494": 961377, + "avid-meerkat-996": 961489, + "doting-herring-679": 961558, + "upbeat-hawk-398": 961559, + "exciting-anaconda-292": 961560, + "incredible-buffalo-273": 961608, + "judicious-basilisk-505": 961609, + "limitless-mallard-930": 961663, + "confident-salamander-575": 961664, + "good-walrus-442": 961773, + "glad-rhinoceros-175": 961774, + "unique-puffin-714": 961778, + "doting-ermine-815": 961780, + "hidden-labrador-801": 961780, + "majestic-marlin-40": 961785, + "aware-chickadee-171": 961786, + "bright-caterpillar-216": 961805, + "affable-jay-584": 961806, + "marvelous-caterpillar-198": 961810, + "veracious-finch-648": 961811, + "vivid-hippopotamus-344": 961865, + "qualified-squid-602": 961866, + "ideal-frog-383": 961913, + "aware-fly-926": 961914, + "industrious-grouse-647": 961914, + "fast-chickadee-235": 961940, + "perceptive-hornet-769": 961940, + "warmhearted-kookabura-800": 961981, + "efficient-civet-175": 961992, + "courteous-hamster-321": 961993, + "judicious-panda-973": 961994, + "quaint-sturgeon-0": 961997, + "content-gazelle-787": 961999, + "fleet-oyster-468": 961999, + "compassionate-rhinoceros-997": 962025, + "grandiose-ferret-993": 962026, + "festive-pheasant-469": 962056, + "reminiscent-shark-920": 962058, + "ceaseless-turtle-128": 962058, + "laudable-basilisk-438": 962067, + "posh-squirrel-886": 962068, + "uncommon-deer-77": 962068, + "brazen-robin-16": 962138, + "superb-rat-938": 962139, + "tacit-starling-701": 962170, + "compassionate-chameleon-332": 962171, + "marvelous-oyster-602": 962171, + "quixotic-chameleon-19": 962193, + "beloved-pony-470": 962195, + "insightful-buzzard-357": 962322, + "rugged-gerbil-688": 962323, + "savory-zebra-764": 962347, + "tough-sardine-168": 962348, + "woozy-moose-231": 962358, + "third-hedgehog-450": 962359, + "outstanding-goldfinch-103": 962390, + "brainy-dolphin-395": 962392, + "trustworthy-woodpecker-33": 962394, + "laudable-newt-69": 962408, + "sincere-civet-200": 962409, + "colorless-civet-15": 962411, + "colorless-cricket-859": 962412, + "polite-panda-668": 962418, + "friendly-yak-921": 962419, + "tangible-pony-204": 962419, + "kindhearted-cobra-428": 962424, + "neat-turtle-943": 962424, + "marvelous-swordfish-607": 962496, + "woozy-retriever-109": 962497, + "shocking-sandpiper-272": 962497, + "modest-vole-861": 962541, + "giddy-ant-512": 962577, + "next-meadowlark-699": 962578, + "helpful-corgi-768": 962681, + "oceanic-opossum-401": 962682, + "quiet-elephant-208": 962726, + "fiery-hippopotamus-138": 962728, + "woozy-rhinoceros-619": 962728, + "tough-wombat-49": 962762, + "bold-caiman-186": 962763, + "gallant-wombat-31": 962765, + "impressive-goldfinch-301": 962766, + "wandering-buffalo-54": 962958, + "dusty-husky-273": 962959, + "animated-snake-603": 962960, + "sincere-caterpillar-537": 962961, + "befitting-buzzard-4": 962962, + "diligent-chipmunk-473": 962963, + "aware-perch-711": 962964, + "tremendous-marten-650": 962964, + "decisive-fly-186": 962965, + "rugged-chameleon-345": 962966, + "capable-hare-312": 962967, + "reliable-grouse-717": 962968, + "enchanted-jackal-475": 962969, + "precious-caribou-248": 962970, + "small-sockeye-48": 962971, + "fiery-bandicoot-475": 962972, + "energized-giraffe-640": 962973, + "limitless-echidna-540": 962974, + "fantastic-gerbil-845": 962975, + "avid-oriole-448": 962976, + "doting-poodle-111": 962977, + "adorable-dogfish-873": 962977, + "youthful-mastiff-472": 962978, + "coordinated-lynx-943": 962988, + "accomplished-mosquito-578": 962989, + "keen-caribou-538": 963098, + "befitting-bullfrog-878": 963099, + "brilliant-coyote-867": 963161, + "necessary-platypus-624": 963162, + "fine-lemur-679": 963162, + "kindred-koala-95": 963164, + "marvelous-alligator-627": 963165, + "spotted-seahorse-12": 963181, + "uncommon-lobster-35": 963183, + "acrobatic-kangaroo-808": 963192, + "diligent-vulture-618": 963193, + "next-deer-41": 963228, + "dashing-bloodhound-466": 963229, + "whimsical-panda-623": 963262, + "chatty-pika-456": 963263, + "proper-lion-76": 963366, + "quixotic-alpaca-15": 963375, + "rosy-monitor-564": 963471, + "savory-moose-879": 963472, + "acoustic-pika-865": 963503, + "gallant-dalmatian-916": 963504, + "efficient-chameleon-657": 963504, + "chatty-albatross-336": 963505, + "good-kiwi-705": 963561, + "beaming-tortoise-559": 963563, + "limitless-civet-27": 963563, + "neat-sockeye-924": 963570, + "dapper-dalmatian-551": 963571, + "terrific-aardvark-477": 963571, + "efficient-clownfish-989": 963598, + "coordinated-cuttlefish-611": 963599, + "youthful-mandrill-630": 963606, + "dynamic-mammoth-107": 963607, + "quiet-elk-953": 963607, + "clean-mongoose-353": 963622, + "vivid-wildebeest-255": 963625, + "exciting-fox-539": 963627, + "fleet-anaconda-159": 963630, + "enchanted-sandpiper-521": 963630, + "trustworthy-armadillo-373": 963634, + "blissful-grasshopper-313": 963635, + "joyous-stingray-218": 963635, + "posh-loris-756": 963646, + "clear-civet-3": 963649, + "amiable-frog-694": 963653, + "determined-ram-391": 963654, + "admired-meadowlark-741": 963664, + "tremendous-hornet-66": 963666, + "valiant-snake-616": 963668, + "adorable-horse-210": 963669, + "dusty-fly-216": 963691, + "next-bee-683": 963692, + "sensible-hamster-141": 963719, + "hip-cormorant-133": 963721, + "upbeat-dove-420": 963749, + "harmless-parakeet-465": 963750, + "original-bloodhound-327": 963807, + "cool-alpaca-717": 963808, + "giddy-manatee-425": 963939, + "youthful-raven-458": 963940, + "modest-gecko-389": 963976, + "robust-guanaco-877": 963977, + "tacit-mosquito-526": 963977, + "tame-swan-647": 964014, + "necessary-mammoth-969": 964015, + "cheerful-labrador-288": 964192, + "useful-cat-870": 964193, + "cool-armadillo-686": 964247, + "glad-toad-533": 964248, + "shiny-lemur-424": 964248, + "adventurous-newt-234": 964256, + "confident-mammoth-300": 964257, + "watchful-meerkat-18": 964257, + "sincere-bloodhound-498": 964261, + "ceaseless-herring-193": 964262, + "valuable-gull-368": 964262, + "quick-eagle-593": 964450, + "fast-roadrunner-140": 964451, + "prestigious-zebra-784": 964451, + "outgoing-oyster-679": 964486, + "valuable-setter-69": 964487, + "benevolent-echidna-22": 964527, + "terrific-cormorant-310": 964528, + "youthful-zebra-600": 964545, + "accurate-bass-495": 964546, + "utmost-ox-478": 964716, + "benevolent-hippopotamus-955": 964717, + "bold-sheep-408": 964719, + "uncommon-moose-449": 964720, + "charming-lemming-147": 964763, + "combative-salamander-819": 964825, + "hip-cricket-647": 964826, + "tangible-gull-225": 964826, + "brainy-corgi-415": 964856, + "utmost-oyster-390": 964889, + "befitting-fennec-69": 964890, + "hip-robin-960": 965012, + "precise-ferret-135": 965014, + "dapper-mole-419": 965178, + "fortunate-ermine-101": 965180, + "exciting-mallard-320": 965325, + "rare-raven-41": 965326, + "polite-impala-466": 965362, + "deafening-dove-14": 965363, + "greedy-hedgehog-327": 965387, + "disciplined-hawk-104": 965388, + "knowing-goose-599": 965400, + "combative-whale-847": 965401, + "precise-herring-455": 965411, + "graceful-dinosaur-405": 965412, + "incredible-guineapig-456": 965445, + "hardy-nightingale-301": 965447, + "friendly-alligator-528": 965511, + "expert-snake-53": 965512, + "courteous-horse-128": 965512, + "savory-dove-471": 965664, + "scrupulous-hawk-408": 965665, + "clever-alpaca-607": 965691, + "decisive-kangaroo-469": 965692, + "colorful-viper-117": 965708, + "quaint-opossum-252": 965709, + "marvelous-setter-367": 965747, + "valuable-boar-99": 965748, + "pastel-llama-270": 965799, + "doting-iguana-198": 965800, + "friendly-viper-16": 965861, + "veracious-pheasant-571": 965862, + "outstanding-fly-923": 965935, + "artful-jay-797": 965937, + "adorable-goldfinch-183": 965937, + "uncommon-pig-897": 966023, + "artful-mammoth-466": 966024, + "notable-partridge-143": 966024, + "decisive-anteater-773": 966136, + "determined-gull-932": 966137, + "bold-hound-569": 966176, + "doting-firefly-57": 966178, + "zany-pony-103": 966178, + "efficient-crow-710": 966192, + "sensible-ram-989": 966193, + "befitting-bear-993": 966197, + "grateful-okapi-954": 966198, + "reminiscent-gnat-525": 966207, + "effervescent-chinchilla-701": 966208, + "basic-weasel-728": 966208, + "notable-iguana-584": 966210, + "trustworthy-hyena-589": 966211, + "courteous-fly-155": 966211, + "glorious-pheasant-440": 966264, + "animated-husky-387": 966265, + "fiery-kookabura-643": 966265, + "harmless-bloodhound-754": 966270, + "neighborly-kingfisher-932": 966271, + "precious-crocodile-147": 966291, + "capable-guanaco-738": 966292, + "rare-dove-480": 966308, + "qualified-shrimp-500": 966309, + "rightful-camel-507": 966341, + "benevolent-koala-650": 966342, + "benevolent-vole-24": 966342, + "polite-mongoose-408": 966355, + "festive-ermine-352": 966356, + "nautical-swan-50": 966362, + "benevolent-malamute-916": 966363, + "friendly-antelope-50": 966363, + "careful-owl-280": 966368, + "brave-akita-0": 966369, + "insightful-tiger-655": 966369, + "steady-shark-56": 966406, + "reliable-wolverine-39": 966407, + "jovial-mouse-985": 966464, + "useful-mule-463": 966485, + "grateful-perch-676": 966486, + "descriptive-firefly-996": 966516, + "reliable-jay-66": 966517, + "youthful-jaguar-934": 966517, + "avid-trout-64": 966593, + "veracious-albatross-72": 966594, + "accurate-monitor-387": 966595, + "unique-tiger-905": 966596, + "original-lion-92": 966596, + "valiant-hawk-39": 966619, + "calculating-wildcat-588": 966620, + "chatty-warbler-980": 966627, + "cautious-chicken-213": 966628, + "handsome-crab-320": 966635, + "friendly-dolphin-997": 966636, + "resilient-parakeet-595": 966636, + "calm-snail-272": 966694, + "tangible-raccoon-907": 966695, + "blessed-pheasant-552": 966783, + "tremendous-alligator-350": 966785, + "capable-cricket-493": 966830, + "quixotic-llama-86": 966831, + "resolute-beagle-267": 966831, + "descriptive-leopard-474": 966850, + "proficient-hawk-7": 966851, + "disciplined-echidna-661": 966910, + "shiny-owl-139": 966990, + "quick-sturgeon-221": 966991, + "adorable-toucan-616": 966991, + "successful-marlin-898": 966995, + "sincere-hippopotamus-594": 966996, + "zealous-squid-915": 967011, + "kindred-robin-857": 967012, + "clean-chipmunk-331": 967020, + "ceaseless-gnat-291": 967022, + "glorious-wolf-793": 967062, + "hidden-wren-230": 967063, + "agreeable-cuttlefish-209": 967063, + "grandiose-manatee-472": 967100, + "courteous-ostrich-802": 967102, + "neat-sparrow-118": 967130, + "rare-cardinal-34": 967131, + "joyous-oyster-599": 967147, + "effervescent-camel-462": 967148, + "fabulous-grasshopper-106": 967148, + "incredible-chihuahua-602": 967240, + "posh-oyster-562": 967241, + "energetic-mandrill-545": 967273, + "cautious-cardinal-823": 967274, + "brave-bass-108": 967278, + "laudable-impala-927": 967279, + "useful-kookabura-130": 967287, + "adept-fly-331": 967288, + "youthful-iguana-555": 967381, + "giddy-sockeye-173": 967382, + "knowing-oriole-681": 967405, + "upbeat-chihuahua-490": 967407, + "graceful-lapwing-197": 967414, + "greedy-eagle-660": 967416, + "elated-cuttlefish-617": 967416, + "dependable-bee-460": 967515, + "sleek-cormorant-578": 967516, + "superb-mallard-113": 967516, + "uncommon-shrimp-308": 967543, + "vibrant-canary-399": 967544, + "uncommon-shepherd-512": 967548, + "merry-eel-389": 967549, + "glorious-camel-85": 967552, + "hip-chipmunk-269": 967553, + "valuable-raven-944": 967553, + "flexible-schnauzer-689": 967560, + "famous-mastiff-408": 967561, + "elated-kingfisher-464": 967592, + "formal-mastiff-833": 967595, + "agreeable-vole-57": 967595, + "prestigious-tortoise-975": 967604, + "shocking-rhinoceros-562": 967605, + "outstanding-bison-292": 967609, + "hallowed-canary-159": 967610, + "bright-gnu-640": 967618, + "pleasant-clownfish-976": 967619, + "affable-toucan-827": 967636, + "clear-firefly-505": 967637, + "hidden-panda-56": 967696, + "kindly-oyster-109": 967697, + "animated-goldfinch-567": 967720, + "handsome-okapi-526": 967720, + "shiny-lemur-440": 967768, + "optimistic-mammoth-232": 967769, + "whimsical-kudu-697": 967788, + "canny-pony-151": 967789, + "prestigious-magpie-766": 967790, + "beloved-puffin-453": 967792, + "rare-peacock-691": 967793, + "sensible-magpie-159": 967794, + "adamant-vole-377": 967863, + "hip-retriever-180": 967864, + "amiable-dragon-849": 968062, + "shiny-perch-642": 968063, + "hushed-hound-636": 968087, + "optimistic-lapwing-437": 968090, + "dutiful-curlew-841": 968140, + "different-eel-671": 968141, + "wandering-dove-648": 968157, + "determined-chipmunk-957": 968158, + "rightful-walrus-590": 968210, + "upbeat-albatross-142": 968211, + "kindly-porpoise-423": 968331, + "dazzling-koala-715": 968377, + "flippant-wolverine-486": 968378, + "agreeable-chickadee-606": 968386, + "earnest-caterpillar-666": 968387, + "affable-curlew-554": 968407, + "handsome-starling-730": 968408, + "notable-pheasant-206": 968420, + "vivid-mole-291": 968421, + "clean-zebra-993": 968428, + "striped-fly-900": 968429, + "kindly-iguana-118": 968429, + "tidy-lapwing-244": 968494, + "polished-panther-531": 968495, + "mellow-egret-550": 968546, + "quick-mink-909": 968547, + "steady-perch-108": 968547, + "kindly-ant-86": 968564, + "scintillating-puma-331": 968564, + "precise-ferret-777": 968566, + "fine-jellyfish-15": 968568, + "watchful-pig-400": 968573, + "hallowed-crow-944": 968574, + "marvelous-pelican-373": 968584, + "optimistic-opossum-600": 968585, + "marvelous-rhinoceros-597": 968635, + "brainy-crane-472": 968636, + "wary-cricket-111": 968640, + "dependable-stork-211": 968641, + "zealous-lemur-492": 968675, + "ardent-zebra-240": 968676, + "aware-parrot-514": 968717, + "little-tern-763": 968718, + "successful-mosquito-117": 968730, + "unique-cassowary-112": 968731, + "grand-wolverine-175": 968819, + "cheery-nightingale-743": 968820, + "blissful-akita-686": 968820, + "gregarious-narwhal-982": 968828, + "patient-salmon-495": 968828, + "diligent-lion-805": 968852, + "wooden-octopus-753": 968853, + "fantastic-oriole-123": 968857, + "vivid-narwhal-761": 968858, + "calculating-bandicoot-456": 968858, + "wandering-lynx-308": 968860, + "beloved-platypus-370": 968861, + "valuable-fish-498": 968966, + "oceanic-bobcat-369": 968967, + "wonderful-sheep-664": 968967, + "glorious-anaconda-78": 968980, + "vibrant-setter-330": 968981, + "perceptive-rabbit-386": 968994, + "graceful-starling-40": 968995, + "kindly-clownfish-281": 969028, + "content-goat-457": 969029, + "blessed-spider-852": 969057, + "limitless-rooster-331": 969058, + "lovable-canary-114": 969093, + "dashing-grouse-828": 969096, + "enchanted-blackbird-959": 969097, + "modest-retriever-444": 969106, + "fleet-hummingbird-346": 969107, + "peaceful-lion-167": 969131, + "colorless-emu-849": 969132, + "avid-spoonbill-689": 969135, + "glorious-mink-808": 969136, + "greedy-mockingbird-303": 969136, + "dusty-hedgehog-542": 969249, + "diligent-weasel-923": 969251, + "wonderful-snail-522": 969252, + "majestic-hawk-478": 969253, + "hip-toucan-109": 969284, + "optimistic-kiwi-862": 969285, + "canny-tiger-580": 969292, + "handsome-partridge-286": 969295, + "standing-crow-870": 969296, + "veracious-jay-948": 969314, + "secret-hummingbird-438": 969315, + "calculating-fennec-963": 969334, + "limitless-cardinal-319": 969335, + "opulent-sandpiper-841": 969359, + "fast-antelope-664": 969407, + "determined-ferret-879": 969409, + "qualified-hippopotamus-374": 969414, + "insightful-axolotl-415": 969415, + "insightful-platypus-445": 969437, + "diligent-sardine-479": 969438, + "optimistic-salmon-345": 969461, + "bright-manatee-617": 969465, + "effervescent-manatee-738": 969466, + "proficient-mandrill-408": 969496, + "graceful-peccary-832": 969497, + "valuable-caiman-199": 969522, + "uncommon-egret-82": 969523, + "hardy-shepherd-553": 969526, + "reminiscent-setter-359": 969529, + "incredible-buzzard-254": 969567, + "rugged-finch-438": 969568, + "dusty-akita-74": 969568, + "quick-bulldog-118": 969626, + "adjoining-sardine-848": 969627, + "moonlit-donkey-346": 969724, + "proficient-tiger-27": 969725, + "outgoing-basilisk-449": 969752, + "bold-anteater-821": 969753, + "amiable-salamander-108": 969778, + "amiable-gnu-548": 969779, + "robust-antelope-749": 969779, + "mellow-okapi-496": 969906, + "diligent-vulture-574": 969907, + "fine-salamander-548": 969929, + "optimistic-bison-2": 969931, + "clean-lynx-133": 969991, + "adjoining-ocelot-707": 969992, + "calculating-gnu-245": 970048, + "blessed-peacock-113": 970049, + "judicious-antelope-882": 970068, + "accurate-firefly-16": 970069, + "tough-grouse-376": 970094, + "resolute-shark-123": 970096, + "perceptive-mink-506": 970110, + "notable-mastiff-994": 970111, + "usable-dove-918": 970180, + "elegant-greyhound-625": 970181, + "admired-stoat-113": 970181, + "clean-pigeon-564": 970185, + "pastel-elk-329": 970186, + "scintillating-whale-973": 970199, + "grand-clownfish-77": 970200, + "brazen-goat-101": 970219, + "accurate-crab-827": 970220, + "patient-gerbil-58": 970230, + "hallowed-lemur-760": 970231, + "next-coyote-968": 970234, + "modest-spoonbill-791": 970235, + "artful-wildcat-226": 970254, + "oceanic-basilisk-313": 970255, + "polished-stoat-405": 970260, + "effervescent-possum-446": 970261, + "wry-oyster-125": 970326, + "terrific-ox-385": 970347, + "proficient-lark-948": 970349, + "amiable-bandicoot-442": 970349, + "tacit-gull-42": 970388, + "cool-gecko-272": 970389, + "acoustic-dogfish-590": 970389, + "woozy-zebra-487": 970413, + "bold-turtle-649": 970414, + "efficient-cassowary-693": 970436, + "calm-squid-783": 970437, + "agile-ostrich-124": 970456, + "avid-pony-969": 970457, + "knowing-porcupine-290": 970466, + "impressive-mallard-381": 970468, + "frugal-lemur-402": 970552, + "adventurous-mammoth-206": 970553, + "dependable-meadowlark-897": 970566, + "fast-goat-758": 970568, + "clear-flamingo-391": 970631, + "animated-seahorse-96": 970673, + "beloved-reindeer-93": 970674, + "kindred-beagle-426": 970741, + "grand-wolf-164": 970742, + "enchanted-cormorant-838": 970795, + "outgoing-mosquito-630": 970809, + "tacit-hyena-959": 970816, + "secret-hyena-27": 970817, + "dynamic-iguana-967": 970947, + "woozy-caterpillar-434": 970969, + "reminiscent-reindeer-259": 970970, + "polite-mongoose-574": 971062, + "hip-starling-305": 971063, + "limitless-sturgeon-287": 971113, + "artful-guineapig-427": 971139, + "secret-stoat-602": 971141, + "lovely-cuttlefish-21": 971262, + "fiery-gnat-214": 971263, + "tacit-mole-987": 971427, + "judicious-rat-217": 971427, + "shiny-albatross-808": 971431, + "disciplined-rabbit-224": 971432, + "enchanted-turtle-957": 971457, + "mellow-leopard-719": 971458, + "precious-salamander-186": 971458, + "next-pony-247": 971554, + "clever-dog-712": 971554, + "combative-schnauzer-947": 971554, + "stoic-trout-393": 971554, + "tough-hamster-49": 971618, + "effervescent-terrier-33": 971619, + "bold-gecko-22": 971624, + "energized-hamster-930": 971625, + "adjoining-marmot-969": 971645, + "confident-armadillo-226": 971646, + "enduring-wolverine-289": 971653, + "adjoining-albatross-189": 971654, + "glad-rat-49": 971683, + "bold-sparrow-323": 971683, + "watchful-frog-200": 971685, + "good-finch-365": 971714, + "limitless-bobcat-986": 971714, + "lovely-rooster-718": 971717, + "charming-peccary-342": 971719, + "kindhearted-puma-449": 971720, + "artful-duck-190": 971766, + "handsome-jackal-441": 971766, + "elegant-condor-451": 971776, + "adventurous-shepherd-194": 971778, + "rapid-deer-128": 971834, + "harmless-partridge-859": 971835, + "tangible-dolphin-441": 971878, + "woozy-fly-206": 971878, + "merry-leopard-193": 971923, + "agreeable-rhinoceros-761": 971925, + "accurate-lion-487": 971957, + "beloved-squid-513": 971959, + "fiery-kingfisher-353": 971985, + "nautical-goldfish-949": 971987, + "perceptive-gnu-519": 972004, + "deafening-camel-824": 972005, + "optimistic-manatee-967": 972028, + "shiny-tapir-298": 972029, + "greedy-shark-159": 972029, + "fearless-panther-326": 972037, + "upbeat-warbler-587": 972038, + "unique-dotterel-759": 972064, + "wary-barracuda-6": 972065, + "wry-dachshund-775": 972099, + "terrific-alligator-450": 972100, + "chatty-ram-533": 972121, + "confident-civet-644": 972122, + "jovial-hamster-147": 972136, + "watchful-fennec-66": 972137, + "careful-panther-946": 972138, + "doting-starfish-307": 972139, + "shocking-wren-748": 972151, + "colorless-ox-115": 972152, + "resilient-hummingbird-215": 972189, + "colorless-cardinal-555": 972193, + "grand-hound-999": 972193, + "acrobatic-albatross-710": 972211, + "agile-squirrel-594": 972212, + "kindred-cod-745": 972221, + "quixotic-mosquito-604": 972222, + "ceaseless-skunk-789": 972237, + "incredible-ox-605": 972238, + "dynamic-avocet-587": 972238, + "valuable-wildebeest-206": 972253, + "peaceful-clownfish-917": 972254, + "adamant-ermine-949": 972257, + "coordinated-minnow-983": 972258, + "abundant-quail-5": 972365, + "hardy-chipmunk-80": 972366, + "first-chinchilla-673": 972379, + "neat-perch-637": 972380, + "first-donkey-991": 972380, + "proficient-rabbit-5": 972389, + "standing-parakeet-461": 972390, + "diligent-okapi-564": 972465, + "brainy-bear-275": 972467, + "terrific-squid-609": 972490, + "dazzling-bear-544": 972491, + "ideal-mallard-822": 972523, + "pastel-lapwing-624": 972524, + "agreeable-axolotl-607": 972566, + "brave-alpaca-784": 972567, + "impressive-tortoise-720": 972590, + "quiet-octopus-408": 972591, + "first-snake-513": 972692, + "artful-blackbird-375": 972750, + "optimistic-manatee-309": 972751, + "greedy-firefly-91": 972751, + "fearless-mole-925": 972766, + "posh-hummingbird-935": 972767, + "exciting-octopus-240": 972777, + "valuable-deer-158": 972778, + "industrious-partridge-280": 972792, + "bright-seahorse-737": 972793, + "necessary-manatee-497": 972793, + "clean-rabbit-758": 972820, + "abundant-armadillo-435": 972821, + "precise-snail-119": 972822, + "decisive-goat-281": 972823, + "doting-jellyfish-688": 972829, + "friendly-meadowlark-914": 972830, + "giddy-dragon-691": 972833, + "perceptive-labrador-434": 972834, + "capable-setter-610": 972863, + "shocking-mallard-809": 972864, + "lovable-meerkat-856": 972870, + "outgoing-scorpion-698": 972871, + "reliable-bee-784": 972906, + "colorful-parakeet-557": 972907, + "calculating-badger-69": 972974, + "elated-wildebeest-50": 972975, + "glorious-marmot-952": 972977, + "successful-eagle-343": 972978, + "graceful-squid-404": 972984, + "marvelous-bear-293": 972985, + "mild-oyster-159": 973000, + "vibrant-lyrebird-683": 973001, + "bright-fox-121": 973001, + "successful-panda-547": 973044, + "avid-alpaca-139": 973046, + "colorless-fish-518": 973049, + "decisive-vulture-825": 973050, + "colorless-pika-273": 973057, + "tangible-weasel-906": 973058, + "glorious-wren-429": 973077, + "strong-ibex-572": 973078, + "festive-nightingale-789": 973090, + "third-shepherd-889": 973091, + "quaint-hummingbird-57": 973110, + "expert-hound-587": 973111, + "lovable-gull-543": 973135, + "stoic-lemming-994": 973136, + "blissful-deer-857": 973136, + "trustworthy-dragon-822": 973140, + "cheery-robin-779": 973141, + "beaming-gnat-692": 973143, + "vibrant-possum-337": 973144, + "perceptive-warbler-634": 973160, + "dapper-spoonbill-506": 973161, + "affable-zebra-888": 973186, + "patient-panther-622": 973187, + "resilient-vole-250": 973193, + "good-koala-296": 973194, + "fast-wombat-954": 973206, + "greedy-quail-866": 973207, + "brave-cardinal-843": 973217, + "lovely-schnauzer-360": 973218, + "little-rhinoceros-454": 973219, + "secret-butterfly-871": 973219, + "curious-sandpiper-393": 973220, + "optimistic-dinosaur-698": 973223, + "pleasant-bear-363": 973224, + "superb-rook-675": 973237, + "colorful-bee-216": 973239, + "compassionate-monitor-574": 973240, + "formal-marmot-541": 973241, + "incredible-pelican-202": 973241, + "doting-dog-986": 973249, + "festive-hamster-661": 973250, + "tame-crane-89": 973276, + "keen-horse-171": 973277, + "vivid-anaconda-905": 973277, + "joyous-salmon-711": 973290, + "proper-curlew-479": 973291, + "oceanic-alligator-31": 973309, + "scrupulous-ox-63": 973310, + "keen-gnat-291": 973342, + "dashing-sturgeon-148": 973343, + "tame-yak-571": 973343, + "handsome-lobster-512": 973346, + "optimistic-guanaco-694": 973347, + "canny-pika-116": 973363, + "bold-puma-327": 973364, + "pastel-stingray-92": 973365, + "posh-dachshund-830": 973366, + "canny-herring-929": 973377, + "compassionate-salamander-442": 973378, + "cheerful-avocet-132": 973414, + "flippant-goat-596": 973414, + "mellow-hippopotamus-706": 973426, + "perfect-magpie-890": 973427, + "different-herring-647": 973427, + "watchful-puma-174": 973428, + "affable-warthog-752": 973429, + "greedy-lapwing-181": 973436, + "polished-ptarmigan-65": 973437, + "outgoing-heron-884": 973461, + "fast-cobra-989": 973462, + "robust-heron-8": 973542, + "honorable-deer-753": 973543, + "strong-panther-635": 973543, + "superb-koala-438": 973545, + "elated-seal-354": 973547, + "fiery-squirrel-551": 973614, + "sleek-fly-580": 973615, + "helpful-octopus-972": 973616, + "disciplined-salamander-224": 973617, + "glorious-eagle-134": 973617, + "agile-malamute-10": 973668, + "outgoing-guanaco-960": 973671, + "ceaseless-swan-891": 973672, + "zealous-akita-828": 973672, + "scintillating-bloodhound-876": 973688, + "bold-moose-471": 973689, + "jovial-grouse-908": 973689, + "gallant-labrador-828": 973716, + "fortunate-fox-569": 973735, + "outstanding-falcon-503": 973739, + "honorable-perch-260": 973740, + "kindhearted-crab-929": 973775, + "scrupulous-hyena-293": 973777, + "glorious-cheetah-665": 973802, + "greedy-fish-161": 973803, + "avid-mastiff-396": 973803, + "fiery-bullfrog-682": 973822, + "courteous-snail-759": 973823, + "blissful-woodpecker-422": 973858, + "intent-eagle-638": 973861, + "peaceful-hawk-492": 973892, + "beaming-mallard-633": 973893, + "rare-puma-508": 973926, + "chatty-skunk-687": 973927, + "sensible-platypus-924": 973932, + "hushed-gnu-617": 973933, + "bright-alligator-163": 973937, + "precious-albatross-933": 973938, + "hip-vulture-175": 973953, + "honorable-partridge-219": 973954, + "acoustic-curlew-380": 973954, + "unique-grasshopper-564": 973959, + "aware-starfish-267": 973960, + "third-malamute-970": 973983, + "rapid-parakeet-803": 973984, + "exciting-cassowary-328": 973995, + "colorless-bullfrog-224": 974005, + "moonlit-lemur-37": 974005, + "intent-marlin-140": 974008, + "greedy-ant-676": 974009, + "sincere-sardine-47": 974028, + "animated-gull-505": 974029, + "striped-boar-612": 974037, + "accomplished-elephant-704": 974038, + "scintillating-ibis-239": 974045, + "knowing-tortoise-161": 974046, + "steady-frog-554": 974065, + "grandiose-tortoise-888": 974066, + "accomplished-husky-441": 974101, + "abundant-bass-258": 974103, + "scintillating-sardine-661": 974103, + "grandiose-pig-44": 974106, + "hardy-gull-829": 974107, + "pleasant-snail-18": 974109, + "hallowed-kookabura-909": 974110, + "pleasant-pig-779": 974117, + "careful-porcupine-392": 974119, + "superb-seal-224": 974119, + "adventurous-seahorse-960": 974122, + "fabulous-firefly-419": 974123, + "fine-bloodhound-491": 974130, + "agreeable-pigeon-527": 974131, + "zealous-porcupine-336": 974151, + "striped-schnauzer-136": 974152, + "notable-rhinoceros-687": 974154, + "diligent-reindeer-655": 974156, + "rapid-loris-120": 974156, + "dazzling-gull-483": 974186, + "energized-dove-481": 974187, + "careful-orca-545": 974194, + "calm-dogfish-803": 974195, + "wooden-badger-42": 974195, + "disciplined-sandpiper-259": 974209, + "patient-malamute-285": 974210, + "secret-beagle-785": 974232, + "clear-turtle-563": 974233, + "woozy-minnow-332": 974234, + "merry-beagle-219": 974235, + "unique-sockeye-557": 974302, + "diligent-salmon-634": 974304, + "acrobatic-basilisk-189": 974304, + "fiery-coyote-600": 974406, + "majestic-dinosaur-585": 974407, + "insightful-dolphin-484": 974418, + "honorable-lion-765": 974419, + "dutiful-schnauzer-528": 974419, + "impartial-mammoth-956": 974453, + "dapper-otter-559": 974454, + "hardy-basilisk-375": 974474, + "joyous-gecko-384": 974475, + "amicable-sandpiper-490": 974478, + "calm-koala-849": 974479, + "notable-cobra-998": 974479, + "disciplined-jay-308": 974497, + "outgoing-lion-722": 974499, + "giant-chinchilla-424": 974530, + "vivid-gull-918": 974531, + "clever-panther-270": 974532, + "necessary-falcon-276": 974533, + "adjoining-caterpillar-42": 974545, + "outgoing-mouse-870": 974547, + "rosy-clownfish-818": 974552, + "merry-labrador-482": 974553, + "hushed-scorpion-627": 974588, + "befitting-barracuda-940": 974590, + "artful-bear-594": 974590, + "acoustic-ostrich-588": 974595, + "patient-horse-377": 974596, + "necessary-canary-396": 974636, + "cool-ferret-192": 974637, + "efficient-narwhal-773": 974659, + "reminiscent-goshawk-416": 974660, + "unique-goldfinch-16": 974660, + "famous-labrador-782": 974673, + "groovy-salmon-285": 974674, + "rugged-antelope-450": 974680, + "posh-parakeet-419": 974681, + "bold-cod-877": 974775, + "formal-leopard-95": 974776, + "content-loris-197": 974781, + "quick-whale-230": 974782, + "honorable-falcon-198": 974782, + "silent-donkey-447": 974787, + "wonderful-ostrich-429": 974788, + "different-canary-895": 974788, + "brazen-seal-826": 974804, + "affable-eel-69": 974806, + "giant-buffalo-590": 974823, + "calm-puffin-716": 974824, + "useful-hamster-805": 974834, + "coordinated-tapir-662": 974836, + "modest-crane-487": 974836, + "compassionate-dotterel-212": 974854, + "mild-mule-315": 974856, + "peaceful-dogfish-667": 974884, + "deafening-guineapig-957": 974885, + "opulent-marlin-951": 974923, + "descriptive-civet-328": 974924, + "precious-goldfish-58": 974988, + "loyal-starfish-78": 974989, + "outstanding-ibex-747": 974989, + "uncommon-parakeet-329": 975010, + "good-mandrill-110": 975011, + "beaming-ram-93": 975036, + "harmless-beagle-351": 975037, + "standing-hummingbird-496": 975037, + "insightful-platypus-203": 975056, + "fine-cuttlefish-825": 975057, + "benevolent-penguin-140": 975089, + "precise-frog-366": 975091, + "lovely-panda-641": 975342, + "tangible-falcon-491": 975343, + "kindhearted-dalmatian-963": 975363, + "silent-jay-353": 975365, + "effervescent-mandrill-99": 975418, + "majestic-crab-736": 975419, + "scrupulous-trout-283": 975435, + "little-shrimp-679": 975436, + "grand-ram-313": 975531, + "rightful-scorpion-49": 975532, + "little-jellyfish-991": 975565, + "fleet-bass-348": 975566, + "polite-meerkat-821": 975592, + "lovely-goldfinch-802": 975594, + "tremendous-raccoon-616": 975605, + "clear-impala-762": 975606, + "careful-raccoon-977": 975669, + "unique-stork-703": 975671, + "insightful-walrus-369": 975681, + "hallowed-goldfish-962": 975683, + "polite-avocet-963": 975683, + "adjoining-aardvark-314": 975792, + "sensible-capybara-840": 975793, + "capable-kudu-417": 975793, + "pleasant-jellyfish-967": 975798, + "polite-woodpecker-850": 975799, + "expert-walrus-784": 975859, + "abundant-crane-769": 975860, + "cool-duck-881": 975927, + "useful-penguin-441": 975928, + "industrious-mouse-734": 975940, + "cautious-shrimp-948": 975941, + "charming-poodle-640": 975941, + "courteous-bass-415": 975957, + "famous-fly-52": 975958, + "useful-cheetah-319": 975992, + "mild-woodpecker-158": 975993, + "quick-herring-599": 976000, + "acoustic-fish-839": 976001, + "tremendous-octopus-581": 976038, + "ceaseless-husky-384": 976039, + "useful-parrot-348": 976135, + "combative-snail-846": 976136, + "polished-partridge-573": 976153, + "basic-moose-622": 976155, + "fine-deer-974": 976155, + "first-horse-540": 976292, + "hip-echidna-546": 976334, + "woozy-chinchilla-277": 976335, + "incredible-robin-599": 976335, + "keen-whale-37": 976360, + "scrupulous-porpoise-47": 976361, + "small-peacock-259": 976385, + "valiant-porcupine-177": 976386, + "impartial-rat-42": 976386, + "astute-gazelle-21": 976436, + "grandiose-gerbil-364": 976437, + "perfect-bird-525": 976447, + "quirky-spoonbill-872": 976448, + "nautical-ermine-772": 976470, + "dynamic-cod-467": 976471, + "pastel-ram-956": 976471, + "hidden-dinosaur-359": 976493, + "combative-meadowlark-435": 976494, + "blissful-salmon-352": 976494, + "acrobatic-rook-179": 976539, + "exciting-blackbird-498": 976540, + "wry-wolverine-752": 976540, + "adept-dachshund-328": 976584, + "gallant-owl-715": 976585, + "oceanic-antelope-476": 976585, + "perfect-echidna-810": 976603, + "fleet-kingfisher-980": 976604, + "hardy-shrimp-394": 976604, + "earnest-boar-634": 976608, + "shiny-guineapig-436": 976649, + "animated-wombat-486": 976650, + "bright-whale-371": 976663, + "necessary-crow-467": 976691, + "majestic-minnow-4": 976702, + "accurate-cassowary-432": 976703, + "mild-ibis-995": 976705, + "frugal-bat-48": 976706, + "tame-fish-613": 976724, + "proper-donkey-935": 976725, + "merry-grasshopper-424": 976725, + "amicable-orca-909": 976741, + "marvelous-chameleon-389": 976742, + "ardent-eagle-827": 976752, + "formal-weasel-66": 976753, + "animated-leopard-894": 976843, + "abundant-coyote-520": 976844, + "utmost-cobra-462": 976844, + "enchanted-possum-311": 976850, + "graceful-crab-825": 976851, + "doting-okapi-716": 976853, + "usable-salamander-560": 976854, + "wonderful-gull-256": 976871, + "shocking-peacock-57": 976872, + "prestigious-alligator-682": 976875, + "scrupulous-roadrunner-757": 976876, + "warmhearted-canary-498": 976876, + "blessed-boar-487": 976885, + "handsome-viper-525": 976886, + "energized-yak-502": 976886, + "calm-caribou-989": 976910, + "zealous-fennec-492": 976911, + "polite-partridge-204": 976911, + "enchanted-ox-321": 976912, + "blessed-corgi-940": 976929, + "spotted-pig-47": 976930, + "capable-civet-798": 976959, + "standing-goshawk-162": 976960, + "colorful-stingray-660": 977041, + "fearless-stingray-73": 977042, + "glorious-sardine-400": 977048, + "lovable-okapi-553": 977049, + "rugged-stork-562": 977094, + "determined-beagle-192": 977095, + "mild-mouse-797": 977095, + "wonderful-elephant-851": 977117, + "utmost-rooster-673": 977118, + "zealous-marmot-965": 977165, + "hearty-buffalo-882": 977166, + "capable-sparrow-426": 977166, + "confident-woodpecker-434": 977180, + "careful-starfish-868": 977182, + "peaceful-quail-407": 977233, + "frugal-pheasant-448": 977234, + "descriptive-mandrill-598": 977246, + "dapper-lyrebird-983": 977247, + "dependable-wildebeest-620": 977279, + "dapper-kingfisher-141": 977280, + "little-hippopotamus-166": 977288, + "focused-setter-852": 977289, + "marvelous-panda-148": 977314, + "charming-antelope-307": 977315, + "tremendous-duck-932": 977339, + "rapid-dolphin-704": 977340, + "academic-tern-998": 977389, + "zany-raccoon-924": 977390, + "hushed-cat-737": 977404, + "adjoining-newt-266": 977405, + "colorful-jaguar-365": 977428, + "ceaseless-wildcat-873": 977429, + "striped-peccary-421": 977473, + "calculating-axolotl-111": 977474, + "precious-corgi-400": 977477, + "successful-squirrel-983": 977478, + "rugged-gnu-136": 977490, + "warmhearted-spider-290": 977491, + "silent-malamute-894": 977491, + "qualified-goldfinch-35": 977498, + "precise-goldfish-185": 977499, + "zany-labrador-835": 977510, + "resilient-hummingbird-472": 977512, + "oceanic-robin-479": 977521, + "neat-robin-865": 977522, + "hardy-goat-657": 977538, + "usable-butterfly-4": 977540, + "hardy-greyhound-484": 977551, + "dusty-lemming-60": 977552, + "good-herring-63": 977560, + "coordinated-husky-810": 977561, + "perceptive-anaconda-743": 977561, + "vivid-porpoise-520": 977566, + "flippant-wildebeest-179": 977567, + "curious-crane-256": 977573, + "knowing-pheasant-9": 977574, + "calculating-snail-417": 977582, + "striped-moose-630": 977583, + "proficient-nightingale-561": 977585, + "frugal-deer-885": 977587, + "compassionate-perch-487": 977605, + "precise-mockingbird-107": 977606, + "uncommon-clownfish-510": 977609, + "courteous-grasshopper-461": 977610, + "wry-chickadee-346": 977611, + "incredible-bloodhound-511": 977612, + "outgoing-gull-823": 977628, + "cautious-mammoth-750": 977629, + "next-lion-402": 977638, + "brilliant-pony-166": 977639, + "wooden-sardine-966": 977643, + "impartial-firefly-132": 977644, + "shiny-dachshund-18": 977645, + "first-turtle-3": 977646, + "sincere-gerbil-730": 977649, + "incredible-pheasant-46": 977650, + "keen-walrus-614": 977653, + "small-trout-132": 977656, + "grand-viper-987": 977664, + "proper-stoat-837": 977666, + "blissful-kiwi-715": 977673, + "fine-manatee-905": 977674, + "nautical-emu-376": 977682, + "amiable-wolf-508": 977683, + "resilient-firefly-268": 977690, + "fiery-parrot-335": 977692, + "brainy-sardine-70": 977696, + "beaming-schnauzer-915": 977697, + "diligent-barracuda-903": 977697, + "zealous-pheasant-333": 977698, + "chatty-starling-611": 977699, + "jovial-sardine-214": 977711, + "adept-pig-556": 977712, + "standing-dragon-685": 977721, + "quick-setter-732": 977722, + "flexible-grasshopper-286": 977728, + "reminiscent-pheasant-430": 977729, + "descriptive-robin-449": 977738, + "accurate-bobcat-312": 977739, + "uncommon-vole-849": 977741, + "handsome-walrus-311": 977742, + "earnest-guineapig-551": 977745, + "adjoining-hare-802": 977752, + "marvelous-marten-701": 977753, + "optimistic-ferret-83": 977766, + "good-ermine-308": 977767, + "greedy-minnow-409": 977773, + "judicious-bison-778": 977774, + "fleet-meadowlark-831": 977778, + "focused-blackbird-921": 977779, + "healthy-warbler-948": 977779, + "fastidious-bobcat-140": 977799, + "next-crow-771": 977800, + "calm-meerkat-281": 977805, + "dazzling-octopus-304": 977806, + "precise-snail-414": 977835, + "benevolent-giraffe-701": 977836, + "enduring-moose-653": 977849, + "mild-buzzard-203": 977850, + "rapid-pony-75": 977886, + "grand-labrador-418": 977887, + "modest-donkey-977": 977887, + "impressive-kookabura-325": 977919, + "limitless-shark-809": 977920, + "original-horse-386": 977920, + "effervescent-rat-756": 977928, + "dapper-tiger-484": 977934, + "focused-coyote-707": 977935, + "tremendous-warthog-508": 977947, + "beloved-mallard-88": 977948, + "quick-hummingbird-832": 977969, + "bright-toad-379": 977970, + "befitting-whale-591": 977990, + "decisive-ocelot-839": 977992, + "cheery-gnat-390": 978004, + "adventurous-gnu-415": 978005, + "clear-sturgeon-349": 978005, + "scintillating-capybara-305": 978055, + "earnest-albatross-756": 978056, + "tame-stork-939": 978101, + "neighborly-perch-871": 978102, + "merry-gazelle-313": 978169, + "basic-crocodile-387": 978170, + "hip-fly-418": 978171, + "optimistic-quail-586": 978173, + "blissful-butterfly-376": 978242, + "acrobatic-buzzard-138": 978243, + "pastel-wren-643": 978283, + "cool-dalmatian-734": 978284, + "successful-sockeye-603": 978317, + "agreeable-herring-597": 978318, + "modest-bloodhound-746": 978335, + "helpful-civet-632": 978336, + "abundant-gazelle-221": 978337, + "third-mammoth-396": 978338, + "hip-caterpillar-261": 978352, + "earnest-kangaroo-773": 978353, + "superb-mouse-882": 978357, + "strong-snail-630": 978360, + "giddy-cobra-367": 978376, + "deafening-lemur-289": 978377, + "resolute-marmot-735": 978401, + "ceaseless-robin-178": 978403, + "colorless-rhinoceros-28": 978403, + "tough-cardinal-924": 978412, + "blessed-lynx-719": 978413, + "mellow-pony-709": 978416, + "grand-crow-295": 978417, + "ceaseless-armadillo-415": 978417, + "trustworthy-retriever-665": 978441, + "majestic-bee-350": 978442, + "fastidious-bulldog-792": 978442, + "handsome-opossum-64": 978447, + "hallowed-lapwing-668": 978448, + "pastel-cow-906": 978449, + "qualified-echidna-754": 978450, + "grandiose-caterpillar-307": 978503, + "festive-possum-836": 978504, + "expert-ibis-270": 978554, + "cheerful-basilisk-281": 978566, + "quiet-chihuahua-817": 978567, + "compassionate-sheep-205": 978573, + "loyal-warthog-793": 978574, + "elegant-spoonbill-236": 978578, + "content-seahorse-918": 978579, + "energetic-fox-287": 978602, + "steady-partridge-971": 978603, + "sensible-bandicoot-92": 978650, + "limitless-cheetah-621": 978652, + "reliable-crane-467": 978652, + "calm-gopher-486": 978675, + "uncommon-crow-946": 978676, + "fortunate-cheetah-757": 978676, + "polite-otter-142": 978744, + "efficient-wombat-691": 978745, + "clever-pony-383": 978860, + "affable-elk-988": 978861, + "different-caribou-488": 978862, + "agile-emu-787": 978863, + "efficient-dalmatian-585": 978870, + "utmost-fish-237": 978871, + "confident-cat-540": 978875, + "precious-ermine-786": 978877, + "canny-goat-392": 978877, + "modest-schnauzer-316": 978920, + "unique-dogfish-284": 978921, + "loyal-mastiff-367": 978938, + "striped-lynx-759": 978939, + "proficient-dragon-997": 978945, + "useful-weasel-299": 978947, + "first-giraffe-937": 978984, + "majestic-salmon-119": 978985, + "grandiose-ladybug-910": 978985, + "outstanding-sandpiper-560": 979030, + "hardy-firefly-105": 979031, + "amiable-spaniel-192": 979049, + "whimsical-echidna-517": 979050, + "tacit-cobra-861": 979075, + "tremendous-hummingbird-571": 979076, + "impartial-ostrich-689": 979138, + "tame-pika-956": 979138, + "posh-kingfisher-634": 979194, + "zany-monitor-254": 979195, + "acoustic-curlew-852": 979225, + "rapid-wildcat-520": 979227, + "joyous-wolf-445": 979271, + "quixotic-lark-611": 979272, + "opulent-akita-641": 979356, + "intent-alpaca-794": 979357, + "fine-bass-726": 979431, + "posh-zebra-289": 979432, + "industrious-emu-513": 979432, + "jovial-penguin-863": 979478, + "cautious-minnow-130": 979479, + "efficient-partridge-44": 979479, + "trustworthy-gnat-758": 979516, + "honorable-deer-53": 979517, + "marvelous-flamingo-887": 979610, + "robust-bulldog-724": 979611, + "proper-ptarmigan-874": 979625, + "savory-alligator-355": 979626, + "avid-dove-184": 979638, + "adept-butterfly-967": 979639, + "small-newt-139": 979725, + "enchanted-rooster-214": 979726, + "quick-ermine-801": 979730, + "animated-bison-915": 979731, + "tangible-falcon-583": 979770, + "secret-husky-369": 979785, + "focused-oriole-392": 979786, + "kindhearted-lemur-508": 979939, + "trustworthy-canary-664": 979940, + "adventurous-aardvark-274": 979940, + "adorable-goat-480": 979974, + "diligent-akita-837": 979975, + "little-lyrebird-296": 979975, + "steady-nightingale-386": 980007, + "dynamic-capybara-800": 980008, + "astute-mink-201": 980008, + "moonlit-civet-446": 980013, + "steady-spider-170": 980014, + "patient-perch-137": 980014, + "rugged-aardvark-709": 980039, + "brainy-ox-946": 980040, + "harmless-camel-577": 980040, + "youthful-jay-438": 980085, + "efficient-cow-683": 980086, + "rapid-newt-694": 980149, + "focused-warbler-514": 980150, + "adept-echidna-994": 980194, + "amiable-corgi-427": 980195, + "deafening-mule-960": 980195, + "energized-chickadee-525": 980225, + "moonlit-echidna-176": 980226, + "reliable-egret-999": 980237, + "perceptive-sockeye-468": 980238, + "rosy-peacock-468": 980238, + "optimistic-elk-230": 980322, + "fantastic-retriever-122": 980323, + "notable-seahorse-813": 980342, + "judicious-perch-531": 980343, + "rare-mockingbird-65": 980346, + "terrific-toucan-923": 980347, + "loyal-goat-96": 980359, + "third-peacock-382": 980360, + "colorless-lyrebird-590": 980362, + "youthful-reindeer-871": 980363, + "little-seal-967": 980377, + "beaming-emu-996": 980378, + "grateful-setter-396": 980382, + "sincere-llama-209": 980383, + "benevolent-lion-52": 980438, + "adventurous-rhinoceros-95": 980439, + "fabulous-salmon-427": 980447, + "striped-kangaroo-284": 980448, + "insightful-jackal-708": 980454, + "next-tortoise-192": 980455, + "careful-lion-211": 980465, + "superb-rooster-250": 980466, + "pleasant-vole-663": 980477, + "giddy-peacock-332": 980478, + "charming-otter-214": 980480, + "curious-albatross-887": 980482, + "opulent-finch-608": 980482, + "sleek-hamster-812": 980492, + "shocking-ibex-375": 980493, + "bright-echidna-749": 980508, + "disciplined-falcon-235": 980509, + "famous-kookabura-874": 980525, + "notable-firefly-278": 980526, + "standing-ptarmigan-358": 980526, + "keen-cod-388": 980572, + "animated-shepherd-665": 980574, + "laudable-dog-770": 980584, + "affable-chicken-984": 980585, + "effervescent-sockeye-440": 980648, + "academic-lyrebird-598": 980650, + "uncommon-schnauzer-272": 980688, + "canny-mockingbird-312": 980689, + "hallowed-caterpillar-536": 980689, + "careful-falcon-77": 980721, + "adamant-starfish-78": 980722, + "third-robin-158": 980770, + "hip-octopus-96": 980771, + "knowing-caterpillar-688": 980810, + "vivid-cardinal-866": 980811, + "affable-cow-145": 980814, + "tidy-shark-28": 980815, + "cheerful-herring-298": 980843, + "expert-mouse-397": 980844, + "strong-pigeon-461": 980850, + "adamant-woodpecker-998": 980851, + "focused-fish-840": 980884, + "earnest-herring-789": 980885, + "peaceful-dinosaur-481": 980907, + "brilliant-ermine-77": 980908, + "opulent-puma-640": 980909, + "disciplined-ibex-496": 980910, + "elegant-marmot-675": 980916, + "little-gerbil-279": 980917, + "charming-moose-823": 980944, + "fearless-chihuahua-228": 980945, + "ceaseless-firefly-554": 980955, + "keen-oyster-154": 980956, + "patient-eagle-336": 980995, + "fabulous-pigeon-685": 980996, + "acoustic-crow-570": 980996, + "polished-lemur-634": 981000, + "necessary-hummingbird-707": 981001, + "necessary-dove-585": 981015, + "optimistic-scorpion-614": 981016, + "basic-fennec-712": 981027, + "adjoining-gerbil-379": 981028, + "fleet-beagle-418": 981031, + "kindhearted-mink-914": 981033, + "intent-mandrill-463": 981037, + "ardent-snail-328": 981038, + "rapid-salmon-87": 981074, + "grandiose-cricket-447": 981075, + "optimistic-peccary-737": 981123, + "lovable-mallard-719": 981125, + "optimistic-ocelot-690": 981130, + "careful-rabbit-425": 981131, + "zealous-penguin-509": 981157, + "curious-koala-2": 981159, + "dynamic-mule-540": 981159, + "upbeat-rabbit-422": 981176, + "festive-tortoise-256": 981177, + "quaint-hare-781": 981231, + "hushed-basilisk-148": 981232, + "third-dachshund-763": 981233, + "oceanic-chinchilla-810": 981234, + "valiant-squid-209": 981258, + "tidy-platypus-420": 981259, + "notable-ermine-291": 981282, + "affable-marmot-327": 981283, + "hidden-caterpillar-886": 981287, + "modest-octopus-335": 981288, + "grateful-albatross-529": 981328, + "quiet-rat-475": 981329, + "artful-wren-275": 981353, + "graceful-cormorant-54": 981354, + "grandiose-caiman-840": 981354, + "dutiful-fly-593": 981364, + "dazzling-echidna-83": 981365, + "loyal-bird-996": 981386, + "veracious-armadillo-763": 981429, + "honorable-hawk-690": 981431, + "lovable-mouse-729": 981438, + "helpful-peacock-837": 981439, + "perceptive-scorpion-298": 981451, + "secret-hamster-255": 981453, + "glorious-shrimp-338": 981453, + "cheery-ibex-583": 981465, + "hushed-elk-650": 981466, + "expert-crow-813": 981466, + "cheerful-coyote-668": 981478, + "cautious-bass-641": 981479, + "amiable-setter-23": 981479, + "neighborly-snail-12": 981537, + "adamant-crocodile-270": 981538, + "loyal-opossum-789": 981603, + "judicious-lyrebird-79": 981604, + "marvelous-dog-25": 981682, + "majestic-boar-116": 981683, + "brainy-caterpillar-890": 981744, + "kindly-poodle-882": 981745, + "modest-lynx-606": 981749, + "agile-albatross-829": 981751, + "limitless-mink-18": 981762, + "quaint-bison-751": 981764, + "first-goat-960": 981793, + "hallowed-tapir-621": 981795, + "qualified-marlin-707": 981797, + "brave-vulture-695": 981798, + "posh-ibis-283": 981798, + "diligent-hummingbird-347": 981805, + "quick-moose-959": 981807, + "spotted-octopus-12": 981873, + "laudable-boar-579": 981874, + "efficient-cardinal-513": 981916, + "sincere-rat-247": 981918, + "pastel-cow-649": 981975, + "superb-ocelot-172": 981976, + "intent-clam-81": 982021, + "tacit-viper-663": 982023, + "adorable-terrier-414": 982023, + "helpful-guineapig-610": 982036, + "acoustic-alpaca-676": 982037, + "calm-pig-31": 982044, + "striped-dog-926": 982083, + "calculating-leopard-722": 982084, + "perceptive-toucan-207": 982084, + "hip-walrus-151": 982104, + "uncommon-wombat-293": 982105, + "sensible-mouse-157": 982105, + "brainy-dalmatian-552": 982108, + "hidden-chicken-443": 982109, + "amiable-peacock-527": 982109, + "colorful-guineapig-941": 982150, + "beloved-ferret-296": 982186, + "woozy-ladybug-321": 982187, + "valuable-quail-202": 982271, + "affable-kangaroo-467": 982274, + "judicious-weasel-340": 982274, + "resolute-cassowary-830": 982291, + "perfect-nightingale-840": 982292, + "lovable-starfish-516": 982545, + "groovy-bison-560": 982546, + "majestic-quail-648": 982575, + "sincere-weasel-753": 982576, + "agile-gnu-52": 982576, + "proficient-chihuahua-769": 982580, + "exuberant-panda-427": 982581, + "brainy-hedgehog-838": 982599, + "fast-trout-874": 982600, + "festive-pig-749": 982671, + "watchful-snake-336": 982673, + "kindly-manatee-302": 982699, + "unique-crab-766": 982700, + "pleasant-cod-957": 982917, + "rosy-mouse-360": 982919, + "handsome-robin-341": 982935, + "laudable-jaguar-578": 982936, + "blessed-whale-600": 983033, + "steady-partridge-242": 983044, + "dusty-buzzard-441": 983046, + "rapid-antelope-736": 983092, + "veracious-lark-345": 983093, + "colorless-cobra-964": 983102, + "clean-ibex-710": 983103, + "sleek-lyrebird-862": 983152, + "tame-chinchilla-139": 983153, + "oceanic-cheetah-41": 983195, + "formal-ibis-363": 983196, + "precious-anteater-987": 983219, + "fiery-curlew-799": 983222, + "curious-lynx-132": 983223, + "affable-trout-49": 983224, + "valuable-bobcat-967": 983225, + "robust-lapwing-859": 983260, + "harmless-kingfisher-656": 983261, + "friendly-quail-836": 983262, + "woozy-bird-328": 983263, + "befitting-otter-339": 983273, + "youthful-ocelot-832": 983274, + "enduring-ostrich-302": 983306, + "notable-bird-680": 983307, + "reliable-civet-508": 983329, + "peaceful-lemur-279": 983330, + "aromatic-iguana-938": 983330, + "sincere-greyhound-875": 983361, + "patient-mallard-868": 983362, + "necessary-jay-922": 983376, + "adamant-salmon-476": 983378, + "confident-goshawk-762": 983406, + "small-starling-684": 983408, + "tough-hippopotamus-740": 983429, + "small-malamute-2": 983430, + "accomplished-snake-261": 983444, + "glorious-guineapig-732": 983445, + "enduring-koala-149": 983455, + "lovable-lynx-516": 983456, + "incredible-buffalo-457": 983505, + "woozy-mole-278": 983506, + "lovely-platypus-554": 983565, + "wry-marlin-703": 983566, + "calm-cow-988": 983599, + "clever-parrot-215": 983600, + "tidy-iguana-877": 983703, + "peaceful-peccary-445": 983704, + "useful-boar-93": 983713, + "striped-greyhound-802": 983714, + "intent-boar-540": 983717, + "fabulous-curlew-519": 983718, + "wooden-deer-959": 983723, + "outstanding-lobster-93": 983724, + "helpful-dinosaur-559": 983764, + "colorful-cricket-179": 983766, + "diligent-raven-332": 983808, + "spotted-wren-335": 983810, + "precious-wildcat-452": 983830, + "wary-herring-513": 983832, + "tangible-antelope-369": 983840, + "ideal-ostrich-978": 983841, + "quaint-hummingbird-159": 983850, + "giant-tortoise-158": 983852, + "small-bison-864": 983863, + "grandiose-malamute-427": 983864, + "quick-ocelot-317": 983908, + "spotted-cow-662": 983909, + "gregarious-spaniel-735": 983909, + "robust-peacock-240": 983923, + "capable-yak-616": 983925, + "acrobatic-gecko-754": 983940, + "neat-pony-524": 983941, + "little-goat-814": 983943, + "robust-ostrich-750": 983945, + "merry-toad-307": 983968, + "tangible-opossum-30": 983969, + "shiny-grouse-999": 984007, + "acoustic-firefly-767": 984008, + "ideal-aardvark-760": 984037, + "amiable-caterpillar-36": 984038, + "efficient-weasel-869": 984056, + "elated-pelican-794": 984057, + "colorless-dolphin-838": 984063, + "bright-donkey-170": 984065, + "chatty-poodle-308": 984073, + "dashing-seal-657": 984074, + "fabulous-narwhal-340": 984083, + "formal-loris-293": 984084, + "glad-crab-40": 984118, + "standing-hippopotamus-331": 984120, + "precious-elephant-934": 984152, + "calculating-mink-714": 984153, + "intent-egret-674": 984185, + "majestic-goshawk-540": 984186, + "efficient-jackal-446": 984229, + "striped-minnow-594": 984230, + "cautious-seahorse-637": 984230, + "tangible-panther-320": 984341, + "neighborly-husky-31": 984342, + "woozy-canary-338": 984395, + "enchanted-stingray-656": 984525, + "impartial-ladybug-711": 984526, + "hallowed-lion-487": 984526, + "knowing-whale-14": 984557, + "resilient-jellyfish-259": 984558, + "silent-chicken-372": 984647, + "neighborly-bee-559": 984649, + "charming-sockeye-696": 984828, + "small-pelican-829": 984829, + "proficient-avocet-797": 984928, + "groovy-duck-920": 984929, + "adventurous-echidna-20": 984929, + "posh-greyhound-692": 985059, + "laudable-panda-596": 985060, + "first-cobra-322": 985060, + "dutiful-warthog-704": 985184, + "judicious-spider-705": 985187, + "acoustic-bee-119": 985187, + "loyal-spoonbill-157": 985243, + "joyous-hornet-759": 985244, + "vivid-mosquito-220": 985244, + "friendly-octopus-828": 985272, + "lovely-boar-602": 985273, + "qualified-kangaroo-792": 985349, + "vivid-dolphin-83": 985351, + "vibrant-antelope-881": 985351, + "wary-hummingbird-548": 985379, + "watchful-albatross-264": 985380, + "silent-tortoise-706": 985437, + "tame-schnauzer-76": 985438, + "unique-butterfly-776": 985438, + "compassionate-llama-802": 985466, + "canny-dachshund-290": 985467, + "robust-snail-248": 985467, + "tidy-owl-865": 985500, + "confident-vulture-603": 985501, + "strong-bear-245": 985512, + "enchanted-fox-835": 985513, + "rapid-alligator-139": 985527, + "harmless-wolverine-237": 985528, + "majestic-retriever-972": 985561, + "standing-porcupine-925": 985562, + "optimistic-ox-810": 985645, + "impressive-mammoth-317": 985646, + "polished-butterfly-827": 985655, + "youthful-butterfly-123": 985656, + "dynamic-gnu-810": 985673, + "flexible-stingray-365": 985674, + "reliable-ferret-52": 985683, + "standing-crab-200": 985693, + "flippant-puffin-228": 985694, + "clever-akita-697": 985697, + "jovial-shepherd-178": 985698, + "dynamic-puffin-597": 985716, + "deafening-axolotl-23": 985717, + "merry-kiwi-517": 985717, + "healthy-civet-47": 985736, + "steady-blackbird-690": 985738, + "perceptive-clownfish-602": 985738, + "harmless-raven-186": 985741, + "fearless-mastiff-58": 985742, + "mild-cod-31": 985754, + "resilient-cormorant-936": 985756, + "kindhearted-greyhound-997": 985763, + "enchanted-stingray-611": 985764, + "coordinated-dolphin-577": 985786, + "dependable-basilisk-844": 985787, + "grateful-bison-549": 985834, + "dependable-lemur-299": 985835, + "energetic-lemming-554": 985837, + "effervescent-hornet-144": 985838, + "deafening-spider-939": 985858, + "tacit-camel-455": 985859, + "silent-wildebeest-486": 985886, + "greedy-mink-744": 985887, + "woozy-chameleon-320": 985893, + "strong-marlin-567": 985894, + "affable-mink-94": 985899, + "spotted-greyhound-318": 985900, + "reminiscent-greyhound-636": 985916, + "flippant-bison-159": 985917, + "wry-coyote-373": 985917, + "ceaseless-koala-298": 985921, + "focused-parrot-46": 985922, + "neighborly-gecko-79": 985927, + "compassionate-platypus-566": 985928, + "sensible-shepherd-559": 985933, + "mellow-goose-145": 985934, + "keen-snail-28": 985949, + "sincere-fox-572": 985950, + "nautical-kangaroo-481": 985965, + "kindhearted-gecko-129": 985966, + "woozy-parrot-923": 985966, + "perfect-shepherd-163": 985984, + "secret-bear-333": 985985, + "basic-curlew-106": 985989, + "striped-ptarmigan-509": 985990, + "rapid-elephant-308": 985990, + "hip-platypus-261": 985993, + "lovable-caterpillar-835": 985994, + "polite-lion-823": 986002, + "spotted-bear-976": 986002, + "aromatic-heron-164": 986022, + "groovy-pig-961": 986023, + "terrific-ladybug-34": 986031, + "shiny-curlew-147": 986032, + "joyous-ant-560": 986044, + "gregarious-setter-752": 986045, + "hidden-chihuahua-261": 986045, + "proper-raven-307": 986059, + "limitless-dotterel-216": 986060, + "avid-meadowlark-79": 986060, + "exuberant-bee-984": 986070, + "kindhearted-elk-624": 986071, + "flexible-walrus-704": 986083, + "scrupulous-blackbird-613": 986084, + "lovable-meerkat-745": 986084, + "loyal-squirrel-151": 986095, + "aromatic-ant-447": 986096, + "wooden-gull-54": 986122, + "dapper-heron-632": 986123, + "grateful-ibex-54": 986155, + "giddy-ferret-154": 986156, + "giddy-albatross-944": 986165, + "hardy-squid-838": 986167, + "proper-retriever-15": 986180, + "resilient-monitor-126": 986181, + "unique-buffalo-876": 986196, + "beaming-peccary-108": 986197, + "modest-pheasant-734": 986197, + "hardy-poodle-316": 986205, + "marvelous-lobster-837": 986206, + "prestigious-owl-244": 986209, + "diligent-cobra-107": 986210, + "diligent-oriole-642": 986227, + "greedy-giraffe-58": 986228, + "basic-ladybug-53": 986265, + "modest-grasshopper-455": 986266, + "valuable-goldfish-25": 986272, + "fabulous-avocet-798": 986273, + "utmost-peacock-727": 986284, + "energized-pig-315": 986285, + "helpful-squid-860": 986285, + "famous-gecko-192": 986319, + "hushed-cobra-479": 986320, + "combative-caribou-990": 986320, + "neat-peacock-114": 986325, + "fast-albatross-481": 986326, + "hallowed-marmot-512": 986346, + "veracious-gerbil-930": 986347, + "jovial-grasshopper-372": 986361, + "joyous-raccoon-794": 986362, + "savory-guanaco-308": 986365, + "beaming-lapwing-158": 986366, + "fastidious-jackal-94": 986374, + "wooden-alpaca-290": 986375, + "dapper-narwhal-508": 986382, + "enchanted-ram-913": 986383, + "shocking-bat-177": 986389, + "coordinated-penguin-500": 986390, + "quiet-sturgeon-855": 986405, + "content-giraffe-91": 986406, + "mild-bloodhound-695": 986419, + "healthy-clam-388": 986420, + "exciting-partridge-416": 986428, + "outgoing-starfish-967": 986429, + "adventurous-sheep-595": 986436, + "secret-sandpiper-59": 986437, + "exuberant-penguin-833": 986440, + "patient-caterpillar-121": 986441, + "kindly-dotterel-289": 986442, + "academic-hamster-366": 986443, + "careful-yak-550": 986456, + "elegant-dalmatian-991": 986457, + "calculating-pigeon-368": 986461, + "oceanic-partridge-763": 986462, + "adorable-peacock-764": 986477, + "loyal-cod-130": 986478, + "astute-hamster-549": 986486, + "tough-nightingale-860": 986487, + "glorious-horse-758": 986519, + "exciting-hound-893": 986520, + "compassionate-alpaca-232": 986521, + "precise-cat-125": 986523, + "tremendous-cat-16": 986529, + "elated-lemming-84": 986530, + "secret-mule-654": 986549, + "diligent-cow-614": 986550, + "quaint-hawk-296": 986556, + "limitless-chameleon-321": 986557, + "whimsical-clownfish-445": 986557, + "animated-dog-491": 986560, + "industrious-swordfish-63": 986561, + "wary-hippopotamus-580": 986564, + "fantastic-walrus-871": 986565, + "sincere-chinchilla-584": 986565, + "adjoining-lyrebird-233": 986589, + "merry-jackal-919": 986590, + "energized-husky-782": 986595, + "kindred-lemur-936": 986597, + "canny-trout-120": 986597, + "necessary-malamute-782": 986615, + "wary-lapwing-63": 986616, + "prestigious-starfish-612": 986624, + "brainy-puffin-117": 986625, + "acoustic-iguana-616": 986625, + "amicable-chinchilla-998": 986634, + "content-sparrow-189": 986635, + "giddy-lapwing-246": 986637, + "colorful-bass-722": 986638, + "first-platypus-726": 986642, + "robust-skunk-165": 986643, + "kindred-raven-56": 986644, + "secret-snake-313": 986645, + "agreeable-crane-842": 986667, + "glad-lobster-504": 986668, + "earnest-hedgehog-963": 986674, + "hushed-cuttlefish-110": 986675, + "impartial-kookabura-115": 986689, + "dazzling-bullfrog-440": 986690, + "superb-bison-641": 986694, + "original-scorpion-466": 986695, + "festive-panda-969": 986697, + "wandering-mastiff-369": 986698, + "kindly-poodle-550": 986701, + "elated-monitor-721": 986702, + "polite-vulture-934": 986703, + "watchful-manatee-140": 986705, + "energetic-gazelle-835": 986727, + "clean-dogfish-5": 986728, + "groovy-dove-476": 986729, + "outstanding-sockeye-798": 986730, + "different-civet-100": 986760, + "successful-ibex-706": 986761, + "tremendous-sandpiper-650": 986798, + "warmhearted-hawk-884": 986799, + "kindly-finch-79": 986809, + "gregarious-tapir-480": 986810, + "cheerful-flamingo-911": 986833, + "fastidious-buzzard-29": 986834, + "wandering-possum-827": 986924, + "determined-corgi-171": 986925, + "wary-lyrebird-45": 986934, + "tangible-wildcat-914": 986935, + "pastel-goldfish-215": 986941, + "accomplished-squirrel-502": 986942, + "warmhearted-fennec-910": 986947, + "fine-lemming-468": 986949, + "affable-ram-651": 986951, + "vibrant-stoat-889": 986952, + "majestic-fish-768": 986958, + "first-orca-209": 986959, + "acrobatic-lemming-230": 986963, + "vivid-lapwing-181": 986964, + "silent-buzzard-204": 986969, + "precious-mandrill-821": 986970, + "necessary-perch-913": 986972, + "insightful-woodpecker-810": 986973, + "kindred-kookabura-342": 987014, + "basic-lapwing-855": 987015, + "elated-dinosaur-380": 987018, + "terrific-jaguar-561": 987019, + "graceful-swordfish-721": 987023, + "efficient-salmon-823": 987024, + "gregarious-gazelle-131": 987026, + "outgoing-stingray-544": 987027, + "fine-rabbit-609": 987027, + "woozy-raccoon-289": 987030, + "knowing-lion-392": 987033, + "bold-dogfish-472": 987034, + "fabulous-sockeye-538": 987044, + "polished-jay-828": 987046, + "unique-canary-620": 987059, + "diligent-spaniel-726": 987061, + "decisive-lyrebird-186": 987063, + "fantastic-pelican-682": 987064, + "doting-pika-923": 987067, + "incredible-parakeet-784": 987068, + "modest-puma-379": 987078, + "dynamic-firefly-268": 987079, + "lovable-vole-709": 987085, + "tangible-woodpecker-75": 987086, + "modest-moose-975": 987093, + "diligent-lark-4": 987094, + "secret-stingray-336": 987112, + "sincere-rhinoceros-858": 987113, + "strong-tiger-736": 987124, + "compassionate-mink-163": 987125, + "capable-malamute-693": 987130, + "cool-dodo-996": 987131, + "industrious-kangaroo-744": 987139, + "fast-nightingale-526": 987140, + "small-emu-116": 987143, + "knowing-okapi-461": 987144, + "lovable-curlew-901": 987149, + "secret-mink-662": 987151, + "hushed-seahorse-245": 987159, + "robust-ocelot-65": 987160, + "flippant-ox-544": 987179, + "incredible-greyhound-428": 987180, + "fearless-ostrich-412": 987184, + "combative-reindeer-463": 987186, + "grateful-canary-846": 987191, + "focused-bat-352": 987192, + "scintillating-shrimp-66": 987200, + "insightful-greyhound-437": 987201, + "resolute-goldfinch-494": 987207, + "handsome-rook-508": 987208, + "acoustic-hedgehog-594": 987232, + "secret-chinchilla-935": 987233, + "tacit-spider-919": 987233, + "reminiscent-ladybug-768": 987234, + "wandering-chinchilla-410": 987235, + "intent-panther-808": 987245, + "clever-ostrich-868": 987246, + "fortunate-kookabura-915": 987252, + "handsome-jellyfish-604": 987253, + "mellow-rook-64": 987253, + "expert-camel-809": 987256, + "graceful-starfish-548": 987257, + "aromatic-gnat-428": 987260, + "tremendous-mole-219": 987261, + "next-porcupine-73": 987293, + "industrious-seahorse-249": 987294, + "tangible-seal-126": 987299, + "optimistic-butterfly-437": 987300, + "superb-dodo-528": 987308, + "grand-hippopotamus-578": 987309, + "first-civet-594": 987353, + "brainy-coyote-725": 987354, + "uncommon-antelope-921": 987359, + "compassionate-lobster-433": 987487, + "glorious-alpaca-757": 987488, + "upbeat-toad-811": 987489, + "diligent-falcon-482": 987490, + "enchanted-ox-492": 987491, + "warmhearted-cow-941": 987492, + "coordinated-spoonbill-696": 987493, + "giant-ladybug-735": 987494, + "focused-cardinal-36": 987495, + "fine-hawk-700": 987496, + "compassionate-lemur-736": 987497, + "animated-flamingo-868": 987497, + "tough-firefly-98": 987498, + "fabulous-perch-953": 987499, + "industrious-narwhal-480": 987500, + "brazen-condor-712": 987501, + "colorless-pelican-903": 987502, + "impressive-antelope-200": 987503, + "zealous-skunk-683": 987504, + "knowing-ptarmigan-84": 987504, + "valuable-platypus-272": 987505, + "merry-marlin-513": 987506, + "hardy-gull-750": 987507, + "wary-ladybug-228": 987508, + "festive-armadillo-155": 987512, + "effervescent-hornet-592": 987513, + "notable-pheasant-973": 987515, + "striped-labrador-907": 987516, + "charming-hare-547": 987540, + "first-curlew-664": 987541, + "spotted-herring-350": 987543, + "rugged-husky-45": 987544, + "tangible-stork-417": 987548, + "small-chicken-128": 987549, + "descriptive-barracuda-533": 987549, + "efficient-loris-643": 987559, + "combative-starling-242": 987560, + "coordinated-squirrel-80": 987568, + "amiable-dodo-401": 987569, + "calm-lyrebird-912": 987574, + "harmless-butterfly-680": 987605, + "affable-ibex-81": 987606, + "confident-viper-499": 987608, + "mellow-salamander-606": 987609, + "keen-jellyfish-689": 987613, + "honorable-kingfisher-801": 987614, + "rugged-ermine-367": 987637, + "insightful-axolotl-742": 987638, + "expert-mallard-281": 987661, + "outgoing-canary-213": 987661, + "polite-crab-260": 987663, + "handsome-bear-683": 987664, + "notable-guineapig-280": 987668, + "kindred-mastiff-758": 987669, + "sensible-shark-201": 987680, + "upbeat-marmot-730": 987682, + "dependable-bobcat-711": 987701, + "basic-sockeye-442": 987702, + "fast-lark-863": 987712, + "stoic-clam-726": 987713, + "fast-mockingbird-910": 987715, + "hushed-mandrill-211": 987716, + "rosy-parrot-411": 987719, + "courteous-meadowlark-205": 987720, + "mellow-gnu-382": 987721, + "tame-mole-579": 987722, + "cool-elephant-946": 987727, + "utmost-retriever-736": 987729, + "robust-cardinal-234": 987733, + "careful-pony-561": 987736, + "notable-octopus-855": 987737, + "stoic-reindeer-456": 987737, + "wonderful-fly-741": 987738, + "original-shepherd-533": 987739, + "strong-bear-530": 987832, + "basic-chinchilla-535": 987833, + "insightful-gecko-53": 987833, + "dynamic-fish-801": 987838, + "mellow-rhinoceros-52": 987839, + "mellow-mandrill-598": 987839, + "greedy-goldfish-435": 987865, + "perceptive-fish-352": 987866, + "spotted-kingfisher-373": 987886, + "good-dalmatian-843": 987887, + "grandiose-sparrow-885": 987907, + "vibrant-penguin-440": 987908, + "kindhearted-goose-477": 987912, + "different-cricket-245": 987913, + "joyous-finch-432": 987920, + "reminiscent-albatross-533": 987922, + "utmost-sturgeon-865": 987928, + "fabulous-dalmatian-832": 987932, + "tidy-lark-141": 987933, + "necessary-mallard-67": 987934, + "descriptive-setter-113": 987935, + "outstanding-goshawk-593": 987958, + "pastel-elk-727": 987959, + "admired-spider-976": 987960, + "woozy-barracuda-559": 987961, + "sleek-caiman-843": 988006, + "little-meerkat-773": 988008, + "merry-cat-472": 988008, + "adorable-terrier-45": 988025, + "glad-akita-129": 988026, + "scintillating-husky-595": 988030, + "admired-rat-295": 988031, + "agreeable-wildebeest-432": 988059, + "acoustic-newt-394": 988083, + "enduring-warthog-893": 988084, + "friendly-puffin-889": 988102, + "coordinated-chihuahua-100": 988103, + "striped-penguin-683": 988107, + "admired-gecko-673": 988108, + "precious-goldfish-694": 988108, + "flexible-dolphin-284": 988128, + "energetic-bee-187": 988129, + "shiny-camel-454": 988206, + "quixotic-snake-90": 988207, + "exuberant-cormorant-682": 988220, + "gallant-puffin-106": 988221, + "intent-emu-367": 988230, + "artful-wren-250": 988232, + "tangible-quail-699": 988245, + "formal-greyhound-771": 988246, + "tacit-dodo-669": 988295, + "pleasant-corgi-554": 988326, + "dazzling-bear-396": 988327, + "woozy-buzzard-302": 988327, + "famous-chickadee-131": 988351, + "basic-wildcat-530": 988352, + "diligent-trout-677": 988380, + "hearty-narwhal-290": 988381, + "gallant-chinchilla-14": 988477, + "ceaseless-dalmatian-947": 988478, + "dynamic-otter-455": 988513, + "determined-partridge-660": 988515, + "rare-jaguar-834": 988518, + "laudable-yak-407": 988519, + "grand-puma-785": 988524, + "amicable-mouse-22": 988525, + "aromatic-pony-327": 988557, + "artful-fish-922": 988558, + "brazen-hyena-311": 988586, + "tremendous-hamster-515": 988587, + "kindly-ox-642": 988598, + "brilliant-parrot-560": 988598, + "dashing-guineapig-328": 988617, + "expert-dodo-169": 988618, + "earnest-bullfrog-382": 988623, + "benevolent-dolphin-395": 988624, + "keen-cow-396": 988635, + "unique-finch-447": 988636, + "adamant-beagle-261": 988661, + "mellow-lemming-30": 988662, + "glorious-wombat-751": 988662, + "artful-duck-908": 988675, + "stoic-falcon-658": 988676, + "little-dalmatian-809": 988726, + "amicable-antelope-664": 988727, + "formal-lapwing-138": 988752, + "loyal-chipmunk-522": 988754, + "loyal-stork-923": 988756, + "astute-dogfish-747": 988757, + "wary-quail-241": 988757, + "gallant-mallard-701": 988786, + "tough-ibex-812": 988787, + "groovy-seahorse-395": 988810, + "next-bloodhound-874": 988811, + "dynamic-dalmatian-664": 988829, + "limitless-swan-974": 988830, + "calculating-axolotl-938": 988875, + "focused-cormorant-411": 988876, + "academic-elephant-421": 988876, + "merry-buffalo-609": 988887, + "jovial-eel-483": 988890, + "scintillating-kangaroo-405": 988907, + "majestic-clownfish-942": 988908, + "scintillating-badger-58": 988908, + "giant-cassowary-610": 988963, + "steady-jellyfish-739": 988964, + "rosy-chameleon-881": 988965, + "clear-okapi-672": 988965, + "accomplished-dove-4": 988966, + "earnest-gnat-633": 988999, + "neighborly-gerbil-324": 989000, + "combative-ostrich-4": 989001, + "amiable-possum-468": 989001, + "admired-firefly-11": 989002, + "dynamic-retriever-942": 989023, + "qualified-bee-277": 989024, + "admired-fish-947": 989040, + "joyous-loris-800": 989041, + "prestigious-ferret-319": 989064, + "prestigious-woodpecker-283": 989066, + "handsome-bison-378": 989084, + "wary-cardinal-818": 989085, + "vivid-hedgehog-53": 989104, + "oceanic-sparrow-803": 989105, + "rosy-frog-619": 989112, + "honorable-vole-35": 989113, + "calculating-firefly-473": 989113, + "effervescent-mockingbird-487": 989125, + "tough-opossum-367": 989126, + "ceaseless-toad-824": 989139, + "zany-cricket-621": 989140, + "shiny-ox-455": 989148, + "compassionate-kudu-368": 989149, + "brazen-rat-92": 989175, + "kindred-fox-257": 989176, + "glorious-jackal-130": 989179, + "adamant-dolphin-590": 989180, + "animated-gecko-343": 989184, + "resilient-manatee-376": 989185, + "oceanic-lynx-483": 989198, + "quiet-porpoise-835": 989199, + "frugal-hornet-779": 989211, + "academic-eagle-197": 989212, + "lovely-mockingbird-182": 989212, + "posh-cheetah-273": 989216, + "enchanted-goose-378": 989217, + "brazen-lobster-635": 989226, + "blessed-horse-321": 989227, + "giant-eagle-985": 989243, + "proper-echidna-171": 989244, + "fastidious-robin-444": 989247, + "peaceful-goldfish-725": 989248, + "energized-bullfrog-353": 989253, + "majestic-oyster-706": 989254, + "elated-oyster-620": 989265, + "pastel-civet-604": 989266, + "energetic-hound-372": 989267, + "fine-boar-986": 989268, + "curious-newt-681": 989273, + "spotted-mandrill-204": 989274, + "brilliant-reindeer-894": 989287, + "hearty-caribou-129": 989288, + "clean-elk-423": 989290, + "hearty-woodpecker-901": 989291, + "quiet-basilisk-76": 989295, + "optimistic-cricket-892": 989296, + "outstanding-tortoise-476": 989298, + "quick-wolf-644": 989299, + "adorable-cuttlefish-140": 989305, + "usable-ox-159": 989306, + "terrific-panther-242": 989306, + "loyal-dove-942": 989311, + "accurate-hare-212": 989313, + "quick-anaconda-747": 989321, + "outstanding-magpie-391": 989322, + "sincere-mandrill-702": 989343, + "wary-eel-200": 989346, + "hushed-turtle-233": 989347, + "keen-condor-970": 989348, + "outstanding-goldfinch-710": 989350, + "content-grouse-496": 989351, + "careful-moose-507": 989354, + "flippant-octopus-801": 989355, + "dependable-swordfish-353": 989355, + "successful-herring-152": 989358, + "tidy-minnow-943": 989359, + "nautical-meadowlark-938": 989361, + "aware-pika-749": 989362, + "superb-horse-322": 989363, + "brilliant-echidna-706": 989364, + "accurate-egret-750": 989370, + "fearless-lemming-376": 989371, + "adorable-spider-628": 989372, + "neat-puffin-626": 989373, + "polished-pony-852": 989374, + "aware-newt-828": 989378, + "superb-lobster-619": 989380, + "tangible-walrus-290": 989381, + "polished-pelican-124": 989383, + "shocking-manatee-918": 989384, + "zany-seal-361": 989393, + "kindhearted-wolverine-734": 989394, + "healthy-ant-175": 989407, + "majestic-dove-375": 989408, + "nautical-otter-559": 989412, + "superb-rook-844": 989413, + "marvelous-guineapig-781": 989417, + "rightful-whale-286": 989418, + "whimsical-lemming-905": 989420, + "giant-opossum-786": 989421, + "adjoining-gerbil-456": 989423, + "fabulous-mockingbird-184": 989424, + "amicable-warthog-118": 989427, + "befitting-frog-996": 989428, + "famous-jaguar-543": 989429, + "posh-jaguar-235": 989430, + "fastidious-hamster-415": 989431, + "limitless-crane-108": 989432, + "dazzling-grouse-196": 989442, + "confident-monitor-236": 989443, + "first-octopus-39": 989460, + "rapid-nightingale-967": 989461, + "quiet-yak-346": 989464, + "animated-caribou-116": 989465, + "kindred-mouse-943": 989466, + "necessary-kangaroo-607": 989467, + "fine-chicken-921": 989467, + "elegant-mouse-375": 989475, + "cautious-salmon-617": 989476, + "cautious-mastiff-993": 989502, + "amiable-retriever-688": 989504, + "artful-lemming-495": 989504, + "standing-meadowlark-894": 989525, + "earnest-porpoise-251": 989527, + "stoic-ox-340": 989528, + "moonlit-aardvark-609": 989549, + "valuable-swan-125": 989550, + "youthful-seahorse-474": 989570, + "usable-chameleon-274": 989571, + "affable-porcupine-298": 989604, + "combative-elk-293": 989605, + "accomplished-whale-237": 989605, + "joyous-herring-849": 989606, + "qualified-ladybug-97": 989608, + "greedy-puffin-158": 989616, + "colorless-oyster-738": 989617, + "avid-seal-110": 989627, + "curious-stoat-44": 989628, + "wary-parrot-718": 989632, + "cautious-lark-490": 989634, + "savory-pig-840": 989637, + "tame-rooster-231": 989638, + "ardent-capybara-407": 989678, + "scrupulous-trout-251": 989680, + "original-egret-307": 989719, + "benevolent-raccoon-481": 989720, + "disciplined-dolphin-940": 989743, + "charming-crow-622": 989744, + "kindred-hyena-146": 989749, + "famous-kookabura-643": 989750, + "loyal-emu-88": 989759, + "scrupulous-crab-822": 989760, + "reliable-puma-754": 989769, + "shiny-gnat-725": 989770, + "fortunate-eagle-195": 989779, + "robust-parrot-154": 989780, + "upbeat-clam-890": 989780, + "patient-raven-632": 989833, + "robust-leopard-480": 989834, + "blissful-cormorant-782": 989836, + "fast-dinosaur-110": 989837, + "standing-llama-155": 989837, + "festive-antelope-248": 989869, + "famous-barracuda-548": 989870, + "sincere-goat-92": 989875, + "posh-mammoth-90": 989876, + "reminiscent-bison-72": 989883, + "fabulous-whale-451": 989884, + "savory-grasshopper-229": 989889, + "necessary-skunk-446": 989890, + "superb-cow-641": 989890, + "ardent-emu-84": 989891, + "fortunate-wombat-254": 989892, + "healthy-nightingale-873": 989898, + "insightful-gnu-343": 989899, + "calm-goldfinch-547": 989901, + "glad-viper-499": 989902, + "diligent-quail-813": 989905, + "canny-elk-966": 989906, + "affable-cuttlefish-363": 989913, + "ardent-toucan-9": 989914, + "rightful-jaguar-327": 989954, + "ceaseless-chihuahua-571": 989955, + "scrupulous-emu-454": 989956, + "clear-lobster-770": 989957, + "amiable-boar-587": 989991, + "basic-swan-577": 989992, + "oceanic-partridge-229": 990031, + "beaming-loris-908": 990033, + "neighborly-dodo-993": 990036, + "curious-bulldog-597": 990037, + "elegant-leopard-943": 990070, + "benevolent-spoonbill-437": 990071, + "hidden-peacock-112": 990118, + "shiny-lynx-191": 990119, + "exciting-whale-187": 990122, + "optimistic-gecko-69": 990143, + "shiny-malamute-764": 990144, + "secret-gull-166": 990164, + "artful-sockeye-125": 990165, + "veracious-vulture-817": 990165, + "tacit-mink-665": 990180, + "festive-capybara-592": 990182, + "striped-dotterel-615": 990280, + "flexible-platypus-348": 990281, + "energized-echidna-472": 990282, + "qualified-porpoise-638": 990283, + "optimistic-deer-558": 990289, + "handsome-dragon-227": 990290, + "scrupulous-tiger-400": 990291, + "hallowed-lyrebird-56": 990292, + "rare-yak-994": 990319, + "modest-rooster-390": 990320, + "kindhearted-poodle-298": 990320, + "lovely-finch-243": 990338, + "secret-gecko-707": 990339, + "zany-toucan-464": 990351, + "tough-wolverine-652": 990352, + "clear-greyhound-586": 990352, + "steady-goose-106": 990367, + "fearless-corgi-950": 990368, + "rugged-dove-402": 990369, + "valiant-caterpillar-798": 990370, + "loyal-mandrill-23": 990375, + "zealous-jaguar-796": 990376, + "beaming-jay-560": 990405, + "hidden-curlew-323": 990406, + "joyous-basilisk-65": 990427, + "oceanic-snake-573": 990428, + "shocking-fennec-20": 990438, + "blissful-cormorant-432": 990440, + "zealous-swordfish-328": 990478, + "fiery-dinosaur-731": 990479, + "lovable-crocodile-947": 990488, + "gallant-sockeye-891": 990489, + "fabulous-civet-793": 990493, + "brilliant-aardvark-857": 990494, + "pleasant-perch-840": 990494, + "animated-kingfisher-148": 990531, + "calm-clam-770": 990532, + "graceful-crow-251": 990541, + "descriptive-koala-409": 990542, + "kindhearted-butterfly-970": 990554, + "spotted-gazelle-235": 990556, + "striped-albatross-930": 990562, + "trustworthy-hippopotamus-570": 990578, + "giant-herring-278": 990579, + "hallowed-stork-568": 990579, + "impressive-dinosaur-168": 990604, + "giddy-hamster-186": 990605, + "lovely-mockingbird-457": 990679, + "youthful-chicken-890": 990680, + "perfect-bulldog-507": 990691, + "usable-sockeye-6": 990692, + "capable-labrador-234": 990719, + "woozy-crow-184": 990720, + "compassionate-ferret-109": 990736, + "reliable-rhinoceros-133": 990737, + "wonderful-frog-915": 990748, + "majestic-hawk-180": 990749, + "dutiful-dog-65": 990772, + "rapid-ermine-414": 990773, + "perfect-snail-939": 990775, + "grand-ptarmigan-288": 990776, + "dependable-rook-594": 990829, + "proficient-oriole-213": 990830, + "cautious-squirrel-342": 990833, + "gallant-poodle-200": 990843, + "woozy-vulture-927": 990844, + "tangible-shark-980": 990909, + "befitting-seahorse-246": 990910, + "astute-hare-324": 990910, + "proficient-nightingale-620": 990924, + "opulent-meadowlark-62": 990925, + "different-axolotl-551": 990927, + "qualified-viper-564": 990929, + "utmost-seal-292": 990958, + "artful-hawk-948": 990959, + "little-chameleon-732": 990960, + "majestic-kangaroo-555": 990961, + "standing-platypus-726": 990985, + "formal-walrus-569": 990986, + "tame-impala-629": 991021, + "elated-clam-257": 991022, + "intent-newt-975": 991022, + "calculating-kudu-996": 991035, + "tidy-cow-629": 991036, + "industrious-goldfinch-923": 991057, + "sleek-wombat-302": 991058, + "affable-coyote-830": 991063, + "accomplished-camel-540": 991064, + "fantastic-cormorant-339": 991085, + "vibrant-ostrich-446": 991086, + "grand-reindeer-507": 991087, + "coordinated-swordfish-206": 991088, + "upbeat-meerkat-871": 991088, + "quaint-orca-68": 991089, + "veracious-fish-674": 991090, + "zealous-mongoose-370": 991106, + "successful-puffin-919": 991107, + "curious-spaniel-423": 991138, + "dutiful-greyhound-602": 991139, + "sincere-basilisk-974": 991148, + "utmost-retriever-539": 991149, + "amiable-firefly-929": 991161, + "peaceful-toucan-76": 991162, + "fast-guanaco-798": 991189, + "abundant-raccoon-166": 991190, + "rugged-kiwi-303": 991208, + "disciplined-duck-848": 991209, + "colorless-sheep-655": 991217, + "content-bear-57": 991218, + "majestic-porcupine-363": 991221, + "dapper-elk-149": 991222, + "neat-lion-765": 991323, + "precious-cod-218": 991324, + "elated-yak-641": 991347, + "savory-jay-60": 991348, + "veracious-heron-17": 991348, + "whimsical-goat-191": 991353, + "shiny-manatee-888": 991355, + "rosy-antelope-599": 991402, + "kindred-dinosaur-352": 991403, + "fortunate-squid-350": 991430, + "greedy-reindeer-169": 991431, + "hip-crab-211": 991494, + "honorable-snake-887": 991495, + "pastel-deer-680": 991507, + "blessed-civet-720": 991508, + "unique-crow-408": 991534, + "reminiscent-grouse-315": 991535, + "benevolent-beagle-508": 991565, + "coordinated-meadowlark-626": 991566, + "charming-guineapig-343": 991566, + "superb-emu-441": 991567, + "laudable-fox-307": 991568, + "kindhearted-perch-947": 991568, + "knowing-armadillo-981": 991609, + "hearty-mink-768": 991610, + "polished-pony-988": 991629, + "disciplined-warbler-951": 991630, + "valiant-mastiff-726": 991637, + "fast-cuttlefish-799": 991638, + "silent-capybara-72": 991672, + "formal-mongoose-439": 991673, + "flippant-labrador-379": 991720, + "courteous-salmon-934": 991721, + "incredible-schnauzer-652": 991737, + "tidy-lemming-753": 991738, + "tame-bee-280": 991738, + "curious-sardine-455": 991748, + "flexible-partridge-85": 991749, + "prestigious-raven-767": 991754, + "veracious-kudu-812": 991755, + "elegant-elk-386": 991755, + "courteous-cormorant-495": 991863, + "fine-lark-565": 991864, + "clever-perch-163": 991979, + "usable-poodle-288": 991999, + "decisive-cormorant-768": 991999, + "wry-chickadee-567": 992063, + "courteous-pheasant-561": 992064, + "amicable-tern-952": 992078, + "resolute-nightingale-619": 992079, + "cautious-greyhound-699": 992144, + "successful-dinosaur-488": 992145, + "amiable-cuttlefish-454": 992176, + "curious-oyster-696": 992177, + "tangible-greyhound-412": 992181, + "honorable-emu-180": 992182, + "cautious-cardinal-246": 992195, + "peaceful-quail-667": 992196, + "reliable-sparrow-280": 992235, + "tremendous-pig-794": 992236, + "groovy-spider-981": 992268, + "optimistic-schnauzer-216": 992269, + "flexible-bear-663": 992269, + "fantastic-emu-376": 992296, + "harmless-grasshopper-278": 992297, + "clever-narwhal-915": 992344, + "dashing-hornet-150": 992345, + "affable-dalmatian-923": 992349, + "prestigious-caterpillar-21": 992350, + "quiet-butterfly-880": 992376, + "intent-meerkat-881": 992377, + "aware-bobcat-924": 992377, + "scintillating-snail-372": 992380, + "colorless-gopher-794": 992381, + "good-walrus-279": 992399, + "quiet-cassowary-813": 992400, + "impressive-scorpion-729": 992402, + "ardent-fly-144": 992403, + "efficient-moose-918": 992403, + "greedy-pheasant-881": 992414, + "fleet-ladybug-29": 992416, + "fortunate-crow-754": 992416, + "oceanic-dachshund-29": 992466, + "clever-ostrich-434": 992467, + "wry-bobcat-723": 992479, + "grand-marten-461": 992480, + "third-mandrill-483": 992493, + "nautical-loris-603": 992494, + "proper-cormorant-761": 992507, + "uncommon-swan-165": 992508, + "precious-trout-355": 992510, + "coordinated-marlin-862": 992511, + "determined-ostrich-756": 992716, + "elegant-nightingale-853": 992717, + "dashing-viper-283": 992721, + "impressive-bison-468": 992722, + "groovy-butterfly-199": 992779, + "knowing-gnat-700": 992781, + "effervescent-swordfish-894": 992781, + "dapper-beagle-966": 992831, + "honorable-fox-352": 992832, + "small-starling-110": 992851, + "savory-bee-825": 992852, + "content-elephant-400": 992852, + "basic-raccoon-156": 992861, + "shiny-bear-503": 992862, + "posh-raccoon-761": 992871, + "chatty-bloodhound-655": 992872, + "ideal-ladybug-58": 992959, + "perceptive-cow-936": 992960, + "vibrant-puffin-466": 992964, + "impartial-basilisk-278": 992966, + "fabulous-moose-991": 992974, + "different-crow-127": 992975, + "proper-lark-182": 993013, + "lovely-toucan-896": 993014, + "loyal-cardinal-353": 993014, + "cheerful-gopher-963": 993031, + "keen-mongoose-915": 993032, + "vibrant-bird-578": 993032, + "avid-tortoise-284": 993038, + "hallowed-starfish-947": 993041, + "knowing-parrot-761": 993041, + "dynamic-gazelle-149": 993042, + "intent-toucan-984": 993043, + "giddy-perch-407": 993054, + "exciting-hummingbird-653": 993055, + "artful-corgi-400": 993055, + "grandiose-jaguar-341": 993060, + "beaming-setter-133": 993061, + "giant-hare-821": 993109, + "helpful-spoonbill-161": 993110, + "kindred-finch-774": 993110, + "savory-bear-968": 993142, + "posh-loris-457": 993143, + "silent-wolf-893": 993143, + "successful-swordfish-977": 993146, + "calm-caiman-786": 993147, + "coordinated-manatee-540": 993170, + "quixotic-shepherd-923": 993172, + "adventurous-orca-199": 993194, + "animated-donkey-297": 993195, + "enduring-nightingale-484": 993195, + "tangible-basilisk-384": 993211, + "hidden-dotterel-265": 993212, + "doting-ox-435": 993221, + "descriptive-narwhal-866": 993222, + "tremendous-tortoise-231": 993222, + "grand-axolotl-381": 993268, + "basic-crow-585": 993269, + "greedy-corgi-434": 993286, + "clever-goose-730": 993287, + "polite-snake-296": 993329, + "tangible-anteater-14": 993331, + "industrious-hound-585": 993371, + "incredible-platypus-42": 993372, + "descriptive-jellyfish-469": 993372, + "spotted-platypus-979": 993388, + "courteous-egret-70": 993389, + "elegant-goshawk-774": 993401, + "grandiose-alligator-847": 993402, + "quixotic-dogfish-49": 993456, + "affable-barracuda-957": 993458, + "loyal-lapwing-410": 993475, + "good-sturgeon-955": 993476, + "efficient-falcon-223": 993499, + "tremendous-falcon-376": 993500, + "zany-tortoise-761": 993510, + "robust-cat-810": 993511, + "nautical-cow-669": 993519, + "brave-gopher-775": 993520, + "moonlit-lion-142": 993520, + "amiable-leopard-940": 993521, + "wandering-tapir-446": 993522, + "dazzling-jackal-322": 993522, + "amiable-toucan-400": 993525, + "moonlit-gopher-143": 993526, + "strong-marten-362": 993535, + "impartial-otter-143": 993536, + "rugged-alligator-443": 993570, + "hip-avocet-604": 993571, + "vivid-anaconda-334": 993571, + "woozy-gopher-34": 993580, + "vivid-porcupine-699": 993581, + "groovy-spaniel-112": 993657, + "groovy-goshawk-549": 993659, + "brilliant-ferret-729": 993659, + "adept-salmon-553": 993673, + "prestigious-kudu-600": 993674, + "kindred-bulldog-580": 993687, + "famous-starling-271": 993688, + "precious-nightingale-881": 993716, + "unique-sheep-940": 993717, + "original-opossum-718": 993726, + "beaming-lobster-475": 993727, + "striped-dachshund-359": 993727, + "giddy-camel-253": 993728, + "merry-bullfrog-802": 993729, + "disciplined-kangaroo-266": 993754, + "giddy-viper-48": 993755, + "utmost-owl-198": 993853, + "loyal-sardine-535": 993855, + "quixotic-guineapig-36": 993858, + "combative-mongoose-816": 993860, + "deafening-seal-870": 993915, + "confident-oyster-357": 993916, + "optimistic-grasshopper-859": 993929, + "formal-jay-209": 993930, + "zealous-barracuda-109": 993959, + "dynamic-ram-999": 993960, + "fantastic-snail-616": 993960, + "posh-pony-660": 993992, + "good-chickadee-780": 993993, + "fleet-clam-4": 993993, + "brazen-goshawk-979": 994039, + "wooden-guanaco-789": 994040, + "brilliant-leopard-979": 994040, + "admired-cuttlefish-563": 994083, + "cheerful-husky-276": 994084, + "clear-jay-69": 994093, + "brave-newt-666": 994095, + "successful-llama-221": 994104, + "glad-magpie-987": 994105, + "warmhearted-basilisk-699": 994208, + "pleasant-meerkat-370": 994209, + "kindly-kudu-407": 994230, + "perceptive-nightingale-258": 994231, + "neat-partridge-668": 994231, + "dapper-dog-278": 994276, + "rare-squid-262": 994277, + "energized-sturgeon-541": 994286, + "silent-badger-64": 994287, + "standing-viper-153": 994372, + "prestigious-pig-811": 994373, + "proficient-vulture-934": 994403, + "capable-jaguar-767": 994405, + "neighborly-fish-663": 994482, + "quaint-anaconda-81": 994484, + "upbeat-fox-433": 994535, + "giddy-aardvark-249": 994695, + "glad-parrot-99": 994696, + "uncommon-otter-814": 994696, + "helpful-mosquito-433": 994708, + "tangible-duck-61": 994709, + "flexible-ibex-583": 994754, + "shocking-dragon-325": 994755, + "different-bison-501": 994755, + "expert-skunk-271": 994788, + "dusty-armadillo-692": 994807, + "agile-bison-385": 994808, + "pastel-stoat-730": 994861, + "opulent-goldfinch-25": 994862, + "energized-avocet-143": 994880, + "knowing-chicken-399": 994881, + "superb-lark-356": 994900, + "warmhearted-condor-808": 994919, + "optimistic-capybara-847": 994920, + "vibrant-cheetah-443": 994940, + "disciplined-mongoose-48": 994941, + "proficient-horse-502": 994941, + "knowing-bison-914": 995016, + "honorable-chickadee-219": 995017, + "resilient-beagle-176": 995017, + "tidy-jackal-325": 995056, + "optimistic-peccary-690": 995057, + "trustworthy-otter-252": 995074, + "merry-orca-557": 995075, + "animated-minnow-532": 995087, + "wooden-hare-270": 995088, + "unique-beagle-673": 995141, + "focused-minnow-394": 995142, + "careful-hippopotamus-663": 995207, + "acrobatic-gazelle-280": 995208, + "fiery-tern-958": 995255, + "jovial-frog-102": 995256, + "doting-skunk-923": 995269, + "standing-sheep-676": 995270, + "fearless-gopher-742": 995292, + "acoustic-coyote-25": 995293, + "energetic-roadrunner-415": 995294, + "animated-squirrel-979": 995295, + "intent-cricket-906": 995298, + "exuberant-mule-666": 995299, + "insightful-hyena-853": 995388, + "exciting-scorpion-463": 995389, + "sincere-bat-749": 995389, + "accomplished-jay-658": 995424, + "tough-chipmunk-252": 995426, + "academic-flamingo-118": 995426, + "steady-bulldog-438": 995428, + "different-corgi-642": 995429, + "effervescent-starling-76": 995443, + "acrobatic-rooster-419": 995444, + "clear-oyster-967": 995444, + "posh-ostrich-115": 995470, + "confident-perch-632": 995471, + "notable-dachshund-104": 995472, + "giant-mastiff-60": 995473, + "zany-meerkat-607": 995485, + "utmost-gopher-389": 995486, + "tangible-ox-212": 995498, + "dutiful-dodo-791": 995499, + "striped-wildcat-845": 995512, + "acrobatic-rooster-4": 995513, + "accurate-ibex-894": 995532, + "pleasant-bulldog-670": 995533, + "notable-elephant-426": 995533, + "earnest-sparrow-759": 995614, + "rugged-alligator-876": 995615, + "calm-wolf-362": 995622, + "optimistic-tapir-633": 995623, + "fine-raven-769": 995631, + "nautical-ibex-914": 995632, + "resolute-walrus-753": 995682, + "scrupulous-pelican-404": 995683, + "fastidious-shrimp-506": 995691, + "effervescent-chickadee-573": 995692, + "robust-lemur-310": 995692, + "pleasant-narwhal-257": 995698, + "limitless-impala-186": 995700, + "decisive-caribou-247": 995736, + "proper-llama-62": 995738, + "animated-grasshopper-641": 995738, + "beloved-dotterel-749": 995773, + "different-gopher-53": 995777, + "precise-koala-722": 995874, + "amiable-eel-889": 995877, + "bold-wombat-200": 995946, + "spotted-meerkat-631": 995947, + "courteous-toad-183": 995969, + "tremendous-hippopotamus-932": 995970, + "clever-sardine-451": 996109, + "zany-vole-168": 996109, + "limitless-finch-350": 996241, + "unique-firefly-214": 996241, + "modest-nightingale-391": 996288, + "impartial-gnu-853": 996290, + "admired-cobra-419": 996290, + "aware-trout-812": 996336, + "perceptive-porcupine-496": 996337, + "curious-koala-481": 996337, + "scrupulous-antelope-833": 996387, + "clever-canary-346": 996388, + "charming-clam-556": 996388, + "silent-marlin-500": 996398, + "dashing-mammoth-361": 996399, + "descriptive-possum-660": 996475, + "decisive-mammoth-989": 996476, + "careful-pig-598": 996476, + "proper-bullfrog-766": 996479, + "canny-bird-858": 996480, + "graceful-reindeer-118": 996480, + "adept-goldfish-106": 996488, + "robust-walrus-599": 996489, + "different-shepherd-543": 996511, + "marvelous-spaniel-892": 996512, + "quick-bear-377": 996535, + "kindred-parrot-949": 996536, + "lovely-coyote-664": 996548, + "acrobatic-bloodhound-775": 996549, + "valuable-swan-34": 996581, + "hallowed-grasshopper-286": 996582, + "fabulous-mockingbird-341": 996591, + "watchful-ladybug-860": 996592, + "fastidious-seahorse-686": 996627, + "tame-kangaroo-564": 996628, + "industrious-starling-141": 996631, + "nautical-shepherd-478": 996632, + "fastidious-mouse-6": 996633, + "cautious-mammoth-286": 996634, + "secret-cuttlefish-515": 996641, + "quaint-duck-674": 996642, + "brainy-bloodhound-817": 996643, + "energized-mallard-750": 996645, + "striped-sockeye-636": 996659, + "vibrant-lobster-925": 996661, + "diligent-owl-71": 996661, + "fabulous-ermine-961": 996662, + "courteous-akita-464": 996663, + "brilliant-mole-675": 996665, + "tough-flamingo-954": 996666, + "gallant-snake-606": 996666, + "brave-bison-49": 996702, + "majestic-scorpion-82": 996703, + "robust-wolverine-73": 996703, + "polished-grasshopper-94": 996706, + "hardy-chipmunk-769": 996706, + "cautious-hawk-203": 996708, + "knowing-condor-110": 996709, + "brainy-bird-752": 996714, + "small-dragon-283": 996715, + "useful-shark-10": 996721, + "amicable-puma-453": 996723, + "successful-boar-534": 996738, + "chatty-cow-437": 996739, + "careful-peccary-944": 996742, + "limitless-tapir-706": 996743, + "standing-donkey-929": 996763, + "dashing-caribou-891": 996764, + "small-stork-791": 996765, + "curious-chickadee-865": 996767, + "hardy-jellyfish-760": 996780, + "greedy-hedgehog-371": 996782, + "ceaseless-scorpion-609": 996792, + "fortunate-porpoise-859": 996793, + "handsome-leopard-111": 996795, + "hidden-seahorse-910": 996796, + "dashing-bison-535": 996819, + "mellow-buzzard-926": 996821, + "brazen-giraffe-33": 996821, + "opulent-newt-183": 996833, + "usable-dotterel-464": 996834, + "dynamic-boar-934": 996834, + "dazzling-meerkat-29": 996835, + "shocking-emu-531": 996836, + "dashing-ocelot-627": 996837, + "greedy-nightingale-982": 996839, + "moonlit-flamingo-891": 996841, + "polished-emu-313": 996846, + "fleet-sandpiper-759": 996873, + "wary-antelope-882": 996874, + "modest-swordfish-855": 996878, + "fine-viper-772": 996879, + "dazzling-salamander-639": 996879, + "different-corgi-465": 996885, + "jovial-flamingo-94": 996886, + "mellow-emu-379": 996955, + "astute-crow-122": 996956, + "scrupulous-anteater-874": 996958, + "superb-mockingbird-804": 996960, + "accurate-cow-118": 996960, + "benevolent-okapi-812": 996966, + "gregarious-schnauzer-780": 996968, + "gallant-butterfly-504": 996971, + "adamant-loris-231": 996994, + "silent-dinosaur-878": 996996, + "robust-narwhal-869": 996997, + "posh-schnauzer-826": 996997, + "amiable-hyena-144": 996998, + "posh-corgi-789": 997003, + "quixotic-badger-139": 997004, + "energetic-cobra-689": 997009, + "optimistic-anteater-382": 997062, + "friendly-reindeer-45": 997085, + "wary-bee-606": 997087, + "hushed-starling-48": 997093, + "usable-porpoise-204": 997093, + "astute-anteater-666": 997098, + "abundant-ptarmigan-915": 997100, + "tough-marmot-674": 997110, + "careful-finch-960": 997111, + "curious-wildcat-397": 997118, + "academic-shark-318": 997119, + "limitless-dinosaur-341": 997156, + "posh-labrador-881": 997158, + "perfect-husky-844": 997170, + "earnest-mallard-598": 997170, + "artful-snail-976": 997191, + "nautical-gerbil-951": 997193, + "perfect-hummingbird-54": 997203, + "affable-retriever-288": 997282, + "fantastic-dove-706": 997284, + "blessed-setter-129": 997327, + "polite-ostrich-654": 997331, + "lovable-cassowary-76": 997362, + "doting-grasshopper-979": 997364, + "elated-fish-498": 997372, + "quaint-ram-890": 997372, + "necessary-lapwing-431": 997373, + "industrious-chinchilla-32": 997376, + "exuberant-shark-155": 997391, + "tacit-turtle-488": 997391, + "befitting-snail-965": 997393, + "wry-orca-334": 997396, + "fortunate-pigeon-633": 997397, + "dashing-penguin-749": 997399, + "precious-kiwi-897": 997460, + "notable-mallard-123": 997466, + "colorful-spoonbill-88": 997478, + "brainy-spider-756": 997482, + "original-porcupine-881": 997496, + "moonlit-retriever-535": 997505, + "dusty-capybara-111": 997522, + "fantastic-caiman-913": 997533, + "utmost-wildebeest-287": 997544, + "third-mouse-21": 997568, + "dynamic-lion-660": 997582, + "sleek-hawk-731": 997583, + "tangible-kingfisher-10": 997584, + "grateful-shark-293": 997585, + "elegant-stoat-812": 997586, + "incredible-octopus-898": 997587, + "glorious-tiger-230": 997588, + "capable-chinchilla-953": 997589, + "modest-bison-3": 997590, + "vibrant-stork-174": 997591, + "different-gnat-953": 997592, + "brilliant-finch-59": 997594, + "kindred-capybara-823": 997595, + "famous-seahorse-667": 997596, + "third-elephant-497": 997599, + "opulent-fish-20": 997600, + "incredible-mink-426": 997601, + "honorable-eel-448": 997604, + "scintillating-wildcat-687": 997605, + "flippant-finch-580": 997606, + "elated-badger-992": 997608, + "zany-rat-268": 997609, + "famous-manatee-757": 997610, + "honorable-puffin-905": 997671, + "savory-tortoise-922": 997671, + "quiet-dogfish-332": 997729, + "energetic-hare-562": 997737, + "impressive-dotterel-946": 997807, + "pleasant-oriole-593": 997865, + "patient-leopard-635": 997870, + "honorable-starling-905": 997924, + "fleet-perch-335": 997924, + "enchanted-ibis-407": 998002, + "amiable-wildcat-155": 998006, + "valuable-perch-795": 998043, + "first-parakeet-379": 998051, + "charming-leopard-395": 998051, + "reminiscent-lemming-777": 998060, + "honorable-turtle-750": 998065, + "blissful-butterfly-90": 998103, + "pleasant-platypus-280": 998110, + "careful-fish-362": 998111, + "effervescent-mink-355": 998112, + "wry-hummingbird-786": 998115, + "knowing-poodle-364": 998163, + "accurate-otter-96": 998164, + "quiet-bulldog-612": 998211, + "neighborly-mammoth-504": 998219, + "compassionate-goshawk-160": 998220, + "impartial-goose-559": 998222, + "sensible-cormorant-523": 998223, + "avid-fennec-119": 998228, + "opulent-bird-182": 998239, + "beloved-ibex-858": 998241, + "healthy-retriever-567": 998242, + "adorable-crab-37": 998242, + "wry-mole-845": 998269, + "careful-koala-146": 998391, + "wooden-beagle-767": 998393, + "rare-wolverine-255": 998396, + "lovely-pika-880": 998402, + "calculating-alpaca-271": 998403, + "gallant-stingray-579": 998411, + "colorless-elephant-805": 998412, + "third-caiman-911": 998414, + "amiable-oriole-132": 998420, + "enduring-toad-913": 998432, + "neat-tapir-878": 998436, + "prestigious-butterfly-498": 998440, + "shocking-capybara-366": 998442, + "astute-hamster-867": 998456, + "first-pony-846": 998457, + "fantastic-hamster-493": 998460, + "terrific-wombat-35": 998461, + "kindly-fox-425": 998466, + "wooden-ibex-395": 998490, + "deafening-buzzard-489": 998496, + "earnest-gnu-51": 998529, + "elegant-firefly-516": 998539, + "silent-hummingbird-603": 998540, + "content-crow-725": 998542, + "reminiscent-goose-660": 998543, + "grateful-ocelot-398": 998547, + "abundant-marlin-460": 998549, + "agile-tortoise-760": 998551, + "hearty-parrot-826": 998552, + "valiant-dragon-752": 998560, + "patient-pika-64": 998560, + "valuable-starfish-766": 998576, + "valiant-toad-50": 998580, + "dusty-warbler-202": 998587, + "hip-eagle-577": 998593, + "incredible-lark-111": 998594, + "zany-curlew-989": 998594, + "majestic-squid-340": 998619, + "industrious-guineapig-386": 998624, + "hearty-porcupine-865": 998625, + "greedy-dotterel-968": 998626, + "different-shark-864": 998651, + "careful-cheetah-288": 998663, + "marvelous-guineapig-412": 998666, + "rapid-panda-864": 998667, + "greedy-minnow-576": 998678, + "outgoing-otter-352": 998727, + "exciting-giraffe-873": 998780, + "dashing-moose-738": 998781, + "basic-bulldog-755": 998782, + "adamant-cardinal-655": 998783, + "limitless-kiwi-984": 998784, + "capable-roadrunner-646": 998785, + "vibrant-akita-464": 998786, + "utmost-cuttlefish-187": 998787, + "giant-cricket-693": 998787, + "beaming-donkey-723": 998788, + "jovial-pika-507": 998789, + "enchanted-gull-794": 998790, + "hidden-donkey-940": 998791, + "rightful-raccoon-297": 998792, + "mellow-wildcat-631": 998793, + "original-giraffe-648": 998794, + "dutiful-wren-829": 998797, + "combative-ferret-751": 998798, + "usable-mule-900": 998799, + "sleek-seahorse-98": 998829, + "proper-clownfish-332": 998830, + "elated-dotterel-320": 998831, + "incredible-cat-460": 998920, + "posh-possum-536": 998921, + "neat-pigeon-733": 998949, + "dependable-spaniel-391": 999082, + "efficient-manatee-906": 999083, + "knowing-beagle-794": 999286, + "scrupulous-retriever-592": 999288, + "curious-iguana-928": 999303, + "wonderful-finch-392": 999304, + "colorful-echidna-168": 999316, + "brave-buffalo-412": 999317, + "amiable-peccary-504": 999318, + "descriptive-cobra-962": 999417, + "descriptive-puffin-965": 999418, + "wonderful-firefly-514": 999487, + "content-sardine-95": 999488, + "rightful-wildebeest-334": 999489, + "shocking-avocet-617": 999506, + "helpful-pheasant-626": 999507, + "dependable-fly-963": 999511, + "qualified-capybara-563": 999512, + "dusty-seahorse-592": 999512, + "benevolent-crow-286": 999569, + "famous-kudu-905": 999570, + "patient-lyrebird-129": 999584, + "watchful-pheasant-72": 999585, + "quirky-bloodhound-531": 999592, + "capable-pelican-264": 999593, + "scintillating-falcon-564": 999595, + "jovial-mule-842": 999596, + "combative-hornet-717": 999607, + "neighborly-oyster-508": 999649, + "good-goldfish-788": 999650, + "colorless-emu-969": 999650, + "harmless-opossum-637": 999717, + "abundant-meadowlark-742": 999718, + "silent-dalmatian-896": 999719, + "clever-terrier-603": 999720, + "acoustic-anaconda-850": 999743, + "canny-jackal-193": 999744, + "successful-sandpiper-859": 999744, + "utmost-avocet-113": 999802, + "acoustic-nightingale-490": 999803, + "savory-jay-808": 999810, + "fastidious-eagle-506": 999811, + "reliable-mule-747": 999863, + "necessary-perch-581": 999864, + "prestigious-ermine-309": 999889, + "accurate-goshawk-480": 999890, + "kindly-raccoon-705": 999907, + "beaming-mandrill-125": 999908, + "grand-bear-809": 999915, + "reminiscent-clownfish-171": 999916, + "adjoining-penguin-194": 999918, + "careful-rook-54": 999919, + "insightful-peacock-395": 999919, + "helpful-chickadee-135": 999923, + "exciting-labrador-603": 999924, + "friendly-terrier-294": 999951, + "merry-narwhal-195": 999952, + "abundant-meerkat-147": 999952, + "pastel-salmon-532": 999959, + "cautious-bass-774": 999960, + "cheery-camel-703": 999967, + "secret-crocodile-948": 999968, + "adjoining-lark-539": 999968, + "valuable-lion-266": 999988, + "warmhearted-turtle-822": 999989, + "acrobatic-puffin-847": 1000000, + "qualified-setter-125": 1000001, + "vibrant-vulture-923": 1000001, + "polite-ermine-729": 1000014, + "mild-stork-319": 1000015, + "silent-orca-704": 1000015, + "hidden-egret-811": 1000017, + "third-hamster-730": 1000018, + "aware-hyena-688": 1000027, + "perceptive-bandicoot-734": 1000028, + "glorious-manatee-336": 1000042, + "gallant-dinosaur-648": 1000043, + "quick-ocelot-414": 1000052, + "strong-hawk-412": 1000053, + "moonlit-sturgeon-549": 1000056, + "basic-guineapig-393": 1000057, + "rugged-gopher-462": 1000057, + "merry-fennec-663": 1000127, + "graceful-crocodile-490": 1000128, + "hushed-woodpecker-316": 1000230, + "expert-frog-55": 1000231, + "avid-hawk-310": 1000254, + "good-raven-852": 1000255, + "scrupulous-quail-116": 1000258, + "spotted-greyhound-900": 1000259, + "hip-hippopotamus-348": 1000301, + "compassionate-cassowary-13": 1000302, + "vivid-weasel-606": 1000326, + "cautious-hippopotamus-589": 1000327, + "beaming-wolverine-358": 1000327, + "canny-octopus-456": 1000334, + "fearless-finch-67": 1000335, + "gallant-narwhal-855": 1000340, + "dazzling-peccary-720": 1000341, + "cool-partridge-89": 1000350, + "outstanding-chipmunk-764": 1000351, + "small-crocodile-543": 1000355, + "exciting-labrador-268": 1000356, + "benevolent-egret-956": 1000362, + "pleasant-dotterel-570": 1000363, + "hardy-lark-592": 1000370, + "kindhearted-mouse-940": 1000371, + "intent-narwhal-439": 1000371, + "wonderful-hedgehog-160": 1000373, + "cheery-kudu-537": 1000374, + "impartial-anaconda-203": 1000380, + "outstanding-parrot-656": 1000381, + "qualified-anaconda-614": 1000387, + "hardy-clownfish-83": 1000388, + "zealous-gopher-557": 1000391, + "artful-cheetah-707": 1000392, + "wandering-narwhal-879": 1000403, + "calm-fennec-563": 1000404, + "limitless-flamingo-740": 1000423, + "wary-wolf-615": 1000424, + "accomplished-opossum-475": 1000425, + "tough-swan-365": 1000426, + "calm-firefly-30": 1000429, + "fastidious-bird-990": 1000430, + "grateful-cod-461": 1000431, + "rosy-elk-613": 1000433, + "first-antelope-243": 1000434, + "loyal-cuttlefish-433": 1000451, + "spotted-hummingbird-285": 1000452, + "ideal-spider-135": 1000458, + "adorable-badger-101": 1000459, + "famous-oriole-990": 1000462, + "greedy-pheasant-33": 1000463, + "opulent-mole-354": 1000478, + "handsome-guanaco-971": 1000479, + "fantastic-koala-670": 1000484, + "animated-rat-84": 1000485, + "capable-lynx-219": 1000509, + "beloved-nightingale-342": 1000510, + "unique-aardvark-645": 1000523, + "artful-chihuahua-454": 1000534, + "dashing-bloodhound-397": 1000535, + "lovely-stoat-618": 1000535, + "pleasant-toucan-520": 1000599, + "nautical-egret-724": 1000600, + "polished-alligator-990": 1000626, + "clever-camel-377": 1000627, + "impressive-dodo-46": 1000627, + "combative-cardinal-416": 1000654, + "handsome-mammoth-462": 1000655, + "patient-peccary-520": 1000657, + "exciting-spider-270": 1000658, + "judicious-jellyfish-443": 1000662, + "small-mockingbird-406": 1000663, + "gregarious-gopher-942": 1000671, + "healthy-civet-416": 1000672, + "bright-crocodile-91": 1000672, + "aware-pelican-954": 1000680, + "prestigious-hummingbird-672": 1000681, + "moonlit-clownfish-25": 1000681, + "expert-finch-398": 1000684, + "reliable-hedgehog-815": 1000685, + "modest-emu-593": 1000762, + "hallowed-stoat-74": 1000827, + "elegant-toucan-427": 1000927, + "original-hippopotamus-602": 1000928, + "clever-tiger-390": 1000952, + "strong-giraffe-772": 1000953, + "usable-greyhound-188": 1000953, + "cautious-vulture-998": 1001054, + "cautious-pelican-456": 1001055, + "fleet-crow-852": 1001077, + "whimsical-vole-691": 1001078, + "proficient-sturgeon-138": 1001112, + "greedy-axolotl-241": 1001113, + "joyous-raven-77": 1001119, + "quixotic-owl-730": 1001120, + "pleasant-crow-590": 1001191, + "artful-cassowary-344": 1001192, + "dynamic-ibis-379": 1001197, + "colorful-porcupine-890": 1001198, + "quaint-avocet-850": 1001238, + "adorable-grasshopper-457": 1001239, + "doting-dog-716": 1001288, + "clever-okapi-22": 1001289, + "hallowed-eagle-15": 1001289, + "knowing-ox-789": 1001302, + "glorious-lynx-129": 1001303, + "famous-fennec-253": 1001314, + "formal-tiger-518": 1001315, + "tough-jackal-477": 1001345, + "fleet-elephant-649": 1001392, + "sincere-jackal-588": 1001393, + "confident-dinosaur-552": 1001394, + "lovely-hamster-199": 1001395, + "artful-goldfinch-142": 1001395, + "fleet-cheetah-845": 1001405, + "friendly-salmon-732": 1001406, + "colorless-elk-430": 1001435, + "kindly-dove-312": 1001436, + "giant-gerbil-362": 1001461, + "oceanic-puffin-441": 1001462, + "joyous-squirrel-777": 1001462, + "flippant-beagle-970": 1001478, + "spotted-hawk-665": 1001479, + "trustworthy-egret-921": 1001479, + "dazzling-shark-366": 1001490, + "dynamic-dalmatian-857": 1001491, + "striped-okapi-264": 1001562, + "dynamic-rat-197": 1001563, + "fast-sparrow-819": 1001563, + "giddy-mink-450": 1001564, + "cheerful-snail-858": 1001565, + "wonderful-moose-267": 1001596, + "calculating-stoat-330": 1001598, + "sleek-kangaroo-590": 1001609, + "jovial-clownfish-143": 1001616, + "handsome-mammoth-618": 1001631, + "unique-spoonbill-473": 1001631, + "whimsical-mammoth-844": 1001642, + "dazzling-civet-223": 1001643, + "bold-bear-458": 1001643, + "dashing-wombat-39": 1001668, + "upbeat-loris-411": 1001669, + "deafening-barracuda-993": 1001711, + "quick-lark-719": 1001712, + "grand-dog-113": 1001712, + "greedy-bat-37": 1001801, + "formal-coyote-744": 1001802, + "beaming-cuttlefish-538": 1001812, + "grateful-jellyfish-240": 1001822, + "pastel-puma-408": 1001823, + "doting-kingfisher-894": 1001823, + "healthy-narwhal-602": 1001824, + "valiant-salamander-757": 1001825, + "blissful-bullfrog-164": 1001825, + "decisive-crocodile-911": 1001873, + "wary-lion-151": 1001874, + "wry-walrus-19": 1001874, + "shocking-cuttlefish-718": 1001954, + "brazen-grasshopper-702": 1001955, + "healthy-dinosaur-629": 1001956, + "hushed-wolverine-38": 1001958, + "polite-elk-477": 1001983, + "brilliant-curlew-72": 1001985, + "necessary-rhinoceros-426": 1001985, + "dynamic-bison-453": 1001998, + "laudable-kudu-124": 1001998, + "amicable-hummingbird-38": 1002107, + "disciplined-avocet-408": 1002108, + "utmost-turtle-273": 1002108, + "hip-nightingale-414": 1002135, + "first-hound-662": 1002175, + "fiery-hound-524": 1002176, + "valuable-cod-58": 1002199, + "harmless-guanaco-480": 1002200, + "utmost-cuttlefish-267": 1002357, + "kindred-parrot-257": 1002359, + "laudable-rat-919": 1002385, + "fleet-bloodhound-893": 1002386, + "loyal-parakeet-286": 1002424, + "doting-heron-287": 1002425, + "tremendous-tortoise-450": 1002515, + "ardent-elephant-797": 1002516, + "dusty-crane-349": 1002516, + "chatty-salamander-443": 1002566, + "elated-mastiff-74": 1002567, + "outgoing-toad-165": 1002581, + "hushed-egret-464": 1002582, + "sleek-peccary-573": 1002608, + "fleet-caterpillar-225": 1002825, + "blessed-eel-210": 1002826, + "watchful-lemur-350": 1002877, + "descriptive-loris-922": 1002879, + "accomplished-anteater-799": 1002879, + "harmless-hedgehog-954": 1002886, + "zealous-anaconda-482": 1002887, + "mild-ermine-540": 1002887, + "next-kangaroo-824": 1002907, + "valiant-viper-415": 1002908, + "doting-lark-602": 1002918, + "modest-akita-935": 1002919, + "optimistic-buffalo-593": 1002930, + "beaming-parakeet-20": 1002931, + "cool-sardine-769": 1002931, + "groovy-hippopotamus-76": 1002947, + "handsome-puffin-533": 1002948, + "calm-blackbird-450": 1002948, + "tough-gull-94": 1002974, + "usable-echidna-500": 1002975, + "proper-condor-647": 1002980, + "cheery-rook-758": 1002981, + "disciplined-tern-334": 1002998, + "elated-mule-81": 1002999, + "joyous-gecko-905": 1003075, + "amiable-alpaca-959": 1003076, + "adventurous-hummingbird-838": 1003103, + "efficient-dinosaur-836": 1003104, + "sincere-jaguar-215": 1003121, + "valiant-dotterel-55": 1003122, + "valiant-sandpiper-364": 1003144, + "rugged-mockingbird-524": 1003145, + "adventurous-beagle-68": 1003170, + "little-sturgeon-57": 1003171, + "cheerful-koala-741": 1003208, + "striped-cormorant-997": 1003209, + "perfect-porcupine-534": 1003212, + "insightful-tern-259": 1003213, + "rosy-cat-930": 1003263, + "warmhearted-robin-41": 1003264, + "good-falcon-881": 1003338, + "exuberant-wolverine-817": 1003339, + "cheerful-yak-589": 1003345, + "necessary-grouse-170": 1003346, + "dazzling-avocet-409": 1003354, + "efficient-cobra-795": 1003355, + "tame-bobcat-751": 1003359, + "ceaseless-sturgeon-789": 1003360, + "agile-malamute-184": 1003366, + "zealous-weasel-329": 1003367, + "silent-bloodhound-768": 1003405, + "descriptive-pig-59": 1003406, + "quixotic-rhinoceros-326": 1003417, + "brainy-clam-299": 1003418, + "acrobatic-firefly-937": 1003437, + "rosy-goose-300": 1003438, + "tough-dotterel-327": 1003440, + "acoustic-goldfish-264": 1003453, + "sensible-bobcat-203": 1003454, + "proficient-avocet-104": 1003484, + "small-echidna-580": 1003570, + "steady-spider-596": 1003571, + "posh-viper-764": 1003571, + "polite-sandpiper-164": 1003573, + "capable-crane-858": 1003574, + "valiant-marmot-888": 1003575, + "tremendous-hummingbird-936": 1003576, + "grandiose-mole-615": 1003582, + "agreeable-alligator-583": 1003583, + "deafening-dinosaur-559": 1003583, + "lovely-canary-86": 1003588, + "friendly-cassowary-148": 1003589, + "polished-greyhound-2": 1003593, + "animated-narwhal-880": 1003594, + "optimistic-swordfish-387": 1003641, + "utmost-rat-706": 1003643, + "chatty-iguana-767": 1003644, + "fantastic-parakeet-372": 1003645, + "prestigious-cardinal-394": 1003655, + "precious-coyote-740": 1003656, + "adventurous-giraffe-392": 1003658, + "agreeable-pig-57": 1003659, + "whimsical-cardinal-376": 1003659, + "determined-caribou-380": 1003665, + "cheery-alligator-192": 1003707, + "wooden-bee-935": 1003708, + "lovable-goldfish-190": 1003711, + "flexible-walrus-116": 1003712, + "fleet-toucan-610": 1003742, + "qualified-tortoise-591": 1003743, + "neighborly-salmon-211": 1003796, + "unique-duck-94": 1003796, + "brave-rook-638": 1003849, + "aware-yak-877": 1003849, + "successful-blackbird-70": 1003868, + "hallowed-capybara-366": 1003869, + "ardent-kiwi-622": 1003883, + "third-peacock-837": 1003885, + "utmost-gull-815": 1003885, + "adorable-kudu-124": 1003954, + "precise-swan-2": 1003955, + "outgoing-antelope-653": 1003977, + "shocking-deer-271": 1003978, + "compassionate-trout-420": 1004036, + "pleasant-antelope-680": 1004037, + "aware-avocet-489": 1004056, + "dazzling-grasshopper-571": 1004057, + "insightful-barracuda-642": 1004057, + "cheery-wolverine-643": 1004060, + "rapid-seahorse-406": 1004061, + "courteous-crab-544": 1004070, + "helpful-lark-70": 1004071, + "content-newt-335": 1004103, + "fantastic-hippopotamus-640": 1004104, + "keen-peacock-61": 1004107, + "wonderful-albatross-873": 1004109, + "utmost-hummingbird-247": 1004118, + "befitting-platypus-532": 1004120, + "rare-grasshopper-239": 1004122, + "small-orca-418": 1004123, + "deafening-wildebeest-933": 1004123, + "sensible-heron-163": 1004128, + "charming-flamingo-544": 1004129, + "earnest-grouse-468": 1004129, + "perfect-dogfish-261": 1004150, + "handsome-jellyfish-534": 1004151, + "beloved-dove-797": 1004159, + "friendly-pig-203": 1004160, + "giant-firefly-418": 1004160, + "adjoining-lynx-168": 1004197, + "compassionate-skunk-990": 1004198, + "ceaseless-rook-968": 1004198, + "limitless-gull-528": 1004295, + "pleasant-wolverine-502": 1004296, + "deafening-lyrebird-975": 1004298, + "canny-spoonbill-402": 1004299, + "fortunate-echidna-395": 1004299, + "deafening-basilisk-664": 1004300, + "elegant-snail-179": 1004301, + "utmost-pelican-286": 1004301, + "effervescent-hare-615": 1004305, + "curious-cow-705": 1004306, + "useful-dove-624": 1004311, + "zany-sheep-58": 1004312, + "adventurous-porpoise-589": 1004312, + "flippant-seahorse-208": 1004318, + "watchful-kookabura-819": 1004319, + "shiny-chameleon-262": 1004339, + "necessary-buffalo-284": 1004340, + "next-dog-424": 1004345, + "quaint-blackbird-996": 1004346, + "greedy-spaniel-317": 1004347, + "colorful-bat-467": 1004348, + "elated-ostrich-964": 1004399, + "quiet-boar-77": 1004399, + "terrific-partridge-567": 1004402, + "prestigious-clownfish-741": 1004403, + "patient-guineapig-122": 1004436, + "perfect-wolf-951": 1004437, + "precious-guineapig-301": 1004475, + "precious-canary-35": 1004477, + "brazen-giraffe-822": 1004505, + "tremendous-leopard-17": 1004506, + "superb-hyena-858": 1004517, + "acrobatic-gnu-584": 1004519, + "zany-buffalo-99": 1004519, + "zany-lynx-24": 1004549, + "groovy-bulldog-236": 1004550, + "mild-pigeon-456": 1004553, + "graceful-caribou-33": 1004554, + "rugged-mongoose-244": 1004554, + "gregarious-panther-991": 1004555, + "affable-curlew-685": 1004556, + "savory-crane-532": 1004556, + "befitting-koala-813": 1004598, + "acrobatic-flamingo-292": 1004599, + "determined-fox-195": 1004635, + "utmost-owl-268": 1004636, + "lovable-tapir-441": 1004677, + "reliable-stork-394": 1004679, + "savory-chameleon-902": 1004703, + "kindred-snail-109": 1004761, + "academic-hawk-433": 1004762, + "deafening-sparrow-351": 1004765, + "dependable-platypus-918": 1004767, + "reminiscent-rat-743": 1004791, + "reminiscent-hedgehog-939": 1004833, + "reminiscent-starling-949": 1004834, + "formal-frog-741": 1004860, + "industrious-clownfish-328": 1004861, + "keen-parrot-686": 1004861, + "quick-bulldog-417": 1004968, + "exciting-sockeye-711": 1004996, + "avid-schnauzer-533": 1004997, + "original-gecko-461": 1004997, + "fearless-dove-241": 1005018, + "amicable-hyena-500": 1005028, + "brazen-kiwi-816": 1005029, + "scrupulous-warbler-906": 1005077, + "giant-newt-736": 1005078, + "stoic-wombat-160": 1005078, + "coordinated-ox-951": 1005085, + "hardy-opossum-293": 1005086, + "hushed-caiman-658": 1005086, + "kindred-grasshopper-456": 1005218, + "kindly-panda-6": 1005219, + "compassionate-kudu-755": 1005243, + "aromatic-alligator-343": 1005244, + "prestigious-badger-163": 1005244, + "reliable-hound-211": 1005249, + "zany-greyhound-71": 1005250, + "hearty-owl-189": 1005279, + "standing-lynx-555": 1005281, + "sincere-goose-57": 1005298, + "befitting-cuttlefish-764": 1005299, + "confident-albatross-270": 1005300, + "fine-clownfish-310": 1005303, + "elegant-goat-45": 1005339, + "disciplined-gnat-134": 1005340, + "deafening-manatee-225": 1005406, + "pastel-cuttlefish-543": 1005407, + "utmost-ptarmigan-297": 1005412, + "acrobatic-wildcat-238": 1005413, + "calculating-mule-841": 1005417, + "adjoining-cat-697": 1005418, + "adorable-cat-315": 1005429, + "intent-eel-44": 1005430, + "adventurous-terrier-794": 1005450, + "outgoing-puffin-793": 1005455, + "gallant-bass-372": 1005456, + "sleek-civet-487": 1005456, + "modest-goose-666": 1005471, + "enduring-dolphin-344": 1005538, + "wary-seal-189": 1005539, + "fine-trout-0": 1005548, + "glad-dragon-334": 1005549, + "gallant-cobra-877": 1005561, + "beaming-ladybug-597": 1005562, + "focused-hedgehog-136": 1005567, + "mellow-cat-542": 1005568, + "avid-alpaca-783": 1005574, + "exciting-ermine-350": 1005576, + "academic-bandicoot-541": 1005584, + "enchanted-puma-249": 1005585, + "knowing-hyena-937": 1005585, + "calculating-spoonbill-542": 1005590, + "accomplished-eel-601": 1005591, + "dependable-meerkat-724": 1005602, + "watchful-cat-612": 1005603, + "wandering-seahorse-199": 1005609, + "hidden-bee-709": 1005610, + "strong-possum-282": 1005610, + "decisive-mosquito-66": 1005667, + "acoustic-orca-629": 1005668, + "impartial-spider-896": 1005681, + "festive-gull-175": 1005682, + "clear-alligator-329": 1005695, + "rapid-moose-124": 1005696, + "decisive-wildebeest-195": 1005724, + "tangible-perch-408": 1005725, + "notable-hummingbird-177": 1005862, + "fiery-anteater-408": 1005863, + "brilliant-corgi-949": 1005863, + "handsome-goat-706": 1005891, + "ardent-quail-54": 1005892, + "artful-bloodhound-902": 1005950, + "tidy-ibex-715": 1005951, + "accomplished-spaniel-622": 1006015, + "patient-goldfish-351": 1006016, + "precise-ladybug-123": 1006052, + "robust-ibis-658": 1006053, + "useful-perch-328": 1006063, + "pleasant-chinchilla-739": 1006065, + "adept-blackbird-344": 1006088, + "proficient-rook-364": 1006089, + "woozy-peacock-504": 1006125, + "animated-avocet-946": 1006163, + "abundant-peacock-407": 1006164, + "stoic-mastiff-426": 1006164, + "mild-shark-240": 1006199, + "notable-orca-455": 1006200, + "elated-kingfisher-554": 1006233, + "strong-partridge-494": 1006234, + "doting-ladybug-369": 1006259, + "kindred-roadrunner-870": 1006261, + "fortunate-lemming-683": 1006263, + "mild-jackal-621": 1006271, + "groovy-turtle-601": 1006272, + "hip-cassowary-404": 1006297, + "abundant-marmot-454": 1006298, + "rare-condor-62": 1006298, + "successful-alligator-575": 1006406, + "watchful-chicken-763": 1006407, + "formal-hedgehog-179": 1006412, + "adept-pony-702": 1006413, + "beaming-echidna-758": 1006417, + "steady-setter-786": 1006418, + "vivid-okapi-441": 1006421, + "tame-dogfish-298": 1006422, + "limitless-stoat-185": 1006445, + "healthy-ptarmigan-58": 1006446, + "brainy-ant-39": 1006474, + "reliable-marlin-837": 1006475, + "gregarious-puma-817": 1006475, + "amicable-snake-183": 1006476, + "abundant-meerkat-113": 1006476, + "effervescent-ibex-19": 1006632, + "dapper-puffin-544": 1006633, + "laudable-dachshund-394": 1006712, + "agile-toad-222": 1006713, + "diligent-malamute-483": 1006714, + "perceptive-pigeon-698": 1006715, + "neighborly-caribou-805": 1006716, + "polished-guineapig-934": 1006717, + "adept-sandpiper-122": 1006718, + "fast-wolf-222": 1006719, + "wandering-salamander-368": 1006721, + "hardy-platypus-317": 1006722, + "glorious-bird-16": 1006723, + "steady-cat-177": 1006724, + "mellow-fly-379": 1006725, + "elated-ptarmigan-602": 1006726, + "terrific-ladybug-705": 1006727, + "proficient-sturgeon-804": 1006728, + "spotted-starfish-665": 1006729, + "exuberant-tortoise-789": 1006730, + "ideal-beagle-683": 1006731, + "little-newt-361": 1006733, + "dependable-husky-4": 1006761, + "accurate-bobcat-893": 1006762, + "friendly-yak-576": 1006763, + "dutiful-elephant-724": 1006764, + "famous-rabbit-684": 1006765, + "pastel-sardine-536": 1006766, + "lovely-rooster-81": 1006767, + "elated-vole-211": 1006768, + "cheerful-avocet-386": 1006769, + "resilient-warbler-506": 1006770, + "tangible-gopher-106": 1006771, + "moonlit-squid-411": 1006772, + "artful-heron-99": 1006773, + "veracious-swordfish-496": 1006774, + "fine-moose-718": 1006775, + "shocking-condor-500": 1006776, + "dynamic-clam-583": 1006777, + "curious-caterpillar-380": 1006778, + "fantastic-lark-341": 1006779, + "patient-schnauzer-524": 1006780, + "flexible-oyster-281": 1006792, + "healthy-parakeet-587": 1006793, + "majestic-newt-794": 1006794, + "resilient-manatee-222": 1006795, + "nautical-leopard-922": 1006796, + "perceptive-seal-638": 1006797, + "colorful-stoat-343": 1006798, + "coordinated-avocet-833": 1006799, + "charming-raven-536": 1006800, + "wonderful-porpoise-648": 1006801, + "affable-narwhal-998": 1006802, + "uncommon-weasel-785": 1006803, + "kindred-koala-41": 1006804, + "laudable-mosquito-713": 1006805, + "wary-tortoise-360": 1006806, + "hallowed-mallard-389": 1006807, + "decisive-dinosaur-207": 1006808, + "secret-cheetah-923": 1006809, + "adamant-wildebeest-230": 1006810, + "kindhearted-mockingbird-547": 1006811, + "acoustic-toucan-234": 1006820, + "perfect-crab-1": 1006905, + "friendly-heron-919": 1006906, + "trustworthy-owl-333": 1006949, + "famous-lemming-684": 1006951, + "unique-weasel-797": 1006952, + "dapper-peacock-252": 1006953, + "vivid-wren-942": 1006954, + "neighborly-warthog-943": 1006955, + "gallant-beagle-480": 1006956, + "descriptive-ermine-145": 1006957, + "hardy-axolotl-472": 1006958, + "zealous-pony-345": 1006959, + "intent-buzzard-826": 1006960, + "friendly-avocet-65": 1006961, + "rapid-crocodile-454": 1006962, + "polished-flamingo-380": 1006963, + "elegant-koala-750": 1006964, + "famous-camel-743": 1006965, + "tremendous-mole-883": 1006966, + "tough-kudu-679": 1006967, + "incredible-duck-472": 1006968, + "doting-sturgeon-543": 1006969, + "dutiful-frog-381": 1006970, + "bold-parakeet-548": 1006978, + "tremendous-jellyfish-901": 1006979, + "sincere-zebra-131": 1006980, + "kindred-buzzard-420": 1006981, + "amiable-chihuahua-411": 1006982, + "incredible-sockeye-422": 1006983, + "amiable-cardinal-238": 1006984, + "dependable-ostrich-87": 1006985, + "handsome-ibex-851": 1006986, + "giddy-walrus-619": 1006987, + "secret-poodle-764": 1006989, + "calm-leopard-510": 1006990, + "aromatic-starling-545": 1006991, + "zany-buzzard-84": 1006992, + "precise-ant-226": 1006993, + "chatty-tapir-409": 1006994, + "vibrant-spaniel-979": 1006995, + "upbeat-snake-486": 1006996, + "abundant-manatee-554": 1006997, + "enduring-spaniel-136": 1006998, + "intent-mongoose-141": 1007002, + "silent-marlin-129": 1007003, + "exciting-whale-389": 1007004, + "posh-rabbit-35": 1007005, + "enduring-hyena-449": 1007006, + "good-turtle-124": 1007007, + "calm-gull-400": 1007008, + "elegant-husky-449": 1007009, + "compassionate-ibis-466": 1007010, + "dazzling-owl-78": 1007011, + "confident-clam-219": 1007012, + "scrupulous-anaconda-92": 1007013, + "rightful-pelican-625": 1007014, + "graceful-dalmatian-873": 1007015, + "wandering-cormorant-910": 1007016, + "laudable-rat-106": 1007017, + "fortunate-warthog-211": 1007018, + "modest-shrimp-593": 1007019, + "fiery-crab-628": 1007020, + "hearty-crab-238": 1007021, + "helpful-lion-42": 1007038, + "dapper-gnat-690": 1007039, + "clear-chinchilla-710": 1007041, + "valuable-blackbird-86": 1007042, + "sleek-starfish-803": 1007043, + "amiable-jay-90": 1007044, + "clever-buzzard-610": 1007045, + "affable-quail-837": 1007046, + "youthful-bobcat-471": 1007047, + "warmhearted-weasel-446": 1007048, + "glad-mouse-609": 1007049, + "careful-starfish-46": 1007050, + "strong-woodpecker-100": 1007051, + "majestic-cheetah-149": 1007052, + "brainy-bloodhound-197": 1007053, + "utmost-possum-386": 1007054, + "confident-sardine-786": 1007055, + "aromatic-wildebeest-265": 1007056, + "incredible-ostrich-827": 1007057, + "rightful-oriole-95": 1007058, + "healthy-pig-202": 1007079, + "honorable-chihuahua-457": 1007080, + "careful-mallard-455": 1007081, + "dutiful-shrimp-718": 1007082, + "resolute-crow-748": 1007083, + "blessed-starling-674": 1007084, + "opulent-tortoise-129": 1007085, + "tacit-lemming-72": 1007086, + "marvelous-hummingbird-199": 1007087, + "strong-hare-135": 1007088, + "vivid-moose-812": 1007089, + "handsome-mole-713": 1007090, + "kindred-coyote-495": 1007091, + "tacit-malamute-68": 1007092, + "expert-bass-274": 1007093, + "third-malamute-605": 1007094, + "accurate-deer-646": 1007095, + "rugged-mammoth-366": 1007096, + "dusty-panda-542": 1007097, + "dashing-orca-342": 1007098, + "blessed-shrimp-364": 1007106, + "hallowed-mosquito-625": 1007107, + "trustworthy-buzzard-951": 1007108, + "necessary-oriole-782": 1007109, + "usable-deer-557": 1007110, + "standing-ant-740": 1007111, + "cool-monitor-150": 1007112, + "third-partridge-1": 1007113, + "precise-ferret-544": 1007114, + "secret-peccary-913": 1007115, + "expert-kingfisher-709": 1007116, + "colorless-herring-777": 1007117, + "successful-goat-325": 1007118, + "blissful-mule-471": 1007119, + "confident-bee-144": 1007120, + "lovely-finch-526": 1007121, + "clear-manatee-962": 1007122, + "confident-lemming-354": 1007123, + "rapid-ram-742": 1007124, + "abundant-quail-850": 1007125, + "cheerful-raccoon-430": 1007126, + "chatty-chicken-523": 1007127, + "secret-raven-388": 1007152, + "strong-emu-288": 1007153, + "flippant-rook-176": 1007154, + "neat-gull-232": 1007155, + "vibrant-pig-919": 1007156, + "bright-monitor-998": 1007157, + "first-lark-6": 1007158, + "laudable-jaguar-250": 1007159, + "formal-vulture-791": 1007160, + "terrific-ibex-571": 1007161, + "capable-civet-648": 1007162, + "glorious-schnauzer-601": 1007163, + "uncommon-curlew-949": 1007164, + "sleek-hedgehog-766": 1007165, + "kindly-ox-31": 1007166, + "secret-tapir-857": 1007167, + "enduring-possum-640": 1007168, + "giddy-warthog-85": 1007169, + "energetic-quail-239": 1007170, + "industrious-fly-902": 1007171, + "animated-spaniel-115": 1007174, + "brainy-oriole-565": 1007175, + "useful-cow-932": 1007176, + "giddy-stoat-845": 1007177, + "unique-dragon-686": 1007178, + "accurate-cat-616": 1007179, + "amicable-ferret-987": 1007180, + "dutiful-dinosaur-715": 1007181, + "determined-bee-481": 1007182, + "dusty-pony-563": 1007183, + "knowing-heron-206": 1007184, + "careful-buffalo-97": 1007185, + "adamant-mastiff-545": 1007186, + "stoic-civet-231": 1007187, + "rapid-shepherd-345": 1007188, + "efficient-civet-860": 1007189, + "tangible-okapi-228": 1007190, + "affable-panda-942": 1007191, + "rightful-gazelle-486": 1007192, + "quirky-lark-462": 1007193, + "fine-sardine-212": 1007197, + "insightful-warbler-298": 1007198, + "ceaseless-trout-984": 1007199, + "fast-squirrel-360": 1007200, + "cool-newt-386": 1007201, + "lovable-lark-420": 1007202, + "formal-marten-646": 1007203, + "limitless-shark-779": 1007204, + "frugal-goose-503": 1007205, + "mild-sheep-674": 1007206, + "laudable-deer-853": 1007207, + "canny-poodle-855": 1007208, + "silent-blackbird-783": 1007209, + "artful-goshawk-606": 1007210, + "brazen-deer-565": 1007211, + "resolute-toad-122": 1007212, + "clean-cuttlefish-653": 1007213, + "zealous-duck-405": 1007214, + "cheery-sheep-170": 1007215, + "determined-caterpillar-674": 1007216, + "next-newt-143": 1007221, + "accurate-crab-802": 1007222, + "dashing-greyhound-626": 1007314, + "earnest-hawk-92": 1007315, + "astute-wombat-635": 1007335, + "mild-schnauzer-856": 1007336, + "resolute-narwhal-630": 1007337, + "patient-kookabura-541": 1007338, + "shocking-wolf-882": 1007339, + "qualified-owl-266": 1007340, + "energetic-chicken-458": 1007341, + "first-duck-732": 1007342, + "diligent-emu-431": 1007343, + "sleek-goat-608": 1007344, + "abundant-hawk-188": 1007345, + "joyous-malamute-403": 1007346, + "upbeat-monitor-492": 1007347, + "blissful-crow-370": 1007348, + "rare-bison-955": 1007349, + "unique-cassowary-770": 1007350, + "fortunate-lynx-592": 1007351, + "stoic-wildcat-364": 1007352, + "intent-terrier-810": 1007353, + "elegant-ostrich-993": 1007354, + "nautical-mammoth-221": 1007361, + "pleasant-peacock-126": 1007362, + "outgoing-retriever-296": 1007363, + "charming-pig-362": 1007364, + "modest-armadillo-744": 1007365, + "veracious-terrier-916": 1007366, + "neat-ladybug-642": 1007367, + "optimistic-toad-609": 1007368, + "polished-weasel-767": 1007369, + "outstanding-marlin-58": 1007370, + "courteous-bobcat-743": 1007371, + "admired-mockingbird-903": 1007372, + "blissful-albatross-773": 1007373, + "blessed-ladybug-33": 1007374, + "loyal-toucan-850": 1007375, + "lovable-weasel-314": 1007376, + "next-hound-945": 1007377, + "jovial-marmot-822": 1007378, + "confident-lark-329": 1007379, + "good-stingray-184": 1007381, + "secret-poodle-192": 1007385, + "reliable-mammoth-973": 1007386, + "charming-manatee-394": 1007387, + "rapid-pony-270": 1007388, + "superb-mammoth-985": 1007389, + "helpful-dinosaur-131": 1007390, + "neat-snail-566": 1007391, + "perfect-ermine-181": 1007392, + "flippant-toad-77": 1007393, + "giant-sockeye-711": 1007394, + "disciplined-spider-693": 1007395, + "hardy-dalmatian-570": 1007396, + "knowing-porcupine-534": 1007397, + "calculating-wildebeest-572": 1007398, + "wry-crocodile-388": 1007399, + "beloved-chihuahua-823": 1007400, + "animated-okapi-900": 1007401, + "acoustic-gecko-531": 1007402, + "gallant-salamander-351": 1007403, + "academic-condor-942": 1007404, + "reliable-albatross-751": 1007411, + "colorful-octopus-154": 1007413, + "quixotic-goldfinch-47": 1007421, + "cheery-cobra-206": 1007422, + "tidy-moose-246": 1007422, + "clever-warthog-746": 1007431, + "reliable-manatee-784": 1007469, + "superb-parrot-52": 1007470, + "effervescent-cheetah-168": 1007509, + "woozy-cheetah-575": 1007510, + "unique-porcupine-611": 1007557, + "dapper-anteater-815": 1007558, + "incredible-porcupine-658": 1007560, + "wary-mule-90": 1007595, + "honorable-orca-961": 1007596, + "hip-wolf-690": 1007600, + "energized-goshawk-544": 1007601, + "adept-lemming-969": 1007609, + "compassionate-badger-661": 1007611, + "lovable-mosquito-950": 1007611, + "enduring-dinosaur-455": 1007618, + "adorable-guanaco-389": 1007663, + "fearless-cassowary-796": 1007664, + "adept-mouse-52": 1007667, + "formal-armadillo-693": 1007668, + "usable-rabbit-401": 1007668, + "brilliant-cheetah-749": 1007675, + "grandiose-owl-412": 1007676, + "focused-iguana-432": 1007677, + "agile-retriever-937": 1007678, + "opulent-orca-48": 1007679, + "calculating-skunk-502": 1007680, + "expert-goat-394": 1007681, + "confident-guanaco-758": 1007682, + "usable-penguin-813": 1007683, + "woozy-nightingale-851": 1007684, + "gregarious-caterpillar-730": 1007685, + "bright-kookabura-246": 1007686, + "effervescent-gull-400": 1007687, + "knowing-squid-523": 1007688, + "judicious-ant-532": 1007689, + "glad-toucan-376": 1007690, + "admired-vulture-733": 1007691, + "modest-bat-960": 1007692, + "sincere-curlew-807": 1007693, + "flippant-bee-323": 1007694, + "earnest-cassowary-512": 1007695, + "fabulous-anaconda-142": 1007707, + "dutiful-ptarmigan-580": 1007708, + "tremendous-lobster-45": 1007739, + "elegant-condor-777": 1007740, + "academic-octopus-224": 1007777, + "effervescent-civet-597": 1007778, + "quixotic-clam-980": 1007778, + "woozy-lemur-562": 1007779, + "dashing-vulture-152": 1007780, + "charming-finch-422": 1007787, + "cautious-ram-360": 1007788, + "patient-wildcat-423": 1007796, + "marvelous-kingfisher-342": 1007797, + "standing-mallard-773": 1007800, + "moonlit-possum-954": 1007801, + "precious-ladybug-392": 1007807, + "strong-rabbit-485": 1007808, + "standing-crane-189": 1007826, + "efficient-iguana-546": 1007827, + "limitless-mandrill-17": 1007828, + "abundant-donkey-767": 1007829, + "bold-bird-206": 1007833, + "dashing-cricket-587": 1007834, + "descriptive-dragon-773": 1007840, + "fast-kudu-990": 1007841, + "robust-bear-42": 1007843, + "tremendous-ox-248": 1007844, + "resilient-jellyfish-15": 1007851, + "perfect-salamander-536": 1007852, + "reliable-emu-918": 1007853, + "graceful-swordfish-69": 1007854, + "brilliant-husky-459": 1007855, + "upbeat-snail-163": 1007856, + "uncommon-jaguar-463": 1007857, + "scintillating-porcupine-780": 1007858, + "moonlit-dove-418": 1007859, + "opulent-chinchilla-562": 1007860, + "watchful-wombat-332": 1007861, + "hidden-armadillo-761": 1007862, + "abundant-nightingale-405": 1007863, + "grand-curlew-462": 1007864, + "rugged-jackal-495": 1007865, + "energetic-mammoth-976": 1007866, + "patient-hedgehog-494": 1007867, + "valuable-robin-121": 1007868, + "reminiscent-bear-311": 1007869, + "energized-eagle-266": 1007870, + "quixotic-eagle-299": 1007874, + "kindred-roadrunner-562": 1007875, + "fearless-sturgeon-72": 1007877, + "earnest-stork-69": 1007878, + "wandering-trout-798": 1007911, + "loyal-peccary-130": 1007917, + "watchful-finch-520": 1007918, + "handsome-lyrebird-43": 1007919, + "elegant-frog-345": 1007920, + "notable-fish-340": 1007921, + "modest-lark-225": 1007922, + "dependable-warbler-5": 1007923, + "standing-hummingbird-922": 1007924, + "fabulous-elk-989": 1007925, + "animated-fish-865": 1007926, + "academic-roadrunner-286": 1007927, + "academic-lynx-542": 1007928, + "mellow-goldfish-436": 1007929, + "courteous-jaguar-574": 1007930, + "tremendous-meadowlark-291": 1007931, + "aware-setter-485": 1007932, + "modest-chipmunk-622": 1007933, + "upbeat-jay-397": 1007934, + "adept-meerkat-854": 1007935, + "calm-setter-118": 1007936, + "adjoining-kookabura-629": 1007947, + "acoustic-goose-683": 1007948, + "enduring-owl-328": 1007949, + "keen-labrador-815": 1007951, + "festive-elk-234": 1007952, + "disciplined-husky-871": 1007953, + "polite-blackbird-804": 1007954, + "perfect-ostrich-232": 1007955, + "dusty-clownfish-640": 1007956, + "sleek-leopard-636": 1007957, + "accomplished-sparrow-156": 1007958, + "tremendous-hound-284": 1007959, + "exciting-parrot-682": 1007960, + "festive-tortoise-382": 1007961, + "zealous-squid-282": 1007962, + "vibrant-pigeon-627": 1007963, + "uncommon-terrier-99": 1007964, + "abundant-civet-669": 1007965, + "glorious-wren-305": 1007966, + "rare-oyster-943": 1007967, + "zealous-moose-148": 1007980, + "necessary-nightingale-119": 1007981, + "resolute-toad-23": 1007981, + "industrious-salamander-904": 1007986, + "astute-warbler-664": 1007987, + "usable-platypus-838": 1007999, + "flexible-hummingbird-695": 1008000, + "useful-goshawk-775": 1008014, + "reminiscent-cheetah-544": 1008015, + "wonderful-parakeet-711": 1008015, + "abundant-seahorse-862": 1008019, + "curious-minnow-588": 1008019, + "first-flamingo-112": 1008031, + "fiery-rook-97": 1008032, + "exciting-hippopotamus-276": 1008032, + "agile-bulldog-678": 1008063, + "fastidious-hound-216": 1008064, + "judicious-caiman-967": 1008085, + "acoustic-basilisk-464": 1008086, + "rapid-cuttlefish-313": 1008088, + "hushed-oyster-116": 1008088, + "dapper-weasel-594": 1008098, + "colorful-swordfish-958": 1008099, + "ceaseless-firefly-474": 1008100, + "scintillating-seal-315": 1008101, + "lovable-cobra-592": 1008102, + "fabulous-marten-216": 1008103, + "vivid-weasel-660": 1008104, + "beloved-oyster-475": 1008105, + "utmost-sardine-3": 1008106, + "notable-donkey-394": 1008107, + "proper-dogfish-143": 1008108, + "bright-hawk-533": 1008109, + "perceptive-seahorse-121": 1008110, + "sleek-gnat-311": 1008111, + "calculating-panther-263": 1008112, + "festive-puma-675": 1008113, + "beaming-anteater-94": 1008114, + "confident-flamingo-160": 1008115, + "modest-ermine-765": 1008116, + "mellow-chipmunk-949": 1008117, + "adept-dodo-687": 1008132, + "cheerful-goat-216": 1008133, + "hearty-emu-411": 1008151, + "precise-heron-628": 1008153, + "hallowed-ant-144": 1008161, + "hallowed-vulture-980": 1008162, + "dazzling-narwhal-685": 1008166, + "tangible-kangaroo-231": 1008167, + "next-frog-587": 1008189, + "animated-elk-8": 1008190, + "reliable-reindeer-262": 1008259, + "chatty-capybara-783": 1008260, + "zealous-cat-746": 1008287, + "opulent-porpoise-20": 1008288, + "youthful-shepherd-409": 1008299, + "polished-fennec-814": 1008300, + "agreeable-alligator-680": 1008315, + "merry-goldfish-840": 1008316, + "secret-coyote-381": 1008331, + "glad-narwhal-171": 1008332, + "impressive-cricket-510": 1008332, + "artful-heron-211": 1008386, + "outstanding-gull-231": 1008387, + "savory-dinosaur-459": 1008397, + "notable-newt-722": 1008397, + "formal-gazelle-3": 1008465, + "oceanic-raven-314": 1008466, + "determined-turtle-298": 1008492, + "quaint-lapwing-722": 1008493, + "acoustic-mandrill-112": 1008500, + "brilliant-crane-185": 1008501, + "doting-clownfish-469": 1008514, + "agreeable-cardinal-621": 1008515, + "admired-narwhal-859": 1008541, + "gregarious-falcon-68": 1008542, + "prestigious-bison-314": 1008563, + "fantastic-woodpecker-74": 1008564, + "marvelous-ibex-87": 1008570, + "courteous-dinosaur-731": 1008571, + "cool-donkey-119": 1008575, + "silent-lemur-465": 1008576, + "steady-caiman-9": 1008605, + "standing-emu-727": 1008606, + "dashing-pelican-417": 1008609, + "cheerful-marmot-96": 1008610, + "quirky-warthog-993": 1008611, + "benevolent-parrot-818": 1008612, + "terrific-gnat-593": 1008616, + "blessed-poodle-590": 1008617, + "insightful-cod-175": 1008619, + "cheerful-pelican-819": 1008619, + "proper-porpoise-375": 1008662, + "sincere-eagle-266": 1008663, + "determined-marmot-403": 1008666, + "festive-chihuahua-60": 1008667, + "dapper-crab-356": 1008672, + "energized-echidna-845": 1008673, + "different-peacock-187": 1008678, + "proficient-jellyfish-564": 1008679, + "proficient-newt-809": 1008679, + "jovial-partridge-720": 1008732, + "ardent-mink-535": 1008733, + "doting-hawk-934": 1008738, + "valuable-giraffe-274": 1008739, + "fantastic-mongoose-968": 1008763, + "keen-fly-651": 1008764, + "wry-snail-431": 1008764, + "capable-cheetah-737": 1008770, + "calculating-cassowary-487": 1008771, + "descriptive-viper-629": 1008797, + "fiery-guineapig-384": 1008798, + "ideal-bandicoot-347": 1008798, + "exciting-roadrunner-314": 1008810, + "adorable-caiman-190": 1008811, + "standing-fly-141": 1008835, + "scintillating-swordfish-150": 1008836, + "perfect-ocelot-517": 1008865, + "amiable-hamster-660": 1008866, + "formal-jay-618": 1008906, + "brainy-jaguar-397": 1008907, + "unique-cricket-956": 1008961, + "wry-ostrich-740": 1008962, + "usable-pelican-286": 1008980, + "affable-penguin-475": 1008981, + "optimistic-condor-867": 1008987, + "artful-lobster-798": 1008988, + "brainy-marmot-542": 1008988, + "earnest-peccary-865": 1009015, + "wandering-seahorse-929": 1009016, + "whimsical-skunk-8": 1009032, + "fine-dalmatian-9": 1009033, + "incredible-rhinoceros-612": 1009044, + "cautious-cod-618": 1009045, + "brave-bass-92": 1009049, + "quixotic-armadillo-819": 1009050, + "combative-tern-655": 1009072, + "amicable-spaniel-128": 1009074, + "elated-nightingale-350": 1009075, + "proficient-crocodile-647": 1009076, + "vivid-hippopotamus-598": 1009091, + "acoustic-finch-303": 1009093, + "shiny-dodo-874": 1009093, + "outstanding-barracuda-759": 1009118, + "next-bloodhound-69": 1009119, + "dapper-starling-798": 1009129, + "whimsical-seahorse-352": 1009131, + "sleek-starling-966": 1009135, + "giant-marten-573": 1009136, + "patient-monitor-728": 1009183, + "steady-seahorse-945": 1009184, + "cautious-oyster-304": 1009184, + "earnest-kudu-988": 1009197, + "usable-moose-136": 1009234, + "peaceful-coyote-755": 1009235, + "exciting-pig-648": 1009235, + "tough-warbler-57": 1009342, + "confident-iguana-989": 1009344, + "impressive-jay-156": 1009357, + "dusty-mastiff-206": 1009358, + "wonderful-perch-690": 1009396, + "agile-setter-77": 1009398, + "scrupulous-clownfish-703": 1009486, + "fortunate-kudu-800": 1009487, + "rare-roadrunner-459": 1009487, + "deafening-tiger-92": 1009515, + "pastel-duck-519": 1009516, + "notable-warthog-816": 1009534, + "scrupulous-gnu-598": 1009536, + "flexible-bandicoot-408": 1009575, + "healthy-mallard-479": 1009576, + "flexible-elk-923": 1009576, + "spotted-beagle-915": 1009621, + "wandering-salmon-894": 1009622, + "shiny-hound-944": 1009623, + "adept-platypus-538": 1009626, + "friendly-akita-206": 1009640, + "neat-alpaca-976": 1009641, + "original-ocelot-788": 1009641, + "dynamic-opossum-687": 1009670, + "calculating-hawk-220": 1009671, + "wonderful-akita-947": 1009704, + "dapper-ant-977": 1009705, + "festive-cricket-233": 1009737, + "gallant-possum-589": 1009738, + "amiable-dotterel-259": 1009764, + "lovable-bat-831": 1009765, + "greedy-capybara-677": 1009765, + "canny-yak-349": 1009778, + "impressive-ant-741": 1009779, + "steady-zebra-56": 1009779, + "quaint-lynx-288": 1009799, + "exciting-eagle-867": 1009800, + "efficient-sparrow-444": 1009800, + "adamant-roadrunner-860": 1009833, + "fine-perch-397": 1009834, + "enchanted-poodle-185": 1009838, + "superb-ptarmigan-318": 1009839, + "aromatic-bat-557": 1009874, + "flexible-zebra-577": 1009875, + "calm-ibis-965": 1009906, + "trustworthy-salamander-344": 1009907, + "agile-cuttlefish-298": 1009908, + "dashing-coyote-482": 1009909, + "agile-blackbird-754": 1009915, + "famous-dodo-829": 1009916, + "hardy-seal-463": 1010018, + "blessed-aardvark-862": 1010020, + "trustworthy-marlin-568": 1010022, + "hardy-fly-850": 1010023, + "silent-shepherd-75": 1010049, + "perceptive-beagle-22": 1010050, + "blessed-wildebeest-41": 1010063, + "calm-fly-359": 1010064, + "careful-goldfish-253": 1010100, + "brainy-bloodhound-112": 1010101, + "calm-rabbit-485": 1010125, + "glad-caribou-463": 1010126, + "greedy-goldfish-550": 1010161, + "little-skunk-128": 1010162, + "original-chameleon-813": 1010210, + "harmless-kingfisher-721": 1010211, + "posh-pony-545": 1010231, + "usable-mallard-340": 1010253, + "honorable-squid-807": 1010255, + "groovy-bird-151": 1010306, + "capable-spoonbill-186": 1010306, + "neighborly-newt-344": 1010313, + "capable-cat-75": 1010314, + "tangible-capybara-471": 1010319, + "merry-jellyfish-401": 1010321, + "energized-elk-794": 1010379, + "kindhearted-rat-219": 1010380, + "content-llama-181": 1010380, + "majestic-rook-49": 1010483, + "colorless-sockeye-950": 1010484, + "basic-gopher-653": 1010528, + "scintillating-gopher-684": 1010529, + "quirky-magpie-668": 1010534, + "exciting-caribou-797": 1010535, + "sensible-reindeer-336": 1010555, + "dashing-retriever-494": 1010556, + "graceful-turtle-179": 1010622, + "graceful-akita-214": 1010623, + "fastidious-wildcat-847": 1010638, + "whimsical-mastiff-148": 1010639, + "upbeat-salamander-632": 1010641, + "standing-kingfisher-441": 1010642, + "resolute-anaconda-656": 1010642, + "trustworthy-fish-130": 1010702, + "accomplished-elephant-684": 1010703, + "cheerful-mink-826": 1010737, + "wooden-malamute-642": 1010738, + "abundant-duck-938": 1010783, + "colorless-dachshund-270": 1010784, + "quaint-sockeye-365": 1010825, + "earnest-seahorse-768": 1010826, + "acrobatic-stoat-719": 1010857, + "hallowed-rooster-437": 1010858, + "brainy-civet-956": 1010858, + "graceful-lemming-908": 1010883, + "woozy-panda-784": 1010884, + "hallowed-sturgeon-366": 1010888, + "knowing-mink-641": 1010889, + "precious-ox-971": 1010893, + "efficient-puffin-841": 1010894, + "wooden-monitor-499": 1010895, + "friendly-dalmatian-978": 1010896, + "adept-axolotl-262": 1010897, + "academic-schnauzer-465": 1010898, + "amiable-sandpiper-322": 1010899, + "helpful-alpaca-637": 1010900, + "moonlit-cheetah-78": 1010901, + "scintillating-puffin-215": 1010902, + "merry-lyrebird-406": 1010903, + "formal-starling-571": 1010904, + "notable-sandpiper-862": 1010905, + "elegant-ferret-811": 1010906, + "uncommon-ptarmigan-991": 1010907, + "handsome-donkey-571": 1010908, + "kindly-fox-379": 1010909, + "jovial-peccary-878": 1010911, + "judicious-scorpion-455": 1010912, + "earnest-starling-108": 1010914, + "beloved-bear-823": 1010933, + "dazzling-ox-462": 1010934, + "compassionate-akita-403": 1010935, + "peaceful-fly-816": 1010936, + "fleet-shrimp-690": 1010937, + "brilliant-lark-933": 1010938, + "shiny-caribou-550": 1010939, + "dusty-possum-736": 1010940, + "precise-wolverine-478": 1010941, + "calculating-iguana-560": 1010942, + "colorless-frog-718": 1010943, + "sensible-wombat-650": 1010944, + "beloved-grasshopper-313": 1010945, + "joyous-wombat-655": 1010946, + "famous-starfish-226": 1010947, + "adjoining-grasshopper-635": 1010948, + "merry-eagle-109": 1010949, + "graceful-fennec-894": 1010950, + "vivid-lion-146": 1010951, + "savory-caribou-731": 1010952, + "curious-caiman-27": 1010965, + "veracious-firefly-944": 1010966, + "accurate-ptarmigan-533": 1010967, + "quirky-ferret-163": 1010968, + "combative-partridge-269": 1010969, + "decisive-capybara-180": 1010970, + "harmless-panther-300": 1010971, + "tacit-orca-370": 1010972, + "descriptive-magpie-503": 1010973, + "qualified-bass-904": 1010974, + "spotted-marmot-791": 1010975, + "keen-squid-377": 1010976, + "aware-porpoise-793": 1010977, + "dusty-pika-123": 1010978, + "wry-corgi-703": 1010979, + "optimistic-sockeye-978": 1010980, + "sensible-sandpiper-845": 1010981, + "tremendous-panther-26": 1010982, + "zealous-coyote-952": 1010983, + "wandering-skunk-969": 1010984, + "impressive-magpie-722": 1010985, + "notable-panda-908": 1010986, + "judicious-vole-819": 1010990, + "energized-gull-608": 1010991, + "hip-sparrow-988": 1010992, + "useful-porpoise-978": 1010993, + "colorless-chameleon-73": 1010994, + "dependable-herring-793": 1010995, + "fortunate-lynx-3": 1010996, + "hidden-oriole-676": 1010997, + "focused-axolotl-906": 1010998, + "hip-anteater-498": 1010999, + "aromatic-salamander-375": 1011000, + "glad-manatee-801": 1011001, + "optimistic-rat-872": 1011002, + "necessary-lion-725": 1011003, + "grateful-ant-562": 1011004, + "agreeable-hamster-726": 1011005, + "fiery-panda-801": 1011006, + "amiable-ox-982": 1011007, + "resilient-avocet-463": 1011008, + "descriptive-rook-411": 1011009, + "knowing-mongoose-84": 1011061, + "small-wildcat-917": 1011062, + "expert-woodpecker-271": 1011081, + "brainy-ram-909": 1011082, + "upbeat-scorpion-576": 1011082, + "energetic-setter-586": 1011107, + "chatty-salmon-127": 1011108, + "whimsical-dotterel-136": 1011114, + "reliable-ladybug-880": 1011115, + "valuable-goldfish-380": 1011116, + "quaint-skunk-380": 1011117, + "amiable-marmot-87": 1011118, + "loyal-elk-60": 1011119, + "different-clam-441": 1011120, + "limitless-frog-700": 1011121, + "keen-capybara-33": 1011122, + "marvelous-shrimp-601": 1011123, + "doting-crane-442": 1011124, + "rosy-zebra-826": 1011125, + "watchful-hippopotamus-50": 1011126, + "blissful-caterpillar-664": 1011127, + "shocking-opossum-877": 1011128, + "loyal-mockingbird-158": 1011129, + "fortunate-beagle-755": 1011130, + "valiant-chinchilla-480": 1011131, + "resilient-anteater-661": 1011132, + "energetic-ocelot-718": 1011133, + "marvelous-anaconda-219": 1011141, + "mellow-deer-585": 1011142, + "helpful-hummingbird-789": 1011155, + "outstanding-spaniel-933": 1011156, + "artful-emu-654": 1011162, + "ideal-ibex-475": 1011164, + "graceful-goose-226": 1011200, + "friendly-mandrill-412": 1011201, + "doting-swan-145": 1011201, + "modest-dalmatian-720": 1011205, + "mellow-civet-886": 1011206, + "kindly-fly-254": 1011206, + "deafening-dolphin-13": 1011257, + "cheerful-heron-67": 1011258, + "benevolent-koala-471": 1011277, + "quick-pig-190": 1011278, + "fearless-zebra-345": 1011278, + "groovy-possum-502": 1011281, + "colorful-herring-122": 1011282, + "neat-clam-432": 1011283, + "blessed-bloodhound-337": 1011284, + "kindred-armadillo-330": 1011289, + "coordinated-fly-76": 1011290, + "robust-crow-199": 1011305, + "quick-hawk-548": 1011306, + "proper-opossum-964": 1011306, + "bright-yak-61": 1011318, + "dapper-hippopotamus-464": 1011320, + "modest-sparrow-128": 1011401, + "enchanted-labrador-123": 1011403, + "helpful-coyote-432": 1011454, + "terrific-wildebeest-483": 1011455, + "grandiose-dinosaur-645": 1011459, + "giddy-ant-606": 1011461, + "tremendous-dotterel-43": 1011471, + "whimsical-falcon-975": 1011472, + "shocking-hippopotamus-424": 1011472, + "dynamic-canary-867": 1011489, + "rosy-crane-594": 1011490, + "grateful-crow-665": 1011491, + "secret-hornet-162": 1011492, + "courteous-rhinoceros-568": 1011493, + "polite-fish-969": 1011494, + "charming-bass-281": 1011495, + "sleek-axolotl-626": 1011496, + "astute-penguin-678": 1011497, + "neat-sparrow-312": 1011498, + "curious-squirrel-621": 1011499, + "healthy-pony-616": 1011500, + "enduring-llama-222": 1011501, + "notable-crocodile-487": 1011502, + "optimistic-ostrich-2": 1011503, + "wandering-koala-430": 1011504, + "colorful-fly-806": 1011505, + "enduring-ladybug-960": 1011506, + "curious-blackbird-702": 1011507, + "reminiscent-lynx-192": 1011508, + "grateful-jay-185": 1011524, + "utmost-ptarmigan-906": 1011525, + "fine-raccoon-612": 1011546, + "impressive-hamster-669": 1011547, + "tangible-albatross-472": 1011548, + "vibrant-rhinoceros-945": 1011549, + "impartial-labrador-284": 1011550, + "perceptive-caiman-130": 1011551, + "opulent-wolverine-701": 1011552, + "determined-falcon-569": 1011553, + "outgoing-marmot-901": 1011554, + "impressive-mandrill-847": 1011555, + "quirky-cardinal-926": 1011556, + "sensible-grasshopper-337": 1011557, + "opulent-snake-938": 1011558, + "efficient-hedgehog-418": 1011559, + "efficient-frog-472": 1011560, + "quaint-oyster-195": 1011561, + "vibrant-ostrich-773": 1011562, + "vivid-goose-673": 1011563, + "polished-seal-757": 1011565, + "enduring-orca-679": 1011566, + "sincere-fox-814": 1011577, + "cheery-panther-893": 1011578, + "shiny-koala-257": 1011579, + "tame-clam-149": 1011580, + "stoic-shepherd-472": 1011581, + "basic-coyote-403": 1011582, + "outgoing-kingfisher-240": 1011583, + "dusty-rhinoceros-334": 1011584, + "hardy-basilisk-514": 1011585, + "tidy-tortoise-244": 1011586, + "famous-toucan-484": 1011588, + "graceful-cobra-922": 1011589, + "rosy-mandrill-909": 1011590, + "qualified-chihuahua-563": 1011591, + "useful-gopher-191": 1011592, + "polite-partridge-59": 1011593, + "vivid-axolotl-791": 1011594, + "exciting-salamander-139": 1011595, + "reliable-weasel-932": 1011596, + "glorious-owl-409": 1011598, + "pleasant-porpoise-89": 1011609, + "unique-marmot-552": 1011610, + "wonderful-heron-162": 1011614, + "opulent-otter-997": 1011615, + "youthful-spider-456": 1011616, + "lovable-buzzard-81": 1011617, + "artful-pig-817": 1011618, + "sleek-rat-344": 1011619, + "trustworthy-antelope-462": 1011620, + "brazen-sheep-903": 1011621, + "terrific-mockingbird-242": 1011622, + "robust-herring-21": 1011623, + "cool-lyrebird-269": 1011624, + "impartial-manatee-366": 1011625, + "proficient-impala-776": 1011626, + "clever-avocet-361": 1011627, + "fantastic-cobra-514": 1011628, + "vivid-rat-493": 1011629, + "wary-marmot-135": 1011630, + "dutiful-dotterel-44": 1011631, + "superb-mockingbird-843": 1011632, + "hallowed-dolphin-152": 1011633, + "hallowed-lemming-844": 1011640, + "nautical-barracuda-934": 1011641, + "combative-gerbil-922": 1011667, + "kindred-cod-226": 1011668, + "wry-rabbit-940": 1011700, + "brazen-narwhal-335": 1011701, + "zealous-hippopotamus-310": 1011702, + "prestigious-elephant-746": 1011703, + "woozy-falcon-789": 1011704, + "descriptive-dogfish-849": 1011705, + "hallowed-mule-985": 1011706, + "coordinated-axolotl-311": 1011707, + "cool-seal-815": 1011708, + "sincere-goose-185": 1011709, + "unique-porcupine-127": 1011710, + "trustworthy-leopard-107": 1011711, + "whimsical-marlin-455": 1011712, + "little-heron-625": 1011713, + "rightful-poodle-376": 1011714, + "grateful-lapwing-16": 1011715, + "adamant-fennec-266": 1011717, + "woozy-alligator-214": 1011718, + "enchanted-tern-647": 1011720, + "brazen-marlin-206": 1011721, + "sincere-alpaca-533": 1011757, + "steady-goldfinch-631": 1011758, + "valuable-wildebeest-235": 1011788, + "terrific-giraffe-878": 1011789, + "dusty-rat-945": 1011832, + "polite-terrier-724": 1011833, + "perfect-gull-6": 1011844, + "dusty-elephant-560": 1011845, + "hardy-husky-875": 1011845, + "admired-spoonbill-211": 1011854, + "tame-wolverine-485": 1011855, + "warmhearted-dove-417": 1011863, + "fine-dinosaur-686": 1011864, + "amiable-flamingo-478": 1011865, + "sincere-lemming-444": 1011866, + "acoustic-mink-452": 1011872, + "scintillating-beagle-74": 1011873, + "zany-stingray-481": 1011875, + "insightful-walrus-270": 1011876, + "astute-bobcat-29": 1011880, + "helpful-clownfish-974": 1011882, + "scrupulous-eagle-902": 1011902, + "diligent-bandicoot-199": 1011903, + "fleet-crow-228": 1011915, + "strong-rhinoceros-581": 1011916, + "spotted-lark-663": 1011931, + "different-hound-699": 1011932, + "hidden-pheasant-842": 1011944, + "unique-panda-712": 1011945, + "elated-chicken-294": 1011945, + "opulent-woodpecker-138": 1011948, + "hip-heron-690": 1011950, + "veracious-oyster-158": 1011952, + "basic-oyster-135": 1011953, + "enduring-mammoth-446": 1011957, + "resilient-mallard-677": 1011958, + "adventurous-blackbird-584": 1011968, + "hushed-rooster-236": 1011969, + "diligent-gecko-418": 1011969, + "moonlit-quail-469": 1011970, + "upbeat-cobra-439": 1011971, + "amiable-dotterel-58": 1011972, + "dependable-newt-876": 1011973, + "giant-gecko-718": 1011973, + "courteous-dachshund-815": 1011977, + "striped-tortoise-28": 1011979, + "deafening-ptarmigan-473": 1011981, + "keen-loris-852": 1011982, + "rapid-camel-745": 1011984, + "gallant-starling-622": 1011985, + "quixotic-emu-703": 1011988, + "woozy-ibex-45": 1011989, + "frugal-swordfish-543": 1011994, + "superb-dogfish-257": 1011996, + "scintillating-caribou-660": 1012003, + "zealous-canary-54": 1012006, + "calm-shark-63": 1012007, + "deafening-frog-734": 1012008, + "aware-butterfly-324": 1012009, + "giddy-squid-342": 1012010, + "confident-malamute-658": 1012011, + "incredible-marlin-109": 1012012, + "calm-mouse-785": 1012013, + "canny-peacock-402": 1012015, + "robust-echidna-929": 1012016, + "effervescent-buzzard-811": 1012017, + "earnest-salamander-879": 1012018, + "brazen-salamander-671": 1012019, + "veracious-starfish-85": 1012020, + "colorful-mole-124": 1012021, + "wry-sparrow-790": 1012023, + "rightful-kookabura-727": 1012024, + "fine-lyrebird-116": 1012035, + "grateful-ibex-638": 1012036, + "harmless-hare-834": 1012037, + "perfect-impala-793": 1012039, + "utmost-chameleon-20": 1012040, + "scrupulous-cormorant-98": 1012041, + "benevolent-barracuda-45": 1012042, + "flexible-finch-935": 1012043, + "amicable-axolotl-709": 1012044, + "tidy-vole-61": 1012045, + "fabulous-wildebeest-511": 1012047, + "savory-condor-884": 1012049, + "small-woodpecker-832": 1012051, + "precious-pig-543": 1012052, + "precise-avocet-599": 1012053, + "healthy-minnow-952": 1012054, + "frugal-pony-27": 1012055, + "steady-buzzard-570": 1012056, + "striped-coyote-122": 1012057, + "quirky-rhinoceros-82": 1012058, + "pastel-clownfish-703": 1012059, + "incredible-boar-787": 1012060, + "amicable-gull-285": 1012061, + "energetic-warthog-768": 1012062, + "tangible-parrot-510": 1012063, + "hip-possum-684": 1012064, + "quaint-pigeon-881": 1012065, + "efficient-bulldog-598": 1012066, + "tacit-roadrunner-878": 1012067, + "hushed-echidna-537": 1012068, + "giant-warbler-840": 1012069, + "patient-dodo-422": 1012070, + "befitting-shepherd-382": 1012071, + "stoic-ocelot-510": 1012071, + "cheery-ermine-366": 1012073, + "keen-herring-660": 1012074, + "incredible-warthog-432": 1012076, + "scintillating-ibex-768": 1012077, + "robust-kangaroo-669": 1012078, + "decisive-partridge-448": 1012079, + "qualified-owl-275": 1012080, + "industrious-shark-351": 1012081, + "standing-crow-824": 1012082, + "exciting-bloodhound-512": 1012083, + "friendly-stork-5": 1012084, + "determined-armadillo-979": 1012085, + "tremendous-squirrel-444": 1012086, + "animated-bat-274": 1012087, + "dapper-magpie-543": 1012088, + "successful-bison-824": 1012089, + "harmless-puma-511": 1012090, + "calm-reindeer-261": 1012091, + "rugged-beagle-562": 1012092, + "perceptive-weasel-116": 1012093, + "tacit-mosquito-391": 1012094, + "majestic-vulture-487": 1012095, + "sleek-otter-690": 1012096, + "perceptive-orca-880": 1012097, + "helpful-lion-620": 1012100, + "hushed-lemur-862": 1012102, + "brave-albatross-539": 1012103, + "peaceful-lemur-899": 1012104, + "polite-leopard-855": 1012104, + "befitting-ant-442": 1012107, + "strong-chickadee-881": 1012108, + "dynamic-gnat-252": 1012113, + "silent-elk-880": 1012114, + "honorable-chicken-139": 1012116, + "perceptive-pony-252": 1012117, + "spotted-porcupine-734": 1012119, + "loyal-mongoose-75": 1012120, + "colorless-dodo-145": 1012121, + "pastel-raccoon-819": 1012122, + "knowing-partridge-812": 1012125, + "blissful-puffin-542": 1012126, + "lovely-chinchilla-850": 1012128, + "loyal-owl-76": 1012129, + "cool-dachshund-731": 1012131, + "quaint-schnauzer-88": 1012132, + "lovable-squid-996": 1012132, + "helpful-starfish-694": 1012133, + "grandiose-gnu-903": 1012136, + "acrobatic-bear-102": 1012136, + "cheery-civet-303": 1012137, + "combative-blackbird-494": 1012138, + "first-dogfish-778": 1012138, + "proficient-owl-569": 1012139, + "robust-penguin-265": 1012140, + "silent-greyhound-779": 1012144, + "earnest-moose-406": 1012145, + "tremendous-deer-728": 1012146, + "sleek-albatross-962": 1012148, + "outstanding-goose-479": 1012148, + "clear-fox-782": 1012154, + "doting-swan-456": 1012155, + "hallowed-parrot-283": 1012156, + "limitless-cheetah-104": 1012157, + "honorable-jay-199": 1012161, + "blissful-puffin-304": 1012162, + "modest-sandpiper-74": 1012169, + "gregarious-axolotl-591": 1012170, + "valuable-heron-398": 1012170, + "original-akita-876": 1012174, + "famous-eagle-517": 1012175, + "brave-anteater-267": 1012184, + "upbeat-tiger-501": 1012185, + "quirky-clam-646": 1012186, + "pleasant-impala-309": 1012187, + "silent-starling-322": 1012188, + "dusty-pony-775": 1012189, + "flexible-rhinoceros-816": 1012197, + "modest-coyote-809": 1012198, + "acoustic-porcupine-130": 1012202, + "flippant-pelican-61": 1012203, + "usable-kudu-434": 1012205, + "befitting-bison-950": 1012206, + "secret-cassowary-749": 1012206, + "handsome-yak-212": 1012208, + "useful-malamute-977": 1012209, + "energized-gnat-655": 1012209, + "gregarious-stoat-591": 1012210, + "dashing-kiwi-918": 1012211, + "kindred-salmon-240": 1012214, + "valiant-cheetah-638": 1012215, + "accomplished-fly-673": 1012216, + "quirky-raven-563": 1012217, + "avid-cow-577": 1012220, + "successful-fly-707": 1012221, + "reliable-porcupine-253": 1012226, + "neighborly-tortoise-457": 1012227, + "bold-goldfish-93": 1012228, + "beloved-kookabura-690": 1012228, + "polished-weasel-946": 1012232, + "hallowed-impala-843": 1012232, + "colorless-goldfinch-286": 1012237, + "befitting-akita-332": 1012238, + "wary-grasshopper-576": 1012241, + "precise-hamster-218": 1012247, + "vibrant-snake-180": 1012248, + "kindred-dragon-380": 1012255, + "dependable-roadrunner-942": 1012256, + "resolute-alligator-682": 1012261, + "dapper-dotterel-482": 1012262, + "blessed-panther-565": 1012264, + "precise-buzzard-853": 1012265, + "trustworthy-lark-386": 1012271, + "opulent-chameleon-16": 1012272, + "watchful-aardvark-928": 1012273, + "calculating-kingfisher-446": 1012274, + "amiable-ibis-306": 1012279, + "quirky-toucan-663": 1012280, + "hallowed-dragon-101": 1012281, + "spotted-snake-248": 1012283, + "dusty-mockingbird-992": 1012285, + "valuable-nightingale-505": 1012287, + "amicable-kudu-906": 1012288, + "mild-cassowary-60": 1012290, + "fleet-sandpiper-624": 1012292, + "focused-fennec-621": 1012294, + "adventurous-goat-930": 1012297, + "judicious-wildcat-513": 1012298, + "courteous-antelope-258": 1012298, + "mellow-swordfish-645": 1012314, + "steady-sardine-517": 1012315, + "small-camel-335": 1012319, + "usable-giraffe-561": 1012320, + "dynamic-chameleon-46": 1012325, + "expert-narwhal-620": 1012326, + "opulent-bullfrog-382": 1012328, + "scrupulous-lyrebird-475": 1012329, + "loyal-cobra-509": 1012331, + "third-ptarmigan-106": 1012332, + "spotted-ocelot-269": 1012334, + "tame-goldfinch-316": 1012335, + "resolute-penguin-468": 1012336, + "small-gopher-233": 1012337, + "cheery-kiwi-403": 1012338, + "useful-impala-602": 1012339, + "sleek-dalmatian-420": 1012340, + "colorful-boar-656": 1012341, + "brave-otter-64": 1012342, + "bright-sparrow-957": 1012344, + "expert-flamingo-170": 1012345, + "small-bass-474": 1012346, + "valuable-dalmatian-309": 1012348, + "curious-fish-832": 1012349, + "calm-bison-315": 1012350, + "adorable-buzzard-977": 1012351, + "adorable-crocodile-941": 1012352, + "knowing-guineapig-533": 1012353, + "adept-turtle-712": 1012354, + "earnest-lion-906": 1012355, + "neighborly-okapi-162": 1012358, + "groovy-frog-573": 1012359, + "nautical-anteater-773": 1012360, + "veracious-labrador-42": 1012361, + "savory-horse-346": 1012361, + "dynamic-setter-323": 1012362, + "compassionate-swan-260": 1012363, + "fine-bison-335": 1012364, + "rosy-alligator-865": 1012365, + "honorable-dalmatian-420": 1012366, + "focused-clam-951": 1012367, + "descriptive-parrot-48": 1012368, + "cautious-swordfish-464": 1012369, + "fortunate-civet-247": 1012370, + "giddy-vulture-154": 1012371, + "qualified-fennec-833": 1012372, + "enchanted-labrador-11": 1012373, + "festive-hyena-910": 1012374, + "tame-weasel-541": 1012375, + "acoustic-mallard-403": 1012376, + "cautious-chameleon-874": 1012376, + "harmless-cardinal-247": 1012377, + "vivid-hamster-179": 1012378, + "cautious-pigeon-966": 1012380, + "outgoing-elephant-860": 1012381, + "optimistic-toucan-141": 1012382, + "quaint-raven-401": 1012383, + "chatty-cardinal-332": 1012384, + "harmless-basilisk-884": 1012386, + "beloved-fish-622": 1012388, + "next-bee-450": 1012388, + "canny-guineapig-187": 1012396, + "giddy-trout-419": 1012397, + "dapper-caterpillar-128": 1012404, + "tough-ibis-731": 1012405, + "jovial-chickadee-230": 1012408, + "rapid-spoonbill-858": 1012409, + "upbeat-marlin-736": 1012412, + "reliable-swordfish-550": 1012413, + "tacit-puma-795": 1012417, + "knowing-platypus-390": 1012418, + "impressive-caiman-404": 1012418, + "rosy-rat-931": 1012424, + "utmost-minnow-152": 1012425, + "exuberant-loris-498": 1012432, + "healthy-iguana-363": 1012433, + "next-chicken-453": 1012435, + "agile-firefly-130": 1012436, + "watchful-barracuda-331": 1012441, + "shocking-minnow-950": 1012442, + "outstanding-warthog-225": 1012444, + "woozy-ladybug-957": 1012446, + "courteous-mosquito-976": 1012446, + "warmhearted-crocodile-73": 1012460, + "flexible-wolverine-327": 1012462, + "tame-starfish-402": 1012463, + "pleasant-retriever-340": 1012464, + "quiet-alpaca-862": 1012471, + "original-marlin-729": 1012472, + "polished-manatee-595": 1012473, + "adamant-eel-336": 1012474, + "brilliant-firefly-485": 1012481, + "confident-mammoth-187": 1012482, + "utmost-kiwi-125": 1012489, + "nautical-setter-871": 1012490, + "amiable-elk-513": 1012490, + "fabulous-chickadee-949": 1012499, + "precious-hyena-429": 1012500, + "tough-anteater-273": 1012500, + "clever-emu-801": 1012503, + "vivid-marten-238": 1012506, + "calculating-dragon-512": 1012506, + "outstanding-vole-917": 1012507, + "groovy-bobcat-75": 1012509, + "resolute-goat-533": 1012511, + "brainy-grouse-158": 1012513, + "wry-armadillo-903": 1012517, + "moonlit-dodo-815": 1012518, + "mild-hyena-590": 1012518, + "keen-opossum-399": 1012520, + "knowing-squirrel-120": 1012521, + "mild-meadowlark-766": 1012521, + "earnest-goat-442": 1012528, + "secret-deer-32": 1012529, + "nautical-lemur-942": 1012532, + "acrobatic-salamander-459": 1012533, + "qualified-echidna-902": 1012535, + "famous-oyster-294": 1012536, + "notable-squirrel-899": 1012540, + "elegant-raven-389": 1012541, + "optimistic-pika-62": 1012548, + "kindly-trout-110": 1012549, + "opulent-sturgeon-182": 1012549, + "agile-firefly-746": 1012559, + "ideal-dog-883": 1012560, + "brilliant-mouse-167": 1012565, + "peaceful-crocodile-421": 1012567, + "aware-whale-651": 1012572, + "gregarious-malamute-681": 1012573, + "unique-sparrow-111": 1012584, + "precise-bloodhound-699": 1012585, + "proficient-newt-53": 1012585, + "grand-giraffe-97": 1012586, + "accomplished-kiwi-264": 1012587, + "loyal-salmon-240": 1012588, + "polished-tortoise-200": 1012589, + "beloved-butterfly-669": 1012593, + "polished-puma-82": 1012594, + "joyous-chihuahua-711": 1012597, + "exciting-rook-15": 1012598, + "industrious-swordfish-252": 1012598, + "glad-goldfinch-873": 1012600, + "calculating-chicken-868": 1012601, + "spotted-shepherd-302": 1012602, + "ideal-ferret-32": 1012604, + "good-goshawk-718": 1012604, + "adamant-tern-690": 1012605, + "upbeat-kangaroo-473": 1012606, + "quaint-armadillo-966": 1012612, + "ardent-lark-954": 1012613, + "valuable-hummingbird-868": 1012615, + "necessary-curlew-495": 1012616, + "polished-ostrich-271": 1012617, + "accurate-raven-210": 1012619, + "mild-zebra-60": 1012623, + "courteous-stoat-993": 1012624, + "handsome-porpoise-812": 1012625, + "knowing-chinchilla-151": 1012626, + "striped-guanaco-446": 1012632, + "wry-bulldog-952": 1012633, + "zealous-mallard-37": 1012641, + "exuberant-fox-931": 1012642, + "wooden-terrier-509": 1012642, + "original-spider-637": 1012643, + "acoustic-marlin-481": 1012644, + "spotted-warthog-419": 1012646, + "decisive-vulture-397": 1012647, + "combative-snail-921": 1012648, + "resilient-ostrich-464": 1012649, + "quiet-woodpecker-347": 1012663, + "focused-gazelle-679": 1012664, + "marvelous-chihuahua-370": 1012671, + "agreeable-deer-46": 1012672, + "qualified-akita-141": 1012672, + "affable-horse-564": 1012673, + "hushed-cod-54": 1012674, + "beaming-pelican-184": 1012676, + "laudable-sturgeon-157": 1012679, + "unique-skunk-991": 1012681, + "expert-ibis-233": 1012682, + "uncommon-caribou-480": 1012682, + "first-schnauzer-266": 1012686, + "modest-dodo-607": 1012688, + "compassionate-dove-385": 1012689, + "cool-weasel-135": 1012690, + "silent-hippopotamus-789": 1012694, + "adept-opossum-231": 1012695, + "beaming-egret-271": 1012697, + "confident-kookabura-513": 1012698, + "hushed-basilisk-183": 1012700, + "charming-koala-39": 1012701, + "colorless-oriole-140": 1012701, + "resilient-malamute-23": 1012704, + "adjoining-firefly-612": 1012705, + "posh-eagle-799": 1012708, + "content-swan-930": 1012709, + "clean-egret-525": 1012711, + "formal-llama-460": 1012715, + "harmless-manatee-153": 1012716, + "bright-kingfisher-720": 1012718, + "energetic-vulture-995": 1012719, + "veracious-panda-756": 1012720, + "nautical-mouse-532": 1012720, + "grandiose-chickadee-215": 1012721, + "harmless-anteater-87": 1012721, + "helpful-poodle-826": 1012726, + "first-civet-633": 1012727, + "compassionate-puffin-70": 1012735, + "zany-frog-678": 1012737, + "fearless-donkey-926": 1012741, + "academic-loris-685": 1012743, + "dusty-peccary-946": 1012745, + "rapid-capybara-166": 1012746, + "woozy-jaguar-394": 1012747, + "decisive-quail-187": 1012749, + "reliable-spider-978": 1012751, + "outstanding-elephant-445": 1012752, + "accurate-squid-72": 1012753, + "optimistic-starling-752": 1012754, + "spotted-sturgeon-872": 1012757, + "disciplined-albatross-199": 1012758, + "careful-bat-957": 1012759, + "kindhearted-wren-865": 1012761, + "valuable-firefly-938": 1012767, + "qualified-possum-129": 1012768, + "focused-owl-779": 1012768, + "whimsical-clownfish-749": 1012769, + "utmost-gnat-544": 1012770, + "descriptive-chameleon-195": 1012770, + "avid-dragon-456": 1012771, + "wry-raccoon-251": 1012772, + "robust-husky-6": 1012772, + "canny-hyena-251": 1012781, + "efficient-flamingo-66": 1012782, + "dashing-crab-678": 1012787, + "rugged-barracuda-308": 1012788, + "bold-pony-803": 1012792, + "uncommon-goshawk-845": 1012796, + "deafening-mule-664": 1012800, + "hardy-ox-180": 1012801, + "descriptive-nightingale-723": 1012802, + "efficient-ostrich-805": 1012803, + "fantastic-horse-445": 1012807, + "neighborly-sturgeon-865": 1012808, + "shocking-ladybug-301": 1012809, + "majestic-puma-390": 1012811, + "descriptive-meadowlark-949": 1012813, + "confident-lark-179": 1012815, + "chatty-peccary-402": 1012819, + "next-heron-787": 1012821, + "amicable-frog-857": 1012823, + "quixotic-panda-29": 1012824, + "striped-mink-388": 1012824, + "qualified-shrimp-698": 1012829, + "determined-mosquito-737": 1012830, + "terrific-cuttlefish-479": 1012830, + "cheerful-weasel-825": 1012833, + "proper-guanaco-60": 1012834, + "zany-badger-707": 1012838, + "keen-ram-767": 1012840, + "vivid-caterpillar-323": 1012840, + "polite-porpoise-695": 1012842, + "charming-peacock-164": 1012843, + "compassionate-skunk-147": 1012843, + "helpful-setter-404": 1012850, + "hip-tortoise-272": 1012851, + "watchful-mouse-928": 1012855, + "disciplined-mole-860": 1012856, + "lovely-lobster-428": 1012856, + "adorable-salamander-906": 1012857, + "loyal-owl-141": 1012858, + "avid-flamingo-224": 1012860, + "exciting-mockingbird-295": 1012861, + "kindhearted-beagle-130": 1012865, + "quirky-snail-743": 1012866, + "keen-pigeon-112": 1012871, + "fortunate-rhinoceros-625": 1012872, + "quiet-squid-265": 1012872, + "valuable-avocet-818": 1012873, + "curious-panda-500": 1012874, + "frugal-panther-692": 1012875, + "ideal-ferret-361": 1012876, + "earnest-corgi-224": 1012891, + "combative-sandpiper-507": 1012892, + "striped-curlew-227": 1012899, + "usable-ptarmigan-432": 1012900, + "different-corgi-510": 1012904, + "first-lyrebird-672": 1012905, + "original-mosquito-382": 1012918, + "utmost-fish-896": 1012920, + "warmhearted-mammoth-930": 1012920, + "earnest-chihuahua-877": 1012931, + "determined-swordfish-658": 1012932, + "perfect-alligator-566": 1012938, + "gregarious-crow-588": 1012939, + "joyous-duck-984": 1012939, + "elegant-reindeer-412": 1012942, + "groovy-pig-983": 1012943, + "impressive-oyster-886": 1012943, + "dynamic-raven-893": 1012953, + "glad-roadrunner-896": 1012954, + "zealous-egret-945": 1012959, + "dynamic-corgi-315": 1012960, + "moonlit-otter-225": 1012961, + "benevolent-cat-832": 1012962, + "necessary-hound-550": 1012968, + "disciplined-puffin-455": 1012969, + "proper-emu-281": 1012973, + "efficient-hedgehog-801": 1012974, + "groovy-dotterel-321": 1012975, + "graceful-marlin-331": 1012976, + "warmhearted-gnu-8": 1012979, + "academic-kiwi-436": 1012980, + "opulent-bobcat-714": 1012980, + "moonlit-anaconda-131": 1012982, + "ideal-lobster-758": 1012983, + "aware-porpoise-130": 1012983, + "enchanted-ox-48": 1012988, + "focused-parrot-217": 1012989, + "canny-ox-254": 1012995, + "fleet-wildcat-722": 1012996, + "shocking-pika-906": 1012998, + "tidy-tapir-37": 1013000, + "silent-duck-69": 1013000, + "next-llama-610": 1013001, + "quaint-finch-276": 1013002, + "reminiscent-seahorse-583": 1013003, + "cheery-fly-844": 1013005, + "formal-hedgehog-340": 1013005, + "flexible-monitor-162": 1013007, + "enduring-meerkat-933": 1013009, + "flippant-snail-182": 1013012, + "giant-scorpion-587": 1013013, + "terrific-aardvark-499": 1013018, + "cheery-ermine-76": 1013019, + "watchful-elephant-904": 1013026, + "adept-chameleon-720": 1013029, + "valuable-dodo-387": 1013029, + "valiant-gnu-440": 1013031, + "astute-impala-495": 1013033, + "doting-swordfish-773": 1013040, + "oceanic-salamander-218": 1013041, + "patient-curlew-119": 1013042, + "neat-beagle-540": 1013043, + "mellow-mandrill-939": 1013044, + "decisive-oyster-326": 1013045, + "kindred-otter-21": 1013046, + "admired-partridge-505": 1013047, + "hip-cat-211": 1013048, + "clean-quail-614": 1013049, + "outstanding-reindeer-505": 1013050, + "qualified-marlin-117": 1013051, + "qualified-trout-368": 1013052, + "cheery-whale-169": 1013053, + "deafening-cormorant-465": 1013054, + "fast-alligator-911": 1013055, + "proficient-mallard-811": 1013056, + "ardent-perch-925": 1013057, + "exuberant-rhinoceros-810": 1013059, + "cool-blackbird-201": 1013060, + "next-owl-307": 1013063, + "affable-frog-875": 1013064, + "jovial-crow-913": 1013064, + "ardent-mastiff-837": 1013065, + "content-weasel-429": 1013067, + "precious-toad-329": 1013070, + "robust-ibex-147": 1013071, + "patient-beagle-129": 1013075, + "focused-bison-292": 1013076, + "posh-marlin-168": 1013079, + "jovial-jellyfish-819": 1013080, + "small-mastiff-112": 1013081, + "hidden-goldfish-875": 1013082, + "steady-goshawk-369": 1013083, + "hushed-mosquito-586": 1013084, + "capable-donkey-53": 1013085, + "joyous-cormorant-711": 1013086, + "polished-ibis-952": 1013087, + "polite-axolotl-430": 1013088, + "gallant-vulture-957": 1013089, + "rightful-shrimp-128": 1013090, + "grand-lion-516": 1013091, + "beloved-squid-525": 1013092, + "adamant-vole-277": 1013093, + "canny-rhinoceros-631": 1013094, + "grandiose-anteater-532": 1013095, + "adamant-mink-242": 1013096, + "kindly-ant-629": 1013097, + "curious-falcon-657": 1013098, + "sleek-caribou-392": 1013099, + "canny-pika-799": 1013100, + "expert-starfish-502": 1013104, + "fearless-schnauzer-717": 1013105, + "beaming-ibis-496": 1013106, + "stoic-gnat-703": 1013107, + "enchanted-tern-570": 1013110, + "woozy-ladybug-665": 1013112, + "agile-tern-667": 1013113, + "warmhearted-goshawk-314": 1013114, + "blissful-oriole-672": 1013116, + "spotted-walrus-412": 1013117, + "warmhearted-caribou-401": 1013123, + "prestigious-manatee-674": 1013124, + "stoic-chicken-304": 1013125, + "clever-ibex-587": 1013126, + "exciting-anteater-240": 1013132, + "kindred-hummingbird-807": 1013133, + "scintillating-turtle-553": 1013134, + "elated-sardine-568": 1013136, + "lovable-opossum-388": 1013148, + "prestigious-deer-841": 1013150, + "decisive-alligator-960": 1013151, + "honorable-snail-292": 1013153, + "sensible-anaconda-568": 1013160, + "terrific-eagle-835": 1013161, + "sleek-sandpiper-393": 1013164, + "prestigious-basilisk-308": 1013165, + "helpful-mallard-841": 1013166, + "precise-capybara-716": 1013166, + "savory-hippopotamus-933": 1013167, + "tame-hawk-205": 1013169, + "bright-hound-306": 1013170, + "adept-chinchilla-436": 1013170, + "little-sardine-573": 1013179, + "fantastic-quail-175": 1013180, + "amiable-goose-541": 1013180, + "sincere-peacock-375": 1013181, + "hip-civet-426": 1013182, + "aware-butterfly-313": 1013182, + "knowing-trout-419": 1013185, + "fast-beagle-243": 1013187, + "loyal-octopus-657": 1013188, + "friendly-crane-411": 1013190, + "compassionate-cat-309": 1013200, + "earnest-buzzard-904": 1013201, + "rapid-hummingbird-508": 1013203, + "merry-peccary-585": 1013204, + "elegant-koala-319": 1013206, + "earnest-eel-52": 1013207, + "wooden-cheetah-183": 1013207, + "hallowed-iguana-713": 1013214, + "descriptive-caterpillar-665": 1013215, + "robust-duck-867": 1013220, + "combative-mouse-658": 1013221, + "cool-ox-131": 1013224, + "usable-zebra-795": 1013225, + "judicious-leopard-733": 1013226, + "upbeat-dachshund-166": 1013226, + "whimsical-wombat-559": 1013228, + "admired-boar-264": 1013230, + "effervescent-lobster-837": 1013231, + "fortunate-bass-124": 1013232, + "handsome-egret-69": 1013233, + "useful-jackal-553": 1013235, + "stoic-raccoon-177": 1013237, + "stoic-cuttlefish-460": 1013242, + "calm-pelican-957": 1013243, + "grand-fish-614": 1013267, + "flippant-schnauzer-292": 1013268, + "admired-weasel-783": 1013269, + "warmhearted-camel-660": 1013271, + "secret-gnu-241": 1013273, + "disciplined-lynx-240": 1013274, + "utmost-ermine-560": 1013274, + "vivid-cormorant-368": 1013275, + "content-stork-681": 1013276, + "accomplished-tapir-243": 1013277, + "colorful-dragon-26": 1013278, + "judicious-albatross-661": 1013282, + "befitting-buffalo-719": 1013283, + "industrious-chameleon-628": 1013283, + "grandiose-buffalo-325": 1013297, + "descriptive-ant-157": 1013299, + "accurate-ocelot-443": 1013303, + "exciting-tiger-881": 1013304, + "disciplined-poodle-710": 1013307, + "agreeable-goose-441": 1013309, + "benevolent-whale-958": 1013315, + "quixotic-antelope-937": 1013317, + "veracious-viper-656": 1013317, + "tame-wolf-659": 1013319, + "benevolent-gnu-681": 1013320, + "effervescent-rook-404": 1013327, + "fabulous-ladybug-887": 1013328, + "patient-mink-479": 1013329, + "shocking-kingfisher-772": 1013330, + "graceful-platypus-935": 1013330, + "judicious-toucan-502": 1013331, + "dusty-mule-113": 1013334, + "pleasant-avocet-206": 1013335, + "rare-rook-764": 1013339, + "tangible-axolotl-263": 1013345, + "hushed-ferret-928": 1013346, + "descriptive-curlew-376": 1013351, + "quixotic-peccary-395": 1013352, + "accurate-mammoth-322": 1013365, + "neat-warthog-239": 1013367, + "famous-malamute-308": 1013368, + "quiet-bear-732": 1013370, + "keen-hound-411": 1013371, + "zany-wildcat-260": 1013372, + "peaceful-mink-553": 1013379, + "honorable-retriever-326": 1013380, + "original-llama-716": 1013383, + "shiny-echidna-753": 1013384, + "descriptive-lemur-699": 1013385, + "hardy-gnat-959": 1013386, + "accomplished-sardine-881": 1013387, + "spotted-boar-25": 1013388, + "bright-yak-515": 1013398, + "quick-parrot-24": 1013399, + "peaceful-bandicoot-628": 1013418, + "agreeable-boar-295": 1013419, + "perfect-rook-400": 1013432, + "brainy-whale-182": 1013433, + "dashing-horse-974": 1013438, + "merry-manatee-803": 1013439, + "hardy-cassowary-681": 1013440, + "kindly-squid-347": 1013441, + "energetic-firefly-493": 1013443, + "bold-curlew-187": 1013444, + "efficient-wildcat-628": 1013450, + "steady-dotterel-120": 1013451, + "hushed-raven-121": 1013459, + "adventurous-squirrel-776": 1013460, + "grandiose-chipmunk-923": 1013487, + "incredible-labrador-454": 1013488, + "youthful-owl-399": 1013488, + "gallant-ibis-257": 1013502, + "neighborly-minnow-204": 1013503, + "resolute-ox-611": 1013504, + "flippant-turtle-277": 1013505, + "festive-kangaroo-102": 1013509, + "astute-mastiff-330": 1013510, + "affable-setter-360": 1013510, + "exciting-dotterel-198": 1013512, + "clever-lemming-517": 1013515, + "terrific-pony-617": 1013516, + "fabulous-seal-334": 1013522, + "prestigious-nightingale-394": 1013523, + "beloved-hummingbird-692": 1013525, + "harmless-puma-435": 1013526, + "famous-chameleon-438": 1013529, + "vivid-octopus-198": 1013530, + "helpful-rook-184": 1013532, + "calculating-salmon-788": 1013533, + "adventurous-gazelle-449": 1013565, + "zealous-rook-79": 1013566, + "perceptive-rook-599": 1013566, + "agreeable-armadillo-996": 1013578, + "rugged-koala-690": 1013579, + "astute-goat-553": 1013580, + "accomplished-bird-743": 1013582, + "combative-scorpion-685": 1013591, + "abundant-lemur-195": 1013592, + "fine-lynx-827": 1013600, + "modest-skunk-501": 1013601, + "cool-kangaroo-432": 1013616, + "effervescent-ant-702": 1013617, + "savory-mongoose-366": 1013617, + "expert-roadrunner-363": 1013621, + "admired-ptarmigan-366": 1013622, + "accurate-frog-442": 1013622, + "optimistic-guanaco-821": 1013624, + "knowing-wolverine-44": 1013625, + "adjoining-wren-169": 1013625, + "rapid-chinchilla-602": 1013638, + "rare-cod-209": 1013639, + "quixotic-sturgeon-947": 1013642, + "dazzling-robin-920": 1013643, + "savory-retriever-25": 1013649, + "brazen-clam-691": 1013650, + "marvelous-barracuda-570": 1013655, + "kindred-eel-881": 1013656, + "canny-pika-910": 1013671, + "rightful-mule-242": 1013672, + "fastidious-oriole-386": 1013680, + "utmost-wombat-578": 1013681, + "beaming-crab-645": 1013686, + "wandering-duck-949": 1013687, + "impartial-ram-916": 1013688, + "dynamic-sockeye-642": 1013689, + "dashing-tortoise-260": 1013695, + "grand-lemming-527": 1013697, + "secret-loris-516": 1013701, + "proficient-axolotl-441": 1013703, + "unique-snail-500": 1013704, + "hardy-starfish-118": 1013705, + "hearty-fox-684": 1013709, + "academic-shepherd-996": 1013710, + "amiable-minnow-565": 1013710, + "coordinated-bear-845": 1013713, + "beaming-chickadee-540": 1013714, + "expert-kingfisher-906": 1013718, + "calm-crow-49": 1013719, + "expert-bulldog-270": 1013731, + "adorable-goshawk-593": 1013733, + "benevolent-fennec-103": 1013734, + "strong-buffalo-501": 1013735, + "youthful-marten-259": 1013736, + "valiant-gnu-646": 1013737, + "marvelous-panther-276": 1013741, + "adorable-dalmatian-736": 1013742, + "strong-armadillo-651": 1013744, + "colorful-squid-309": 1013745, + "impressive-finch-199": 1013748, + "polite-zebra-924": 1013749, + "accurate-corgi-395": 1013759, + "qualified-wolverine-486": 1013760, + "precious-basilisk-691": 1013761, + "trustworthy-cricket-408": 1013762, + "formal-butterfly-483": 1013770, + "dashing-rooster-346": 1013771, + "glorious-camel-30": 1013782, + "cheery-goshawk-270": 1013783, + "groovy-cod-37": 1013785, + "dapper-marten-201": 1013786, + "trustworthy-rabbit-635": 1013786, + "clear-terrier-782": 1013797, + "qualified-chameleon-477": 1013798, + "elated-bass-221": 1013801, + "prestigious-corgi-489": 1013803, + "necessary-starfish-807": 1013805, + "blessed-terrier-450": 1013806, + "coordinated-shepherd-193": 1013809, + "savory-lark-992": 1013810, + "tacit-warthog-316": 1013826, + "artful-parakeet-624": 1013827, + "pastel-starfish-352": 1013833, + "healthy-kookabura-193": 1013834, + "efficient-bloodhound-201": 1013835, + "knowing-mallard-442": 1013836, + "harmless-oriole-726": 1013837, + "utmost-starling-544": 1013838, + "striped-lapwing-521": 1013845, + "calm-horse-143": 1013846, + "proficient-penguin-433": 1013848, + "utmost-corgi-482": 1013849, + "curious-ant-714": 1013850, + "efficient-tapir-566": 1013851, + "silent-gecko-805": 1013852, + "energetic-alpaca-272": 1013854, + "opulent-newt-322": 1013855, + "healthy-setter-817": 1013857, + "elegant-cobra-384": 1013858, + "proper-deer-730": 1013859, + "ardent-crab-342": 1013883, + "tame-stork-63": 1013884, + "enchanted-clam-74": 1013884, + "determined-fish-644": 1013895, + "animated-axolotl-325": 1013896, + "chatty-lark-633": 1013902, + "reminiscent-bobcat-133": 1013903, + "earnest-crocodile-231": 1013905, + "lovely-crow-291": 1013906, + "effervescent-cormorant-766": 1013907, + "outstanding-rabbit-681": 1013908, + "deafening-chinchilla-544": 1013908, + "limitless-trout-375": 1013911, + "fine-fennec-778": 1013912, + "capable-horse-497": 1013915, + "cool-gopher-518": 1013916, + "agreeable-stoat-635": 1013927, + "hardy-peccary-697": 1013928, + "accomplished-malamute-750": 1013933, + "chatty-hummingbird-590": 1013934, + "valiant-duck-473": 1013957, + "resolute-mole-655": 1013959, + "deafening-oyster-366": 1013964, + "different-impala-517": 1013965, + "groovy-ibex-255": 1013967, + "fabulous-wolverine-145": 1013968, + "different-kingfisher-159": 1013974, + "rosy-ocelot-536": 1013976, + "scrupulous-grouse-415": 1013979, + "earnest-meadowlark-972": 1013980, + "standing-bird-670": 1013985, + "necessary-gull-337": 1013988, + "brilliant-butterfly-469": 1013990, + "veracious-vole-530": 1013991, + "acoustic-penguin-535": 1013996, + "diligent-ibis-92": 1013997, + "animated-orca-671": 1013999, + "decisive-peacock-47": 1014000, + "polite-crocodile-524": 1014000, + "formal-lion-362": 1014001, + "wandering-lobster-453": 1014002, + "wandering-vulture-996": 1014002, + "spotted-puffin-688": 1014007, + "jovial-impala-760": 1014008, + "flippant-cat-555": 1014018, + "dashing-toucan-763": 1014020, + "adorable-viper-712": 1014024, + "aromatic-falcon-104": 1014026, + "aware-stoat-742": 1014026, + "curious-spider-453": 1014032, + "zany-bullfrog-745": 1014034, + "necessary-dog-408": 1014043, + "patient-tapir-642": 1014044, + "gallant-albatross-108": 1014047, + "outgoing-beagle-666": 1014048, + "acrobatic-manatee-621": 1014058, + "aromatic-marmot-955": 1014061, + "enchanted-wombat-603": 1014061, + "disciplined-cat-103": 1014064, + "pastel-otter-475": 1014066, + "tangible-lynx-268": 1014072, + "limitless-sheep-341": 1014073, + "compassionate-porcupine-27": 1014087, + "mellow-whale-116": 1014088, + "affable-salmon-854": 1014088, + "fearless-kookabura-409": 1014097, + "dapper-viper-823": 1014098, + "tremendous-alpaca-936": 1014107, + "blissful-roadrunner-966": 1014108, + "silent-warbler-940": 1014108, + "fortunate-bullfrog-333": 1014111, + "successful-grasshopper-369": 1014112, + "giant-rabbit-720": 1014115, + "dapper-goldfish-509": 1014116, + "upbeat-crow-163": 1014139, + "calm-parrot-688": 1014140, + "aware-crane-139": 1014140, + "tangible-rat-940": 1014142, + "dashing-cardinal-517": 1014143, + "earnest-lapwing-949": 1014145, + "good-walrus-401": 1014146, + "precious-tortoise-740": 1014161, + "upbeat-hare-476": 1014162, + "glorious-horse-677": 1014162, + "amiable-civet-36": 1014167, + "agreeable-crab-754": 1014168, + "zealous-pigeon-392": 1014195, + "tame-bulldog-810": 1014197, + "frugal-basilisk-593": 1014208, + "astute-partridge-285": 1014209, + "giant-shark-838": 1014228, + "ardent-swan-930": 1014229, + "blissful-porpoise-345": 1014234, + "energetic-otter-470": 1014235, + "lovely-wren-298": 1014248, + "grandiose-meerkat-546": 1014250, + "insightful-fox-496": 1014251, + "prestigious-mammoth-960": 1014253, + "industrious-viper-186": 1014267, + "exciting-tiger-46": 1014269, + "rosy-woodpecker-79": 1014270, + "calculating-goshawk-885": 1014272, + "optimistic-clownfish-83": 1014281, + "vivid-lyrebird-437": 1014284, + "proficient-puma-636": 1014288, + "exciting-porcupine-790": 1014291, + "rapid-cod-783": 1014292, + "mild-stingray-766": 1014293, + "limitless-caribou-416": 1014297, + "utmost-flamingo-812": 1014298, + "accurate-chickadee-154": 1014307, + "dusty-boar-839": 1014308, + "dazzling-sandpiper-476": 1014308, + "lovable-woodpecker-747": 1014326, + "colorful-blackbird-688": 1014327, + "deafening-hyena-100": 1014332, + "enchanted-tortoise-122": 1014332, + "wonderful-cormorant-733": 1014334, + "polished-hippopotamus-789": 1014340, + "valuable-ostrich-947": 1014342, + "standing-parrot-302": 1014342, + "proper-rook-278": 1014346, + "healthy-dachshund-743": 1014347, + "giddy-terrier-977": 1014377, + "groovy-guineapig-523": 1014378, + "enduring-impala-123": 1014390, + "clever-marmot-234": 1014391, + "elated-jay-259": 1014392, + "joyous-beagle-749": 1014393, + "oceanic-falcon-839": 1014399, + "incredible-egret-917": 1014399, + "adventurous-herring-210": 1014401, + "calculating-hummingbird-221": 1014404, + "diligent-impala-317": 1014405, + "basic-grasshopper-367": 1014405, + "necessary-scorpion-809": 1014422, + "chatty-horse-355": 1014424, + "small-marmot-466": 1014424, + "graceful-anteater-171": 1014443, + "usable-goose-260": 1014444, + "silent-civet-922": 1014446, + "youthful-antelope-138": 1014447, + "tangible-toad-439": 1014471, + "astute-jay-891": 1014473, + "accomplished-chickadee-414": 1014473, + "graceful-fox-263": 1014496, + "secret-chameleon-858": 1014497, + "wry-setter-414": 1014498, + "superb-lynx-778": 1014499, + "calculating-barracuda-75": 1014500, + "valuable-hedgehog-779": 1014500, + "rapid-bear-799": 1014501, + "aromatic-puffin-890": 1014509, + "artful-marlin-500": 1014510, + "content-octopus-710": 1014518, + "wary-platypus-685": 1014522, + "beaming-impala-393": 1014522, + "good-gnat-638": 1014565, + "outstanding-platypus-454": 1014567, + "judicious-spaniel-774": 1014575, + "reminiscent-sockeye-928": 1014576, + "industrious-viper-537": 1014578, + "quick-spider-119": 1014579, + "third-condor-144": 1014585, + "brilliant-lynx-575": 1014588, + "vibrant-kingfisher-252": 1014598, + "glorious-shrimp-593": 1014600, + "jovial-goose-546": 1014605, + "helpful-butterfly-309": 1014608, + "graceful-schnauzer-298": 1014608, + "curious-wombat-530": 1014618, + "rare-raccoon-898": 1014620, + "formal-chinchilla-272": 1014634, + "different-wolf-590": 1014637, + "dynamic-pig-962": 1014637, + "resilient-mouse-584": 1014650, + "outgoing-dotterel-907": 1014651, + "animated-giraffe-772": 1014651, + "little-pig-300": 1014683, + "graceful-badger-860": 1014685, + "aware-horse-251": 1014688, + "aromatic-owl-964": 1014690, + "focused-herring-187": 1014692, + "optimistic-malamute-376": 1014694, + "quixotic-pigeon-102": 1014695, + "cheery-jay-92": 1014696, + "elated-dodo-114": 1014703, + "groovy-butterfly-632": 1014705, + "energetic-llama-342": 1014715, + "brainy-dog-391": 1014716, + "academic-jaguar-721": 1014716, + "fine-ferret-554": 1014723, + "hardy-goshawk-819": 1014724, + "clear-bloodhound-727": 1014732, + "disciplined-hound-90": 1014733, + "veracious-scorpion-767": 1014734, + "ideal-bullfrog-364": 1014735, + "neat-tapir-107": 1014754, + "agile-perch-619": 1014755, + "doting-whale-523": 1014755, + "quirky-deer-144": 1014758, + "adept-trout-731": 1014762, + "utmost-caribou-333": 1014773, + "focused-barracuda-312": 1014775, + "fleet-ibex-182": 1014784, + "kindly-duck-896": 1014785, + "hearty-sockeye-103": 1014792, + "gregarious-echidna-365": 1014793, + "astute-coyote-479": 1014804, + "brainy-bat-872": 1014805, + "secret-ibex-133": 1014817, + "vivid-cormorant-276": 1014819, + "tacit-parakeet-493": 1014829, + "disciplined-civet-78": 1014830, + "judicious-warbler-72": 1014838, + "fine-moose-182": 1014839, + "decisive-capybara-688": 1014840, + "hip-salamander-553": 1014841, + "mild-crab-943": 1014863, + "brave-hawk-272": 1014864, + "descriptive-whale-673": 1014865, + "quixotic-walrus-508": 1014866, + "enduring-bass-454": 1014874, + "agile-butterfly-247": 1014875, + "rapid-hedgehog-741": 1014876, + "capable-cormorant-92": 1014877, + "lovable-iguana-988": 1014883, + "sleek-salmon-225": 1014884, + "strong-dalmatian-30": 1014889, + "determined-gazelle-769": 1014892, + "wooden-fly-512": 1014906, + "healthy-stingray-22": 1014907, + "neat-sandpiper-239": 1014931, + "pleasant-civet-540": 1014932, + "confident-basilisk-260": 1014958, + "stoic-owl-441": 1014959, + "insightful-viper-99": 1014971, + "quixotic-wren-44": 1014972, + "flexible-stork-914": 1014972, + "tangible-deer-309": 1014975, + "neat-parrot-530": 1014977, + "marvelous-octopus-965": 1014979, + "striped-shark-208": 1014980, + "adamant-dogfish-494": 1014981, + "wooden-fly-384": 1014984, + "limitless-oyster-635": 1015003, + "adventurous-sturgeon-458": 1015004, + "colorless-turtle-801": 1015018, + "elegant-salamander-887": 1015021, + "doting-whale-52": 1015025, + "proficient-dogfish-17": 1015026, + "pleasant-jackal-302": 1015027, + "quixotic-spaniel-528": 1015029, + "tacit-spoonbill-75": 1015029, + "blissful-guineapig-484": 1015034, + "friendly-peccary-683": 1015035, + "grand-zebra-840": 1015039, + "focused-cassowary-578": 1015040, + "neat-dachshund-34": 1015040, + "ardent-schnauzer-606": 1015044, + "exuberant-warthog-741": 1015046, + "greedy-dodo-982": 1015048, + "rapid-pigeon-863": 1015049, + "glorious-parrot-225": 1015049, + "spotted-fox-982": 1015059, + "compassionate-mallard-932": 1015060, + "dutiful-rooster-651": 1015068, + "energetic-hyena-892": 1015070, + "agreeable-mammoth-570": 1015072, + "mild-terrier-972": 1015073, + "dashing-lynx-455": 1015073, + "notable-robin-425": 1015077, + "neat-rabbit-657": 1015078, + "gregarious-octopus-946": 1015078, + "resilient-rat-737": 1015083, + "brave-avocet-224": 1015084, + "rugged-meadowlark-884": 1015088, + "secret-leopard-766": 1015089, + "precious-hornet-174": 1015107, + "dutiful-poodle-676": 1015108, + "abundant-butterfly-424": 1015108, + "little-mink-763": 1015111, + "clean-crab-401": 1015112, + "descriptive-ostrich-699": 1015117, + "agile-hummingbird-386": 1015118, + "dapper-hawk-52": 1015118, + "doting-rabbit-561": 1015125, + "formal-warthog-696": 1015126, + "avid-labrador-762": 1015139, + "tough-robin-769": 1015140, + "giddy-capybara-443": 1015140, + "rugged-salmon-942": 1015158, + "blessed-sheep-17": 1015159, + "academic-frog-772": 1015182, + "colorful-clam-145": 1015185, + "beloved-grouse-378": 1015202, + "bold-raccoon-119": 1015203, + "coordinated-hyena-624": 1015213, + "brilliant-bison-974": 1015214, + "reminiscent-badger-982": 1015222, + "vibrant-wildcat-767": 1015224, + "tacit-owl-758": 1015225, + "sleek-sturgeon-550": 1015226, + "precise-ram-539": 1015227, + "strong-wildcat-706": 1015228, + "striped-sandpiper-161": 1015233, + "giant-chicken-738": 1015234, + "exciting-ladybug-261": 1015242, + "avid-herring-611": 1015243, + "shocking-gull-119": 1015247, + "exciting-badger-670": 1015248, + "shocking-clownfish-693": 1015249, + "spotted-lapwing-214": 1015250, + "elated-roadrunner-361": 1015251, + "useful-dachshund-516": 1015254, + "striped-husky-941": 1015262, + "wandering-newt-488": 1015263, + "tame-ermine-948": 1015268, + "quiet-corgi-86": 1015269, + "cool-bee-544": 1015270, + "handsome-anteater-303": 1015271, + "resilient-reindeer-8": 1015276, + "shiny-alpaca-515": 1015277, + "basic-emu-498": 1015291, + "incredible-ptarmigan-311": 1015292, + "opulent-alligator-795": 1015310, + "valuable-hawk-39": 1015312, + "terrific-hound-71": 1015312, + "steady-goat-507": 1015324, + "tremendous-partridge-703": 1015326, + "posh-armadillo-323": 1015326, + "outstanding-roadrunner-445": 1015346, + "fast-gazelle-754": 1015348, + "vivid-puma-558": 1015354, + "giant-ox-864": 1015357, + "brainy-antelope-907": 1015359, + "fantastic-magpie-967": 1015360, + "different-mongoose-203": 1015361, + "blessed-tortoise-748": 1015362, + "tangible-hound-592": 1015364, + "shocking-perch-942": 1015365, + "groovy-hyena-863": 1015369, + "aware-hedgehog-185": 1015370, + "accurate-mouse-516": 1015375, + "woozy-loris-561": 1015376, + "impartial-warthog-35": 1015376, + "enduring-albatross-791": 1015386, + "adept-otter-242": 1015387, + "exuberant-lapwing-901": 1015407, + "fortunate-swan-215": 1015408, + "prestigious-mockingbird-765": 1015408, + "reminiscent-bobcat-882": 1015412, + "fortunate-tern-521": 1015413, + "pastel-sockeye-272": 1015413, + "hushed-bat-829": 1015422, + "chatty-buzzard-927": 1015424, + "grandiose-wombat-252": 1015436, + "exciting-gnu-762": 1015438, + "youthful-starfish-946": 1015446, + "artful-caribou-738": 1015447, + "youthful-hound-107": 1015459, + "marvelous-lemur-282": 1015460, + "calm-chameleon-439": 1015463, + "quaint-seahorse-225": 1015464, + "artful-gazelle-108": 1015472, + "rightful-emu-13": 1015474, + "small-bloodhound-89": 1015482, + "colorless-dog-161": 1015483, + "canny-moose-904": 1015484, + "astute-warthog-608": 1015485, + "befitting-spaniel-687": 1015485, + "elated-skunk-226": 1015490, + "trustworthy-giraffe-885": 1015494, + "clear-oriole-41": 1015498, + "nautical-leopard-828": 1015499, + "honorable-panda-152": 1015502, + "kindhearted-toad-129": 1015504, + "watchful-flamingo-763": 1015504, + "ceaseless-zebra-898": 1015511, + "kindly-cod-6": 1015514, + "energized-fennec-595": 1015515, + "stoic-iguana-413": 1015515, + "admired-setter-403": 1015516, + "rapid-anaconda-956": 1015526, + "jovial-clownfish-160": 1015527, + "diligent-gnat-654": 1015528, + "fearless-kudu-906": 1015531, + "harmless-jellyfish-300": 1015532, + "content-buzzard-97": 1015532, + "fiery-meerkat-694": 1015535, + "effervescent-bird-857": 1015549, + "clean-salamander-221": 1015550, + "fantastic-minnow-913": 1015552, + "brainy-meerkat-641": 1015553, + "shocking-guanaco-970": 1015561, + "reminiscent-stingray-816": 1015563, + "neighborly-perch-175": 1015564, + "determined-herring-372": 1015566, + "usable-hyena-831": 1015568, + "deafening-wildcat-591": 1015569, + "quixotic-clownfish-576": 1015569, + "necessary-gazelle-643": 1015570, + "dutiful-gerbil-686": 1015573, + "courteous-stingray-868": 1015580, + "confident-canary-953": 1015581, + "uncommon-robin-95": 1015587, + "vibrant-echidna-265": 1015589, + "quirky-lobster-366": 1015608, + "colorless-jaguar-426": 1015609, + "hearty-gull-872": 1015610, + "judicious-seal-265": 1015613, + "successful-gnu-303": 1015614, + "lovely-pigeon-859": 1015615, + "brazen-greyhound-237": 1015619, + "clear-buffalo-715": 1015620, + "marvelous-snail-117": 1015621, + "kindly-stingray-62": 1015621, + "graceful-mallard-904": 1015622, + "superb-ostrich-690": 1015622, + "handsome-poodle-495": 1015624, + "optimistic-antelope-764": 1015626, + "glad-mule-389": 1015641, + "little-condor-653": 1015643, + "glad-cormorant-502": 1015652, + "insightful-chicken-392": 1015654, + "glad-chihuahua-546": 1015655, + "adorable-avocet-853": 1015657, + "prestigious-lapwing-630": 1015670, + "basic-goose-419": 1015671, + "vivid-caribou-501": 1015677, + "brave-goldfinch-686": 1015678, + "adept-shark-504": 1015690, + "patient-dog-228": 1015692, + "academic-dotterel-695": 1015704, + "trustworthy-dogfish-614": 1015705, + "notable-fish-18": 1015705, + "neighborly-marlin-366": 1015727, + "valiant-tern-394": 1015730, + "hallowed-chinchilla-991": 1015730, + "exciting-pig-273": 1015735, + "grand-jay-304": 1015737, + "enduring-hawk-724": 1015737, + "exuberant-goshawk-61": 1015749, + "youthful-cheetah-591": 1015750, + "confident-raven-614": 1015757, + "friendly-shepherd-362": 1015758, + "agreeable-dotterel-96": 1015763, + "fabulous-sockeye-915": 1015764, + "cheery-fish-998": 1015766, + "joyous-rabbit-23": 1015766, + "effervescent-civet-968": 1015768, + "necessary-crane-983": 1015781, + "giddy-raccoon-326": 1015782, + "curious-armadillo-870": 1015784, + "befitting-cassowary-987": 1015784, + "knowing-mink-588": 1015785, + "famous-chicken-943": 1015786, + "blissful-puffin-47": 1015787, + "quirky-goldfish-797": 1015798, + "veracious-bee-51": 1015799, + "brilliant-cat-301": 1015801, + "coordinated-falcon-300": 1015805, + "sensible-giraffe-79": 1015810, + "brilliant-crab-646": 1015812, + "canny-echidna-571": 1015816, + "tremendous-oyster-28": 1015817, + "useful-hedgehog-16": 1015818, + "notable-goshawk-660": 1015819, + "exuberant-ostrich-982": 1015820, + "befitting-bobcat-906": 1015822, + "quiet-finch-120": 1015839, + "insightful-wombat-359": 1015843, + "fastidious-mule-428": 1015862, + "steady-peacock-582": 1015864, + "confident-beagle-988": 1015868, + "coordinated-lemming-827": 1015871, + "tacit-nightingale-141": 1015885, + "moonlit-orca-293": 1015888, + "judicious-sockeye-613": 1015894, + "tough-viper-299": 1015898, + "animated-toad-619": 1015912, + "lovable-basilisk-943": 1015915, + "sensible-lemur-75": 1015933, + "wonderful-bison-191": 1015933, + "amiable-goose-53": 1015935, + "lovable-ladybug-962": 1015973, + "zany-caiman-808": 1015975, + "dynamic-corgi-889": 1015993, + "blissful-sandpiper-322": 1015995, + "greedy-curlew-867": 1016003, + "neat-terrier-37": 1016004, + "first-grouse-422": 1016009, + "opulent-goose-813": 1016011, + "quick-goose-401": 1016022, + "expert-wildcat-617": 1016024, + "blessed-porpoise-809": 1016024, + "moonlit-duck-767": 1016028, + "vivid-ocelot-273": 1016029, + "descriptive-marmot-509": 1016038, + "fast-jay-228": 1016039, + "opulent-lapwing-599": 1016040, + "giddy-guanaco-127": 1016041, + "shiny-frog-257": 1016048, + "friendly-alpaca-134": 1016049, + "hallowed-bobcat-393": 1016049, + "valuable-dinosaur-332": 1016088, + "fantastic-barracuda-239": 1016092, + "adorable-toucan-561": 1016103, + "modest-bear-455": 1016104, + "fortunate-labrador-988": 1016126, + "benevolent-sheep-451": 1016127, + "perfect-guanaco-838": 1016127, + "honorable-kangaroo-818": 1016130, + "sensible-ferret-370": 1016131, + "usable-sandpiper-806": 1016153, + "helpful-clownfish-850": 1016154, + "whimsical-alpaca-29": 1016158, + "tame-ram-308": 1016160, + "kindred-caribou-113": 1016168, + "admired-parrot-807": 1016169, + "decisive-ocelot-569": 1016191, + "precious-sardine-219": 1016196, + "striped-scorpion-465": 1016201, + "silent-hippopotamus-770": 1016205, + "perfect-walrus-955": 1016211, + "amicable-blackbird-107": 1016212, + "sincere-loris-921": 1016216, + "curious-gull-952": 1016219, + "gregarious-jellyfish-72": 1016223, + "small-marten-854": 1016224, + "shiny-koala-730": 1016235, + "compassionate-starling-60": 1016236, + "fantastic-stork-266": 1016240, + "hushed-dalmatian-771": 1016241, + "blissful-mongoose-292": 1016246, + "peaceful-cow-85": 1016247, + "adamant-heron-739": 1016311, + "rare-zebra-861": 1016314, + "giant-labrador-519": 1016329, + "reliable-penguin-488": 1016330, + "kindly-curlew-709": 1016355, + "amicable-tortoise-718": 1016357, + "doting-bulldog-382": 1016357, + "brainy-iguana-615": 1016365, + "prestigious-octopus-170": 1016367, + "sensible-chameleon-294": 1016403, + "hallowed-spaniel-599": 1016407, + "loyal-marten-520": 1016407, + "insightful-tern-270": 1016412, + "dynamic-lemming-411": 1016414, + "clear-jaguar-257": 1016436, + "flexible-peacock-480": 1016436, + "giddy-bee-869": 1016438, + "quick-okapi-262": 1016462, + "groovy-goat-689": 1016464, + "giant-dachshund-22": 1016473, + "keen-avocet-6": 1016474, + "mild-aardvark-470": 1016483, + "woozy-vole-486": 1016484, + "canny-snake-306": 1016499, + "perfect-fennec-696": 1016501, + "fiery-mammoth-690": 1016554, + "cautious-chihuahua-162": 1016555, + "small-lyrebird-323": 1016571, + "precious-anteater-175": 1016572, + "cautious-starling-677": 1016585, + "artful-beagle-587": 1016586, + "dependable-quail-601": 1016586, + "quixotic-cuttlefish-973": 1016588, + "veracious-sheep-317": 1016589, + "grateful-goldfish-832": 1016592, + "blissful-rat-560": 1016593, + "wry-sardine-298": 1016611, + "dependable-hornet-39": 1016612, + "canny-emu-301": 1016612, + "perceptive-porcupine-101": 1016620, + "valuable-akita-204": 1016621, + "deafening-bobcat-72": 1016622, + "little-dodo-596": 1016624, + "famous-nightingale-726": 1016646, + "giant-wombat-855": 1016648, + "cautious-peccary-895": 1016659, + "proficient-bullfrog-65": 1016661, + "polished-lyrebird-698": 1016668, + "energetic-cheetah-128": 1016670, + "good-basilisk-523": 1016689, + "outgoing-wolf-316": 1016689, + "rugged-trout-216": 1016690, + "graceful-panda-765": 1016691, + "modest-sparrow-706": 1016692, + "elegant-swan-108": 1016694, + "hidden-herring-193": 1016695, + "clear-mandrill-766": 1016697, + "abundant-guineapig-222": 1016698, + "laudable-wildcat-430": 1016701, + "uncommon-opossum-707": 1016704, + "neighborly-sandpiper-157": 1016714, + "superb-barracuda-306": 1016715, + "hushed-dinosaur-223": 1016717, + "outstanding-seahorse-73": 1016718, + "disciplined-husky-75": 1016719, + "groovy-wolverine-584": 1016720, + "cool-schnauzer-217": 1016721, + "trustworthy-giraffe-51": 1016722, + "stoic-goose-76": 1016728, + "oceanic-dogfish-772": 1016729, + "striped-shrimp-467": 1016738, + "calculating-herring-893": 1016739, + "quixotic-canary-43": 1016746, + "artful-falcon-972": 1016747, + "mild-bass-968": 1016789, + "chatty-seal-938": 1016790, + "lovely-panda-137": 1016790, + "exuberant-swan-737": 1016793, + "sincere-fly-219": 1016794, + "calm-whale-666": 1016802, + "canny-kudu-448": 1016803, + "different-warthog-356": 1016816, + "proficient-fox-493": 1016817, + "prestigious-mongoose-534": 1016818, + "enduring-magpie-431": 1016818, + "spotted-gerbil-999": 1016819, + "quirky-gnat-966": 1016819, + "canny-meadowlark-203": 1016824, + "cheerful-greyhound-446": 1016825, + "beaming-goshawk-440": 1016833, + "pleasant-parrot-403": 1016834, + "silent-goose-727": 1016846, + "calm-llama-602": 1016847, + "lovely-toad-67": 1016847, + "dutiful-donkey-609": 1016867, + "rugged-hedgehog-19": 1016868, + "cheerful-egret-37": 1016870, + "mild-husky-218": 1016871, + "lovely-kingfisher-116": 1016871, + "superb-sheep-105": 1016872, + "dapper-warthog-797": 1016873, + "precious-seal-162": 1016876, + "compassionate-rabbit-853": 1016878, + "neat-mole-780": 1016913, + "brazen-pika-736": 1016915, + "hip-snail-550": 1016918, + "effervescent-elephant-883": 1016919, + "limitless-barracuda-187": 1016922, + "posh-barracuda-825": 1016923, + "calculating-flamingo-972": 1016928, + "small-pig-152": 1016929, + "curious-lemur-290": 1016930, + "handsome-weasel-343": 1016931, + "shiny-sturgeon-174": 1016931, + "artful-kangaroo-917": 1016932, + "cheerful-mockingbird-694": 1016933, + "rightful-condor-941": 1016937, + "pastel-moose-927": 1016938, + "hidden-okapi-15": 1016939, + "brainy-peacock-843": 1016940, + "graceful-bird-876": 1016942, + "nautical-monitor-440": 1016943, + "tangible-guanaco-920": 1016953, + "neat-seahorse-774": 1016954, + "perceptive-bulldog-671": 1016954, + "pastel-lapwing-156": 1016965, + "vivid-setter-512": 1016966, + "quiet-woodpecker-291": 1016966, + "adventurous-iguana-568": 1016970, + "colorful-terrier-724": 1016972, + "disciplined-butterfly-405": 1016987, + "chatty-sturgeon-406": 1016988, + "abundant-cobra-468": 1016993, + "impressive-wildebeest-774": 1016994, + "wandering-orca-648": 1017027, + "knowing-camel-706": 1017028, + "energetic-lapwing-749": 1017033, + "adventurous-porcupine-600": 1017035, + "cheery-wildcat-128": 1017045, + "knowing-emu-432": 1017046, + "chatty-emu-320": 1017052, + "perfect-penguin-570": 1017054, + "grandiose-octopus-462": 1017055, + "strong-hornet-539": 1017057, + "posh-warbler-968": 1017057, + "agile-whale-971": 1017064, + "brilliant-hare-867": 1017065, + "amicable-ibis-957": 1017065, + "cheerful-cricket-450": 1017066, + "frugal-magpie-620": 1017067, + "amicable-snake-49": 1017067, + "grandiose-kiwi-188": 1017088, + "rightful-emu-771": 1017089, + "benevolent-seahorse-377": 1017099, + "tacit-cobra-991": 1017100, + "utmost-labrador-374": 1017101, + "coordinated-skunk-602": 1017102, + "cheerful-lapwing-740": 1017105, + "affable-goldfinch-993": 1017106, + "rosy-pigeon-697": 1017106, + "perfect-opossum-399": 1017116, + "merry-lyrebird-192": 1017119, + "small-newt-503": 1017119, + "calculating-alpaca-832": 1017147, + "oceanic-sandpiper-398": 1017148, + "mild-hummingbird-285": 1017156, + "exciting-octopus-403": 1017157, + "greedy-greyhound-889": 1017173, + "hidden-crocodile-88": 1017174, + "warmhearted-wildebeest-415": 1017175, + "tough-gnu-783": 1017176, + "polite-ocelot-149": 1017217, + "patient-nightingale-319": 1017218, + "prestigious-otter-597": 1017229, + "scrupulous-husky-285": 1017230, + "brave-flamingo-806": 1017250, + "unique-gopher-224": 1017251, + "agile-chinchilla-77": 1017268, + "proficient-toucan-379": 1017269, + "aromatic-meerkat-212": 1017311, + "honorable-partridge-746": 1017315, + "curious-snail-227": 1017362, + "proper-cardinal-218": 1017363, + "superb-ptarmigan-954": 1017381, + "effervescent-lemming-912": 1017383, + "quirky-elk-495": 1017386, + "prestigious-rhinoceros-392": 1017387, + "frugal-narwhal-480": 1017399, + "enchanted-bison-4": 1017400, + "beaming-starfish-812": 1017408, + "pleasant-lynx-639": 1017409, + "merry-meerkat-4": 1017431, + "hallowed-guineapig-945": 1017432, + "doting-lyrebird-38": 1017443, + "gallant-mallard-488": 1017444, + "unique-lobster-194": 1017486, + "hearty-guanaco-903": 1017487, + "frugal-crab-28": 1017523, + "fortunate-fly-607": 1017525, + "uncommon-sardine-929": 1017548, + "capable-warthog-190": 1017549, + "resilient-ferret-37": 1017563, + "different-jaguar-912": 1017564, + "avid-guanaco-101": 1017566, + "agreeable-tapir-439": 1017567, + "successful-ocelot-968": 1017582, + "successful-okapi-472": 1017583, + "canny-reindeer-442": 1017601, + "marvelous-iguana-282": 1017602, + "knowing-raccoon-941": 1017613, + "first-badger-877": 1017614, + "sincere-hawk-484": 1017619, + "descriptive-boar-723": 1017620, + "zany-boar-825": 1017632, + "friendly-jay-564": 1017634, + "astute-ermine-485": 1017634, + "keen-bobcat-450": 1017649, + "polite-civet-719": 1017651, + "notable-rook-975": 1017673, + "resilient-jaguar-912": 1017674, + "resilient-mallard-207": 1017699, + "little-fish-223": 1017700, + "groovy-lemming-492": 1017718, + "industrious-anteater-572": 1017719, + "scintillating-penguin-242": 1017723, + "flexible-schnauzer-740": 1017724, + "tidy-kangaroo-201": 1017730, + "zany-pika-392": 1017731, + "whimsical-boar-379": 1017769, + "good-mink-652": 1017770, + "amiable-wombat-593": 1017783, + "sleek-skunk-589": 1017784, + "hip-ermine-570": 1017844, + "astute-donkey-109": 1017845, + "kindly-manatee-992": 1017846, + "admired-bat-147": 1017847, + "ceaseless-setter-426": 1017847, + "third-vole-883": 1017851, + "festive-gazelle-75": 1017853, + "adept-dodo-291": 1017862, + "dapper-moose-376": 1017863, + "youthful-peacock-226": 1017923, + "watchful-jay-246": 1017924, + "woozy-sparrow-577": 1017925, + "original-rat-655": 1017926, + "abundant-ibis-580": 1017927, + "wooden-capybara-563": 1017928, + "basic-goshawk-881": 1017929, + "affable-hare-598": 1017930, + "fabulous-bison-33": 1017932, + "content-hedgehog-179": 1017933, + "festive-mandrill-267": 1017934, + "adorable-chinchilla-959": 1017935, + "youthful-marlin-752": 1017936, + "festive-stork-416": 1017937, + "glorious-cassowary-940": 1017938, + "standing-fly-297": 1017939, + "wandering-perch-83": 1017940, + "giant-snail-940": 1017941, + "incredible-kiwi-547": 1017942, + "tacit-manatee-108": 1017943, + "resolute-bloodhound-596": 1017943, + "necessary-echidna-997": 1017944, + "calm-goshawk-813": 1017945, + "successful-skunk-309": 1017977, + "robust-akita-959": 1017978, + "flippant-dragon-813": 1017978, + "kindred-hound-931": 1017996, + "enchanted-stork-579": 1017997, + "harmless-otter-402": 1018002, + "useful-starfish-38": 1018003, + "prestigious-gnu-350": 1018003, + "outgoing-bandicoot-42": 1018004, + "oceanic-pika-759": 1018005, + "wooden-tortoise-158": 1018006, + "combative-iguana-161": 1018007, + "adjoining-flamingo-968": 1018008, + "focused-tapir-430": 1018009, + "abundant-starling-30": 1018010, + "dashing-weasel-272": 1018011, + "tidy-pig-908": 1018012, + "avid-yak-520": 1018013, + "scintillating-walrus-520": 1018014, + "fastidious-starling-995": 1018015, + "industrious-manatee-998": 1018016, + "optimistic-walrus-159": 1018017, + "neat-lapwing-139": 1018018, + "expert-dove-727": 1018019, + "canny-peccary-821": 1018020, + "utmost-lapwing-483": 1018021, + "warmhearted-gecko-482": 1018022, + "quiet-dalmatian-632": 1018023, + "valiant-marmot-382": 1018032, + "adventurous-sandpiper-247": 1018033, + "modest-magpie-316": 1018033, + "tacit-possum-959": 1018045, + "different-horse-124": 1018046, + "flippant-sandpiper-263": 1018047, + "aware-mallard-258": 1018069, + "agreeable-hare-511": 1018070, + "impartial-cormorant-912": 1018070, + "academic-roadrunner-648": 1018087, + "fortunate-tortoise-533": 1018088, + "notable-guanaco-109": 1018091, + "hip-partridge-315": 1018092, + "disciplined-cod-952": 1018115, + "robust-panda-919": 1018117, + "exuberant-opossum-866": 1018117, + "standing-moose-551": 1018197, + "cheerful-sheep-478": 1018199, + "tidy-parrot-720": 1018206, + "jovial-sheep-856": 1018207, + "sensible-alligator-645": 1018212, + "secret-mockingbird-911": 1018213, + "steady-giraffe-841": 1018223, + "pleasant-rat-96": 1018224, + "brilliant-crab-111": 1018275, + "next-crab-895": 1018277, + "clear-akita-888": 1018277, + "elated-duck-237": 1018280, + "energized-bear-542": 1018281, + "efficient-ibex-901": 1018289, + "useful-spoonbill-664": 1018291, + "energetic-dodo-696": 1018292, + "incredible-turtle-280": 1018292, + "majestic-cobra-116": 1018293, + "tidy-jay-529": 1018306, + "upbeat-chinchilla-546": 1018307, + "loyal-mammoth-256": 1018311, + "efficient-seahorse-393": 1018312, + "rightful-dinosaur-590": 1018314, + "impartial-ibis-936": 1018315, + "perceptive-cricket-866": 1018350, + "blessed-hamster-695": 1018352, + "keen-crow-879": 1018368, + "terrific-seal-810": 1018369, + "doting-goldfish-15": 1018373, + "chatty-kiwi-32": 1018374, + "watchful-lemming-155": 1018374, + "amicable-coyote-330": 1018375, + "honorable-quail-980": 1018376, + "grand-jackal-918": 1018402, + "exuberant-hummingbird-156": 1018403, + "friendly-goose-549": 1018452, + "stoic-lapwing-369": 1018453, + "basic-lapwing-989": 1018453, + "fast-avocet-141": 1018461, + "kindly-hamster-458": 1018464, + "focused-orca-201": 1018476, + "neat-cheetah-359": 1018477, + "limitless-finch-213": 1018489, + "vibrant-gopher-186": 1018493, + "tremendous-corgi-312": 1018495, + "acrobatic-otter-405": 1018498, + "chatty-ladybug-129": 1018500, + "cheery-caiman-859": 1018520, + "little-salamander-878": 1018521, + "neat-peccary-808": 1018533, + "standing-trout-39": 1018534, + "colorless-pig-629": 1018555, + "gallant-deer-527": 1018556, + "brilliant-ram-563": 1018556, + "tidy-bulldog-297": 1018571, + "cool-marlin-393": 1018572, + "giddy-hyena-279": 1018579, + "quaint-chipmunk-249": 1018580, + "dependable-peccary-400": 1018646, + "polite-crab-239": 1018647, + "beaming-hare-697": 1018717, + "cheery-ram-883": 1018718, + "polite-curlew-87": 1018741, + "handsome-flamingo-861": 1018742, + "upbeat-otter-888": 1018747, + "vivid-hedgehog-74": 1018761, + "courteous-dalmatian-900": 1018763, + "basic-fennec-465": 1018832, + "different-roadrunner-210": 1018833, + "basic-bison-572": 1018845, + "adorable-clownfish-618": 1018846, + "hushed-finch-846": 1018857, + "resolute-skunk-333": 1018859, + "festive-vulture-821": 1018956, + "ardent-dalmatian-331": 1018958, + "steady-pig-460": 1018994, + "brazen-aardvark-285": 1018995, + "decisive-gnu-527": 1019003, + "cautious-hornet-150": 1019004, + "glad-ram-582": 1019052, + "hip-narwhal-855": 1019054, + "veracious-falcon-516": 1019087, + "colorless-hound-946": 1019088, + "useful-retriever-593": 1019109, + "calm-viper-818": 1019111, + "gallant-bullfrog-351": 1019112, + "rosy-mockingbird-231": 1019114, + "accomplished-lobster-791": 1019118, + "artful-gopher-729": 1019120, + "scintillating-yak-135": 1019172, + "fiery-basilisk-988": 1019173, + "dependable-coyote-252": 1019176, + "curious-fly-947": 1019178, + "oceanic-crab-620": 1019181, + "agreeable-warthog-600": 1019182, + "decisive-heron-617": 1019192, + "dusty-cat-888": 1019193, + "animated-husky-575": 1019194, + "festive-swan-785": 1019196, + "first-armadillo-147": 1019217, + "shiny-spaniel-548": 1019218, + "little-crow-369": 1019237, + "ceaseless-parrot-708": 1019238, + "striped-butterfly-71": 1019248, + "outstanding-ant-267": 1019249, + "fabulous-ptarmigan-154": 1019249, + "content-wolverine-846": 1019262, + "insightful-dogfish-210": 1019265, + "giddy-gull-358": 1019270, + "frugal-raccoon-121": 1019271, + "merry-mole-154": 1019273, + "dynamic-wolverine-866": 1019275, + "hip-mandrill-77": 1019285, + "cool-albatross-529": 1019288, + "dutiful-caterpillar-400": 1019310, + "harmless-firefly-634": 1019311, + "dusty-bobcat-913": 1019330, + "jovial-egret-195": 1019331, + "careful-panda-868": 1019339, + "glorious-skunk-428": 1019340, + "optimistic-pigeon-321": 1019359, + "upbeat-swordfish-955": 1019360, + "earnest-echidna-911": 1019385, + "insightful-firefly-575": 1019387, + "flippant-crab-739": 1019397, + "ceaseless-greyhound-174": 1019399, + "hidden-lobster-629": 1019429, + "incredible-bass-899": 1019430, + "dapper-emu-397": 1019439, + "precise-robin-570": 1019440, + "keen-ptarmigan-968": 1019443, + "combative-emu-376": 1019444, + "good-mastiff-178": 1019457, + "unique-lemur-51": 1019459, + "different-stork-70": 1019465, + "energized-schnauzer-847": 1019466, + "tame-wildcat-98": 1019538, + "beaming-peacock-625": 1019539, + "grateful-squirrel-121": 1019539, + "dutiful-lynx-135": 1019541, + "wary-gazelle-140": 1019543, + "content-stoat-324": 1019545, + "secret-ibex-420": 1019547, + "dusty-husky-78": 1019590, + "robust-leopard-886": 1019591, + "wry-pika-32": 1019611, + "coordinated-bullfrog-817": 1019614, + "capable-jackal-724": 1019619, + "bright-kiwi-86": 1019620, + "avid-labrador-417": 1019683, + "wary-mandrill-403": 1019685, + "lovely-ferret-127": 1019731, + "abundant-condor-512": 1019732, + "combative-blackbird-821": 1019732, + "proficient-greyhound-41": 1019807, + "canny-elk-409": 1019808, + "quirky-salmon-365": 1019827, + "shocking-stoat-612": 1019828, + "gallant-bison-955": 1019829, + "tangible-lemur-169": 1019829, + "bright-dachshund-673": 1019830, + "combative-ostrich-227": 1019833, + "diligent-chickadee-696": 1019834, + "reliable-caribou-983": 1019843, + "academic-orca-209": 1019844, + "zealous-oyster-513": 1019890, + "elegant-puma-19": 1019892, + "calm-herring-324": 1019892, + "accomplished-goldfinch-537": 1019926, + "chatty-trout-883": 1019928, + "combative-fly-971": 1019928, + "intent-hamster-347": 1019956, + "tacit-warthog-165": 1019957, + "neighborly-rooster-851": 1019992, + "fortunate-porcupine-22": 1019993, + "posh-caiman-599": 1020015, + "effervescent-meerkat-570": 1020016, + "shocking-buffalo-684": 1020088, + "quick-fennec-285": 1020091, + "watchful-akita-991": 1020092, + "unique-porcupine-306": 1020093, + "shocking-echidna-344": 1020094, + "veracious-snake-114": 1020097, + "trustworthy-malamute-433": 1020131, + "ideal-guanaco-331": 1020132, + "impressive-starling-126": 1020144, + "rosy-dog-650": 1020145, + "loyal-gnu-24": 1020149, + "posh-spider-364": 1020150, + "charming-emu-483": 1020159, + "quixotic-alpaca-529": 1020160, + "insightful-egret-626": 1020170, + "different-gopher-707": 1020171, + "savory-hornet-307": 1020175, + "valuable-camel-800": 1020176, + "neighborly-mosquito-862": 1020194, + "joyous-flamingo-626": 1020195, + "handsome-setter-25": 1020209, + "jovial-manatee-851": 1020210, + "hearty-lemur-654": 1020215, + "next-iguana-438": 1020216, + "third-pig-30": 1020220, + "rugged-curlew-306": 1020221, + "glorious-hare-890": 1020221, + "elated-chinchilla-289": 1020238, + "hushed-sardine-917": 1020240, + "neat-ibex-338": 1020258, + "formal-jellyfish-890": 1020259, + "hip-goat-515": 1020281, + "chatty-beagle-948": 1020282, + "superb-panda-886": 1020318, + "grateful-peccary-373": 1020319, + "cool-antelope-612": 1020326, + "adamant-lapwing-521": 1020328, + "rare-cobra-78": 1020359, + "pleasant-frog-591": 1020360, + "fearless-labrador-726": 1020360, + "modest-quail-352": 1020370, + "famous-ostrich-259": 1020371, + "tidy-wildcat-793": 1020374, + "adept-shepherd-691": 1020376, + "precise-chihuahua-281": 1020377, + "shocking-fly-958": 1020379, + "pastel-guineapig-465": 1020389, + "descriptive-sockeye-268": 1020390, + "dusty-duck-111": 1020400, + "festive-deer-510": 1020403, + "judicious-sardine-37": 1020405, + "third-tapir-659": 1020406, + "tame-hyena-462": 1020443, + "grateful-mule-854": 1020444, + "adorable-kangaroo-296": 1020484, + "doting-raccoon-964": 1020486, + "different-salamander-198": 1020490, + "adept-grasshopper-977": 1020491, + "polished-impala-841": 1020498, + "successful-condor-847": 1020499, + "resilient-poodle-746": 1020499, + "zealous-magpie-103": 1020505, + "agile-sardine-193": 1020507, + "brilliant-schnauzer-391": 1020585, + "sincere-rhinoceros-731": 1020586, + "fearless-stoat-597": 1020596, + "friendly-jay-84": 1020597, + "polished-reindeer-112": 1020631, + "agreeable-goshawk-890": 1020634, + "handsome-snake-527": 1020669, + "charming-hedgehog-586": 1020672, + "opulent-retriever-485": 1020678, + "befitting-rabbit-920": 1020679, + "different-camel-231": 1020693, + "elegant-parakeet-108": 1020695, + "earnest-jackal-182": 1020695, + "wary-poodle-854": 1020735, + "aromatic-mammoth-548": 1020736, + "determined-badger-374": 1020765, + "chatty-possum-322": 1020766, + "famous-squid-691": 1020766, + "bold-ferret-173": 1020769, + "healthy-meadowlark-100": 1020770, + "third-ibex-696": 1020775, + "careful-cod-355": 1020777, + "shiny-bulldog-957": 1020789, + "prestigious-stoat-320": 1020790, + "flippant-raven-281": 1020797, + "quiet-lapwing-831": 1020799, + "insightful-poodle-397": 1020819, + "canny-toucan-658": 1020820, + "wry-aardvark-112": 1020823, + "accomplished-barracuda-643": 1020825, + "rightful-deer-212": 1020825, + "impartial-ox-349": 1020861, + "little-hippopotamus-467": 1020862, + "accomplished-tern-328": 1020882, + "lovable-finch-191": 1020883, + "brave-chicken-109": 1020883, + "dynamic-starfish-463": 1020887, + "honorable-kookabura-183": 1020888, + "industrious-ant-569": 1020893, + "hidden-ladybug-399": 1020894, + "giddy-hound-274": 1020926, + "enchanted-caiman-996": 1020927, + "polished-chinchilla-530": 1020928, + "standing-fox-501": 1020929, + "cheery-kiwi-257": 1020929, + "keen-capybara-375": 1020950, + "shocking-antelope-101": 1020951, + "intent-trout-658": 1020954, + "confident-ox-396": 1020955, + "brilliant-rabbit-346": 1020956, + "hushed-okapi-25": 1020958, + "deafening-corgi-356": 1020970, + "glorious-blackbird-598": 1020971, + "tangible-yak-180": 1020996, + "qualified-ram-978": 1020997, + "rugged-dolphin-748": 1021011, + "wandering-moose-987": 1021012, + "hardy-mosquito-650": 1021012, + "rapid-parrot-725": 1021016, + "strong-akita-345": 1021017, + "colorless-hamster-105": 1021058, + "peaceful-wolf-559": 1021059, + "sleek-hyena-88": 1021095, + "mild-leopard-316": 1021096, + "academic-elk-684": 1021123, + "clear-squirrel-31": 1021124, + "quirky-setter-132": 1021146, + "cautious-ptarmigan-640": 1021148, + "rightful-kangaroo-565": 1021151, + "different-giraffe-552": 1021152, + "zealous-puffin-415": 1021170, + "decisive-meerkat-155": 1021172, + "woozy-manatee-531": 1021184, + "energetic-viper-775": 1021185, + "dependable-chipmunk-314": 1021185, + "honorable-chihuahua-465": 1021229, + "youthful-fly-214": 1021230, + "calculating-chipmunk-300": 1021247, + "adamant-butterfly-417": 1021248, + "standing-stingray-426": 1021282, + "animated-pony-936": 1021284, + "adjoining-wildcat-517": 1021289, + "aware-rabbit-497": 1021290, + "judicious-egret-436": 1021290, + "quick-sardine-468": 1021315, + "charming-chameleon-602": 1021316, + "rare-ocelot-544": 1021327, + "spotted-snake-149": 1021328, + "sleek-shepherd-652": 1021331, + "necessary-viper-109": 1021332, + "clean-roadrunner-844": 1021337, + "dependable-cod-37": 1021338, + "tidy-beagle-554": 1021343, + "upbeat-kiwi-477": 1021344, + "dashing-chinchilla-472": 1021358, + "outstanding-sheep-502": 1021376, + "brainy-sandpiper-822": 1021378, + "clever-sardine-157": 1021387, + "proficient-toucan-785": 1021388, + "charming-mosquito-38": 1021390, + "benevolent-lapwing-581": 1021391, + "kindhearted-parakeet-320": 1021467, + "tidy-eel-72": 1021468, + "stoic-giraffe-374": 1021499, + "keen-gerbil-765": 1021502, + "utmost-falcon-2": 1021507, + "quixotic-aardvark-624": 1021509, + "shocking-anteater-251": 1021525, + "fiery-starfish-984": 1021527, + "clean-mandrill-710": 1021543, + "capable-chipmunk-770": 1021545, + "bold-meerkat-36": 1021563, + "focused-skunk-302": 1021564, + "shiny-kangaroo-399": 1021564, + "dependable-buffalo-780": 1021604, + "academic-bandicoot-595": 1021606, + "hearty-cricket-196": 1021638, + "benevolent-owl-242": 1021640, + "utmost-hummingbird-16": 1021676, + "befitting-wildebeest-831": 1021677, + "precise-salamander-296": 1021677, + "rightful-rabbit-182": 1021678, + "tangible-sandpiper-92": 1021679, + "zealous-raven-903": 1021688, + "cheery-weasel-238": 1021692, + "fantastic-salmon-498": 1021692, + "fantastic-greyhound-416": 1021712, + "wry-goldfish-596": 1021715, + "neat-wren-298": 1021715, + "flippant-dog-249": 1021746, + "keen-cobra-498": 1021747, + "gallant-newt-658": 1021759, + "peaceful-barracuda-390": 1021760, + "nautical-hound-441": 1021763, + "adjoining-newt-264": 1021764, + "usable-tortoise-740": 1021773, + "dazzling-iguana-252": 1021774, + "giddy-gnu-63": 1021797, + "valiant-orca-4": 1021798, + "abundant-stork-968": 1021819, + "proficient-alligator-781": 1021820, + "cheery-goldfinch-990": 1021820, + "avid-gecko-446": 1021831, + "qualified-dolphin-540": 1021833, + "perceptive-ibis-860": 1021837, + "fastidious-warthog-210": 1021838, + "befitting-cassowary-336": 1021889, + "quick-mole-874": 1021891, + "tacit-kangaroo-150": 1021895, + "lovely-starling-748": 1021896, + "pleasant-dove-219": 1021906, + "prestigious-ox-909": 1021908, + "mild-iguana-364": 1021933, + "determined-puffin-325": 1021934, + "keen-cobra-531": 1021934, + "blessed-octopus-629": 1021952, + "tacit-bird-256": 1021953, + "keen-mallard-349": 1021998, + "dependable-vulture-157": 1021999, + "unique-husky-110": 1022006, + "polished-wombat-630": 1022007, + "abundant-moose-743": 1022080, + "beloved-cricket-24": 1022081, + "scintillating-jellyfish-339": 1022081, + "beaming-firefly-465": 1022104, + "unique-sheep-79": 1022106, + "chatty-parrot-59": 1022122, + "befitting-jackal-902": 1022123, + "bright-tiger-235": 1022123, + "aromatic-duck-18": 1022135, + "lovely-pony-296": 1022137, + "precious-lion-1": 1022164, + "grandiose-grasshopper-199": 1022165, + "glorious-beagle-268": 1022166, + "impartial-gnu-66": 1022167, + "energetic-retriever-660": 1022173, + "glorious-dinosaur-421": 1022174, + "little-mammoth-20": 1022175, + "academic-elk-298": 1022179, + "courteous-partridge-375": 1022181, + "hidden-hamster-162": 1022182, + "savory-pony-468": 1022185, + "affable-malamute-814": 1022186, + "warmhearted-chickadee-814": 1022258, + "festive-albatross-883": 1022259, + "acoustic-cassowary-226": 1022321, + "vibrant-panther-671": 1022322, + "savory-ptarmigan-165": 1022419, + "accurate-chickadee-302": 1022421, + "marvelous-goose-63": 1022451, + "adventurous-kudu-174": 1022453, + "hidden-llama-631": 1022456, + "utmost-anaconda-797": 1022457, + "calm-hyena-414": 1022471, + "courteous-elephant-218": 1022473, + "focused-alligator-708": 1022474, + "third-gecko-690": 1022478, + "knowing-panther-302": 1022484, + "perceptive-ladybug-70": 1022485, + "usable-oriole-432": 1022555, + "mild-sandpiper-470": 1022557, + "small-wildcat-730": 1022558, + "capable-dog-660": 1022562, + "knowing-terrier-209": 1022568, + "superb-hyena-648": 1022569, + "wooden-gull-888": 1022575, + "outgoing-dragon-818": 1022576, + "exuberant-anaconda-97": 1022631, + "cheery-cuttlefish-606": 1022633, + "knowing-rooster-732": 1022638, + "beaming-mule-831": 1022639, + "deafening-greyhound-577": 1022641, + "clear-butterfly-67": 1022644, + "polite-finch-475": 1022652, + "helpful-blackbird-424": 1022653, + "content-owl-177": 1022719, + "gallant-sheep-699": 1022720, + "wooden-gull-873": 1022735, + "utmost-octopus-548": 1022737, + "fiery-bass-421": 1022738, + "rosy-chickadee-531": 1022740, + "wary-cobra-407": 1022750, + "hushed-jaguar-232": 1022752, + "trustworthy-dog-28": 1022790, + "combative-robin-793": 1022791, + "upbeat-bobcat-873": 1022817, + "elegant-malamute-229": 1022819, + "small-toad-18": 1022830, + "polished-rooster-587": 1022831, + "giant-elk-225": 1022831, + "sensible-caiman-181": 1022837, + "greedy-camel-685": 1022839, + "adorable-salmon-175": 1022842, + "usable-hedgehog-518": 1022843, + "kindred-opossum-860": 1022843, + "strong-marmot-503": 1022859, + "valiant-goldfish-626": 1022860, + "next-cuttlefish-330": 1022879, + "kindred-mandrill-639": 1022880, + "proficient-chihuahua-647": 1022908, + "enduring-toucan-485": 1022910, + "canny-sparrow-498": 1022920, + "trustworthy-lion-569": 1022922, + "trustworthy-rabbit-783": 1022948, + "giant-clam-613": 1022951, + "tangible-pelican-529": 1022952, + "shiny-seahorse-56": 1022954, + "ideal-sparrow-63": 1022969, + "determined-ant-810": 1022972, + "accomplished-bee-27": 1022986, + "wooden-schnauzer-140": 1022990, + "friendly-raven-611": 1023078, + "tacit-salmon-498": 1023081, + "trustworthy-boar-252": 1023088, + "adjoining-rabbit-681": 1023091, + "secret-finch-722": 1023095, + "mild-bullfrog-331": 1023097, + "gallant-fish-38": 1023106, + "giddy-cardinal-641": 1023107, + "beaming-mastiff-798": 1023109, + "healthy-fish-642": 1023110, + "dynamic-impala-839": 1023110, + "kindred-avocet-504": 1023114, + "cheery-alligator-252": 1023117, + "veracious-seahorse-685": 1023121, + "fiery-octopus-288": 1023122, + "exuberant-coyote-836": 1023157, + "agile-basilisk-587": 1023159, + "brazen-sheep-381": 1023171, + "quaint-flamingo-307": 1023173, + "shiny-ram-284": 1023173, + "grand-labrador-162": 1023191, + "brilliant-chameleon-668": 1023192, + "impartial-hamster-705": 1023193, + "ceaseless-gerbil-969": 1023194, + "coordinated-peccary-781": 1023194, + "successful-chipmunk-191": 1023239, + "flippant-goose-744": 1023240, + "different-lapwing-647": 1023279, + "oceanic-dogfish-572": 1023280, + "merry-bee-582": 1023280, + "good-gnat-920": 1023314, + "energized-gnat-211": 1023316, + "optimistic-lapwing-675": 1023317, + "kindhearted-perch-44": 1023318, + "resolute-ferret-560": 1023366, + "colorful-anteater-707": 1023368, + "small-platypus-835": 1023372, + "posh-ermine-456": 1023373, + "amiable-cuttlefish-590": 1023373, + "utmost-snake-602": 1023386, + "avid-bloodhound-800": 1023388, + "posh-weasel-540": 1023432, + "disciplined-opossum-540": 1023433, + "gregarious-hamster-339": 1023441, + "marvelous-lion-384": 1023445, + "terrific-minnow-420": 1023449, + "hip-basilisk-588": 1023450, + "aware-minnow-54": 1023476, + "different-possum-728": 1023478, + "astute-jackal-453": 1023492, + "famous-tern-254": 1023495, + "spotted-dinosaur-230": 1023502, + "glad-avocet-648": 1023503, + "polite-boar-35": 1023536, + "greedy-dotterel-993": 1023537, + "judicious-avocet-911": 1023546, + "hidden-pig-661": 1023548, + "terrific-narwhal-339": 1023587, + "youthful-loris-159": 1023590, + "beaming-lion-446": 1023594, + "ardent-ptarmigan-828": 1023596, + "next-stoat-856": 1023597, + "diligent-canary-373": 1023598, + "first-shepherd-386": 1023606, + "loyal-llama-92": 1023609, + "wooden-leopard-226": 1023613, + "sensible-cardinal-613": 1023614, + "judicious-starling-530": 1023626, + "artful-jackal-314": 1023629, + "utmost-vulture-229": 1023633, + "groovy-falcon-128": 1023636, + "aware-ibex-455": 1023648, + "resolute-pony-578": 1023650, + "original-goat-62": 1023668, + "successful-ocelot-498": 1023670, + "energetic-tapir-917": 1023682, + "enchanted-squirrel-611": 1023683, + "different-peccary-174": 1023703, + "deafening-chickadee-84": 1023717, + "scrupulous-ermine-618": 1023718, + "reliable-husky-764": 1023718, + "dapper-lemming-504": 1023721, + "tame-terrier-78": 1023723, + "acoustic-dodo-527": 1023735, + "agile-warthog-580": 1023736, + "laudable-goldfish-589": 1023737, + "exuberant-fly-707": 1023739, + "veracious-greyhound-954": 1023753, + "accurate-meadowlark-395": 1023754, + "upbeat-raccoon-360": 1023775, + "neat-coyote-919": 1023776, + "kindly-lynx-648": 1023811, + "aromatic-aardvark-412": 1023811, + "agile-dove-460": 1023813, + "efficient-retriever-206": 1023833, + "fastidious-robin-909": 1023834, + "grandiose-nightingale-483": 1023863, + "formal-puffin-823": 1023864, + "ceaseless-chameleon-601": 1023878, + "fastidious-axolotl-720": 1023879, + "reliable-bulldog-956": 1023895, + "benevolent-snake-591": 1023896, + "reliable-shepherd-370": 1023897, + "resolute-ermine-80": 1023899, + "kindly-dachshund-288": 1023905, + "amiable-opossum-975": 1023907, + "grandiose-pheasant-320": 1023952, + "clever-impala-457": 1023954, + "reliable-sturgeon-600": 1023970, + "canny-okapi-372": 1023970, + "honorable-pelican-957": 1023973, + "gregarious-skunk-300": 1023998, + "elated-rabbit-726": 1023999, + "dynamic-ant-247": 1024005, + "efficient-curlew-71": 1024006, + "abundant-panda-425": 1024047, + "judicious-bass-975": 1024049, + "blissful-stingray-595": 1024065, + "fortunate-mallard-507": 1024066, + "colorful-aardvark-383": 1024086, + "quaint-ocelot-911": 1024087, + "benevolent-bison-164": 1024088, + "chatty-goldfinch-659": 1024089, + "rugged-gopher-900": 1024090, + "rare-crane-573": 1024090, + "superb-pig-583": 1024093, + "polite-lemur-89": 1024102, + "reliable-shepherd-814": 1024104, + "warmhearted-gopher-908": 1024113, + "peaceful-pony-654": 1024115, + "dashing-ladybug-857": 1024122, + "combative-crab-811": 1024123, + "little-ibex-401": 1024132, + "clean-rooster-757": 1024133, + "amicable-camel-708": 1024136, + "insightful-goldfinch-824": 1024137, + "doting-ermine-210": 1024140, + "limitless-finch-116": 1024140, + "utmost-sturgeon-985": 1024143, + "fearless-tern-467": 1024146, + "fast-fish-282": 1024148, + "elated-reindeer-836": 1024164, + "hidden-ram-379": 1024165, + "acoustic-buzzard-274": 1024175, + "charming-gnat-880": 1024176, + "careful-goldfish-837": 1024186, + "canny-avocet-744": 1024188, + "original-falcon-599": 1024189, + "elated-caribou-995": 1024190, + "benevolent-mockingbird-106": 1024197, + "basic-bass-376": 1024200, + "tame-kudu-409": 1024233, + "pastel-gull-116": 1024234, + "academic-robin-153": 1024246, + "graceful-dinosaur-917": 1024249, + "rightful-seahorse-717": 1024261, + "adept-scorpion-163": 1024262, + "glad-pheasant-303": 1024262, + "successful-dogfish-465": 1024266, + "calm-cormorant-152": 1024269, + "blessed-hamster-500": 1024307, + "resolute-buffalo-25": 1024308, + "marvelous-cow-235": 1024320, + "colorless-capybara-182": 1024322, + "strong-blackbird-405": 1024333, + "content-fennec-428": 1024336, + "benevolent-wildebeest-966": 1024400, + "good-hawk-95": 1024404, + "outstanding-fox-24": 1024404, + "blissful-roadrunner-230": 1024472, + "blissful-gecko-390": 1024473, + "earnest-jaguar-181": 1024526, + "proper-porpoise-565": 1024529, + "chatty-ladybug-466": 1024529, + "uncommon-duck-978": 1024566, + "judicious-corgi-592": 1024568, + "usable-dolphin-527": 1024599, + "grateful-cod-580": 1024603, + "hardy-gopher-332": 1024603, + "peaceful-mammoth-547": 1024614, + "flippant-beagle-253": 1024615, + "colorless-ladybug-427": 1024615, + "standing-dachshund-967": 1024650, + "adjoining-frog-850": 1024651, + "original-newt-685": 1024720, + "exuberant-cormorant-580": 1024722, + "hidden-rhinoceros-783": 1024731, + "knowing-perch-192": 1024733, + "confident-herring-498": 1024734, + "kindly-salamander-218": 1024735, + "artful-kingfisher-985": 1024738, + "rosy-firefly-727": 1024739, + "outstanding-ermine-255": 1024739, + "ceaseless-chameleon-157": 1024740, + "polished-lapwing-749": 1024741, + "grateful-newt-91": 1024742, + "flexible-lark-158": 1024743, + "charming-elephant-751": 1024744, + "wandering-sheep-201": 1024745, + "kindhearted-otter-470": 1024746, + "notable-whale-529": 1024747, + "colorful-toucan-564": 1024748, + "modest-donkey-992": 1024749, + "tremendous-spider-307": 1024750, + "fearless-partridge-658": 1024751, + "mellow-duck-90": 1024752, + "fantastic-boar-434": 1024753, + "adjoining-lark-913": 1024754, + "blissful-raccoon-579": 1024755, + "energetic-owl-983": 1024755, + "veracious-fennec-479": 1024756, + "beaming-sheep-102": 1024758, + "handsome-dragon-152": 1024767, + "fabulous-cod-151": 1024771, + "clear-seahorse-394": 1024771, + "accurate-monitor-102": 1024796, + "modest-gopher-844": 1024797, + "kindly-ermine-602": 1024798, + "ardent-bloodhound-230": 1024799, + "academic-quail-615": 1024840, + "youthful-penguin-845": 1024841, + "glad-cow-64": 1024847, + "superb-bison-864": 1024848, + "befitting-turtle-859": 1024850, + "judicious-panda-147": 1024851, + "intent-sandpiper-759": 1024906, + "clear-tortoise-130": 1024907, + "tacit-lemming-552": 1024934, + "affable-clownfish-561": 1024937, + "flexible-armadillo-130": 1024979, + "deafening-hound-140": 1024980, + "steady-wren-1": 1024984, + "steady-civet-529": 1024985, + "optimistic-puffin-780": 1025011, + "perceptive-mule-715": 1025013, + "charming-cuttlefish-957": 1025093, + "grand-dodo-24": 1025094, + "tidy-dachshund-294": 1025105, + "beaming-spider-25": 1025106, + "chatty-cat-737": 1025131, + "content-octopus-922": 1025132, + "industrious-goshawk-153": 1025197, + "polished-crocodile-612": 1025199, + "handsome-scorpion-847": 1025207, + "quiet-hedgehog-180": 1025208, + "wry-kookabura-59": 1025208, + "warmhearted-gazelle-548": 1025226, + "gregarious-hyena-554": 1025227, + "flippant-otter-873": 1025227, + "posh-cassowary-262": 1025234, + "disciplined-puma-535": 1025237, + "frugal-capybara-816": 1025239, + "sincere-canary-417": 1025242, + "giddy-cormorant-841": 1025245, + "artful-bloodhound-872": 1025247, + "quixotic-panther-767": 1025260, + "chatty-marten-135": 1025261, + "diligent-turtle-675": 1025265, + "blissful-greyhound-410": 1025267, + "ceaseless-shrimp-813": 1025290, + "compassionate-schnauzer-530": 1025291, + "loyal-kookabura-944": 1025292, + "reliable-eel-681": 1025294, + "calculating-firefly-612": 1025296, + "colorful-heron-152": 1025299, + "outstanding-sturgeon-207": 1025338, + "dynamic-newt-165": 1025339, + "doting-bullfrog-393": 1025341, + "good-setter-446": 1025342, + "calm-dotterel-424": 1025354, + "upbeat-cormorant-983": 1025356, + "prestigious-wolf-709": 1025357, + "prestigious-sturgeon-373": 1025357, + "patient-tiger-127": 1025358, + "giddy-quail-798": 1025375, + "calm-lark-386": 1025376, + "formal-giraffe-55": 1025407, + "calculating-dogfish-591": 1025410, + "blissful-spoonbill-154": 1025411, + "brainy-ferret-642": 1025411, + "usable-finch-372": 1025413, + "unique-giraffe-244": 1025423, + "first-dogfish-395": 1025424, + "patient-bear-811": 1025448, + "proper-ox-783": 1025449, + "third-herring-751": 1025459, + "clear-crow-388": 1025461, + "expert-ladybug-658": 1025493, + "aromatic-axolotl-181": 1025494, + "energetic-corgi-167": 1025531, + "wry-puffin-659": 1025531, + "outstanding-porpoise-163": 1025534, + "incredible-weasel-216": 1025535, + "incredible-lapwing-79": 1025536, + "wry-pig-731": 1025556, + "cool-pony-841": 1025557, + "compassionate-vulture-80": 1025564, + "robust-tiger-356": 1025565, + "acoustic-mallard-359": 1025569, + "reliable-whale-7": 1025571, + "rare-gecko-976": 1025573, + "acoustic-ram-796": 1025575, + "unique-gerbil-858": 1025575, + "next-marmot-449": 1025596, + "harmless-gerbil-946": 1025597, + "perceptive-walrus-599": 1025604, + "ceaseless-bird-153": 1025605, + "focused-husky-732": 1025626, + "quirky-cat-582": 1025628, + "avid-peccary-496": 1025630, + "mild-finch-536": 1025632, + "bold-pika-204": 1025670, + "savory-hippopotamus-413": 1025672, + "deafening-gopher-682": 1025675, + "enchanted-terrier-20": 1025676, + "courteous-rat-859": 1025686, + "impartial-wildebeest-200": 1025686, + "fortunate-anaconda-828": 1025687, + "neighborly-oyster-343": 1025688, + "coordinated-mink-395": 1025689, + "affable-gopher-145": 1025704, + "giddy-chicken-908": 1025706, + "efficient-pika-126": 1025711, + "formal-sockeye-97": 1025713, + "hip-oyster-238": 1025721, + "content-sandpiper-764": 1025723, + "secret-bear-666": 1025738, + "pastel-spaniel-435": 1025740, + "sleek-vole-897": 1025740, + "shiny-capybara-857": 1025747, + "harmless-echidna-200": 1025748, + "kindly-caterpillar-462": 1025749, + "formal-kangaroo-760": 1025750, + "academic-ladybug-267": 1025811, + "kindhearted-poodle-709": 1025812, + "fiery-bandicoot-176": 1025846, + "trustworthy-grasshopper-480": 1025848, + "sleek-stoat-648": 1025889, + "scrupulous-grasshopper-578": 1025890, + "graceful-labrador-476": 1025917, + "unique-corgi-654": 1025919, + "doting-possum-793": 1025958, + "calm-bear-555": 1025961, + "brazen-ostrich-708": 1026010, + "honorable-antelope-19": 1026013, + "descriptive-heron-376": 1026014, + "cheerful-coyote-316": 1026015, + "brainy-beagle-879": 1026017, + "mild-crocodile-828": 1026018, + "handsome-dogfish-822": 1026027, + "blessed-meadowlark-398": 1026029, + "giddy-turtle-220": 1026041, + "confident-herring-651": 1026043, + "lovable-walrus-29": 1026044, + "healthy-jellyfish-626": 1026045, + "brilliant-marmot-426": 1026080, + "savory-penguin-482": 1026082, + "grateful-gnu-215": 1026110, + "proper-hare-552": 1026111, + "wonderful-dolphin-738": 1026111, + "aromatic-mandrill-770": 1026113, + "fortunate-loris-752": 1026114, + "curious-elephant-382": 1026130, + "tough-lyrebird-730": 1026131, + "affable-viper-601": 1026149, + "resolute-owl-691": 1026151, + "next-caiman-865": 1026153, + "coordinated-sardine-820": 1026154, + "adept-poodle-16": 1026177, + "elated-wren-314": 1026177, + "proficient-caiman-871": 1026179, + "gallant-hound-61": 1026183, + "grandiose-basilisk-932": 1026185, + "energized-orca-599": 1026191, + "courteous-sockeye-946": 1026195, + "utmost-duck-763": 1026238, + "resolute-antelope-404": 1026240, + "accurate-hummingbird-261": 1026247, + "helpful-firefly-723": 1026248, + "trustworthy-sardine-993": 1026248, + "focused-egret-267": 1026266, + "deafening-beagle-27": 1026268, + "academic-salamander-776": 1026271, + "zealous-blackbird-455": 1026273, + "accomplished-sturgeon-644": 1026285, + "careful-clam-615": 1026286, + "academic-rat-64": 1026367, + "posh-spider-535": 1026368, + "exuberant-jay-777": 1026431, + "valiant-goldfish-934": 1026433, + "avid-capybara-906": 1026520, + "resolute-crab-467": 1026521, + "focused-wildebeest-200": 1026522, + "calculating-platypus-466": 1026523, + "diligent-guineapig-812": 1026525, + "cheery-bobcat-137": 1026526, + "resolute-viper-438": 1026527, + "precise-ox-210": 1026528, + "adventurous-gnat-91": 1026529, + "greedy-blackbird-675": 1026530, + "fleet-oyster-182": 1026532, + "fast-shrimp-549": 1026533, + "little-raccoon-316": 1026534, + "woozy-quail-723": 1026536, + "glorious-hummingbird-167": 1026537, + "robust-hornet-277": 1026538, + "neighborly-frog-887": 1026539, + "lovely-cassowary-314": 1026541, + "precious-lobster-881": 1026542, + "beaming-sockeye-632": 1026543, + "enchanted-cardinal-125": 1026560, + "groovy-horse-126": 1026561, + "wary-ibis-885": 1026567, + "usable-tiger-179": 1026568, + "oceanic-quail-155": 1026593, + "descriptive-curlew-136": 1026594, + "lovely-pika-526": 1026600, + "blissful-gull-424": 1026601, + "limitless-ibex-704": 1026607, + "loyal-pig-828": 1026609, + "kindly-pelican-723": 1026609, + "quirky-clownfish-551": 1026616, + "utmost-eagle-935": 1026618, + "blissful-porcupine-308": 1026619, + "grateful-pelican-747": 1026622, + "beloved-beagle-461": 1026625, + "academic-lemur-37": 1026626, + "elated-cow-575": 1026641, + "fabulous-guanaco-919": 1026642, + "clean-newt-347": 1026643, + "beaming-bulldog-175": 1026644, + "uncommon-grasshopper-222": 1026644, + "tremendous-llama-585": 1026662, + "avid-opossum-600": 1026663, + "useful-nightingale-937": 1026672, + "frugal-toad-923": 1026674, + "grateful-porcupine-675": 1026681, + "spotted-crocodile-592": 1026682, + "grateful-mallard-616": 1026704, + "patient-dodo-145": 1026705, + "efficient-jay-758": 1026706, + "moonlit-avocet-345": 1026709, + "resolute-goldfinch-658": 1026713, + "fortunate-goshawk-976": 1026714, + "small-quail-368": 1026714, + "dashing-monitor-47": 1026746, + "enchanted-fish-610": 1026747, + "giddy-crocodile-418": 1026752, + "hip-tortoise-79": 1026754, + "colorless-goldfish-365": 1026758, + "vibrant-toad-705": 1026759, + "energized-wombat-586": 1026765, + "trustworthy-eagle-674": 1026766, + "famous-hippopotamus-771": 1026780, + "neighborly-bird-173": 1026783, + "friendly-akita-714": 1026785, + "descriptive-mockingbird-592": 1026790, + "warmhearted-cardinal-403": 1026792, + "strong-mallard-180": 1026819, + "posh-perch-613": 1026820, + "dazzling-rhinoceros-107": 1026830, + "savory-salamander-344": 1026830, + "groovy-moose-342": 1026831, + "glad-poodle-934": 1026908, + "mellow-tiger-234": 1026909, + "agreeable-echidna-598": 1026932, + "lovely-okapi-453": 1026935, + "rosy-robin-256": 1026944, + "valuable-lobster-853": 1026949, + "enchanted-sturgeon-187": 1026949, + "original-mosquito-923": 1026951, + "fortunate-lemming-502": 1026953, + "dusty-stingray-515": 1026973, + "tacit-hummingbird-825": 1026974, + "terrific-vulture-466": 1027003, + "vibrant-cow-517": 1027005, + "marvelous-goose-834": 1027005, + "jovial-ram-112": 1027010, + "intent-quail-427": 1027013, + "glorious-cricket-394": 1027029, + "avid-frog-801": 1027031, + "accurate-gazelle-379": 1027086, + "limitless-chihuahua-816": 1027087, + "tidy-avocet-386": 1027089, + "exuberant-perch-376": 1027090, + "jovial-sandpiper-77": 1027095, + "impartial-dogfish-893": 1027097, + "peaceful-ram-998": 1027097, + "exciting-jackal-877": 1027108, + "bright-loris-426": 1027109, + "diligent-goose-240": 1027135, + "scintillating-starfish-577": 1027136, + "prestigious-kangaroo-212": 1027143, + "adorable-squid-664": 1027144, + "bold-goat-281": 1027148, + "confident-vulture-518": 1027149, + "formal-gopher-19": 1027149, + "beloved-oyster-476": 1027156, + "flippant-poodle-894": 1027158, + "coordinated-dodo-354": 1027167, + "disciplined-clam-415": 1027169, + "quiet-otter-194": 1027178, + "helpful-camel-320": 1027181, + "shiny-vulture-688": 1027186, + "keen-badger-939": 1027187, + "patient-kingfisher-408": 1027194, + "healthy-alligator-531": 1027195, + "quaint-anteater-724": 1027196, + "quaint-lynx-174": 1027198, + "veracious-pheasant-146": 1027203, + "colorful-grasshopper-197": 1027204, + "amiable-pika-318": 1027213, + "savory-octopus-512": 1027214, + "limitless-mouse-748": 1027242, + "fearless-sparrow-679": 1027243, + "brave-avocet-827": 1027245, + "pleasant-jellyfish-28": 1027246, + "mild-kingfisher-512": 1027247, + "different-cheetah-722": 1027248, + "polite-dogfish-179": 1027249, + "animated-opossum-691": 1027250, + "flippant-wombat-625": 1027250, + "little-gopher-550": 1027255, + "successful-kiwi-820": 1027256, + "brave-seal-519": 1027257, + "combative-goat-588": 1027259, + "merry-labrador-601": 1027293, + "vivid-crane-37": 1027294, + "prestigious-crane-24": 1027294, + "wry-raven-485": 1027317, + "enduring-falcon-637": 1027319, + "robust-falcon-116": 1027323, + "neat-hummingbird-531": 1027324, + "fantastic-donkey-723": 1027354, + "dynamic-hawk-568": 1027355, + "artful-canary-33": 1027364, + "curious-okapi-341": 1027365, + "steady-quail-262": 1027392, + "warmhearted-hornet-335": 1027393, + "confident-stoat-448": 1027401, + "bright-rhinoceros-906": 1027402, + "expert-pig-25": 1027408, + "laudable-sparrow-95": 1027409, + "striped-mastiff-977": 1027412, + "adorable-oriole-253": 1027413, + "exciting-crow-207": 1027415, + "artful-marlin-676": 1027418, + "quiet-clownfish-175": 1027427, + "valuable-narwhal-408": 1027428, + "neighborly-skunk-535": 1027429, + "hardy-coyote-283": 1027430, + "doting-salamander-740": 1027430, + "capable-owl-466": 1027439, + "wry-herring-228": 1027441, + "hushed-pony-764": 1027442, + "quirky-wildebeest-157": 1027446, + "impressive-mule-456": 1027447, + "calculating-echidna-783": 1027453, + "zealous-snail-157": 1027454, + "efficient-monitor-833": 1027454, + "tacit-bulldog-416": 1027491, + "scrupulous-raven-607": 1027493, + "effervescent-seahorse-734": 1027521, + "adjoining-falcon-404": 1027522, + "outgoing-albatross-622": 1027525, + "affable-salamander-859": 1027526, + "healthy-avocet-812": 1027526, + "disciplined-skunk-412": 1027534, + "nautical-cricket-47": 1027535, + "fleet-capybara-903": 1027537, + "affable-gecko-845": 1027538, + "proficient-cricket-576": 1027575, + "keen-buffalo-832": 1027576, + "rapid-penguin-838": 1027576, + "agile-boar-988": 1027580, + "deafening-partridge-795": 1027581, + "aware-civet-41": 1027581, + "amiable-akita-237": 1027599, + "capable-oriole-512": 1027601, + "dashing-caiman-896": 1027614, + "famous-peacock-18": 1027615, + "nautical-cow-225": 1027616, + "outgoing-armadillo-580": 1027619, + "affable-chinchilla-84": 1027620, + "original-viper-45": 1027621, + "dutiful-roadrunner-315": 1027622, + "helpful-moose-431": 1027623, + "glad-dolphin-261": 1027623, + "colorless-eel-651": 1027633, + "notable-gull-25": 1027634, + "capable-frog-116": 1027640, + "fantastic-wombat-679": 1027641, + "utmost-badger-262": 1027651, + "rugged-stork-168": 1027652, + "tacit-leopard-580": 1027658, + "harmless-loris-762": 1027660, + "focused-kingfisher-790": 1027660, + "brilliant-anteater-136": 1027683, + "chatty-echidna-441": 1027684, + "youthful-dogfish-123": 1027684, + "flippant-ermine-652": 1027712, + "ardent-cobra-732": 1027713, + "determined-dogfish-96": 1027713, + "curious-fly-409": 1027715, + "handsome-mallard-608": 1027717, + "dependable-lark-620": 1027723, + "kindred-squirrel-677": 1027726, + "strong-eel-852": 1027727, + "successful-sparrow-774": 1027728, + "elegant-pig-885": 1027729, + "merry-deer-425": 1027730, + "hearty-mule-305": 1027747, + "disciplined-buzzard-944": 1027748, + "aromatic-possum-626": 1027749, + "incredible-mongoose-450": 1027750, + "industrious-dalmatian-972": 1027750, + "outgoing-toucan-962": 1027796, + "intent-cat-625": 1027797, + "calculating-lobster-402": 1027818, + "cheerful-avocet-246": 1027819, + "moonlit-gnu-711": 1027867, + "colorless-eel-800": 1027868, + "rugged-walrus-149": 1027890, + "content-yak-484": 1027891, + "tough-cricket-963": 1027896, + "striped-finch-745": 1027897, + "kindly-iguana-768": 1027910, + "beloved-swan-883": 1027912, + "tacit-okapi-704": 1027914, + "utmost-koala-193": 1027916, + "agile-cassowary-677": 1027924, + "gallant-avocet-603": 1027925, + "first-alligator-428": 1027925, + "fabulous-dotterel-297": 1027987, + "avid-ibis-95": 1027988, + "incredible-panther-399": 1027988, + "quixotic-ox-725": 1028028, + "shiny-vole-257": 1028029, + "unique-turtle-859": 1028037, + "neat-lion-802": 1028038, + "pastel-cormorant-859": 1028061, + "clear-bird-896": 1028063, + "third-gazelle-405": 1028081, + "knowing-jackal-111": 1028082, + "formal-fly-275": 1028093, + "energized-buzzard-39": 1028094, + "beaming-terrier-865": 1028104, + "patient-sockeye-915": 1028105, + "adamant-zebra-831": 1028118, + "ardent-chihuahua-124": 1028121, + "woozy-shrimp-223": 1028128, + "accurate-goat-136": 1028129, + "hearty-stoat-466": 1028131, + "majestic-pheasant-760": 1028132, + "savory-rook-271": 1028134, + "sensible-roadrunner-366": 1028135, + "harmless-salmon-570": 1028143, + "brainy-crocodile-920": 1028144, + "opulent-ferret-85": 1028147, + "knowing-iguana-737": 1028148, + "pastel-starfish-940": 1028155, + "zany-wombat-927": 1028156, + "merry-clam-784": 1028158, + "handsome-cow-57": 1028159, + "hearty-giraffe-98": 1028185, + "exciting-otter-5": 1028186, + "bold-duck-553": 1028186, + "ardent-impala-354": 1028189, + "expert-magpie-497": 1028190, + "youthful-opossum-456": 1028205, + "curious-partridge-591": 1028206, + "tacit-stork-773": 1028209, + "steady-stingray-116": 1028211, + "blissful-mosquito-501": 1028211, + "giddy-lyrebird-258": 1028217, + "acoustic-bird-425": 1028218, + "savory-alpaca-916": 1028271, + "dazzling-chihuahua-544": 1028272, + "intent-llama-828": 1028272, + "polite-stork-474": 1028292, + "clean-greyhound-771": 1028293, + "hearty-toucan-643": 1028297, + "coordinated-hippopotamus-192": 1028299, + "flippant-finch-430": 1028315, + "precise-cuttlefish-323": 1028317, + "fabulous-quail-201": 1028355, + "standing-hornet-909": 1028356, + "reliable-newt-496": 1028411, + "tame-goldfinch-967": 1028412, + "hushed-retriever-664": 1028412, + "secret-boar-137": 1028488, + "quiet-whale-54": 1028490, + "successful-chicken-402": 1028565, + "precious-parrot-58": 1028566, + "youthful-greyhound-987": 1028593, + "mild-condor-826": 1028594, + "dutiful-wildebeest-981": 1028657, + "vibrant-lemming-901": 1028658, + "calculating-mole-72": 1028664, + "savory-antelope-790": 1028665, + "kindred-scorpion-410": 1028699, + "jovial-guanaco-634": 1028700, + "strong-oyster-61": 1028716, + "good-gecko-988": 1028717, + "vivid-parakeet-780": 1028762, + "fleet-bat-9": 1028763, + "calm-roadrunner-832": 1028763, + "hip-woodpecker-459": 1028795, + "neat-goose-673": 1028796, + "bright-fish-909": 1028803, + "reliable-mammoth-728": 1028804, + "diligent-pony-18": 1028804, + "original-eel-57": 1028821, + "unique-dog-194": 1028822, + "elegant-mole-847": 1028872, + "determined-caiman-976": 1028873, + "neighborly-marten-305": 1028907, + "brilliant-guanaco-783": 1028908, + "outgoing-parakeet-786": 1028908, + "enduring-dragon-690": 1028944, + "kindly-hedgehog-336": 1028945, + "pleasant-pig-526": 1028987, + "focused-salmon-355": 1028988, + "chatty-pika-59": 1028988, + "precise-duck-220": 1028992, + "unique-butterfly-636": 1028993, + "rightful-salamander-525": 1029000, + "energetic-chickadee-838": 1029002, + "coordinated-boar-204": 1029002, + "deafening-donkey-947": 1029007, + "rare-trout-104": 1029008, + "silent-rabbit-848": 1029027, + "silent-ostrich-212": 1029029, + "energetic-emu-136": 1029037, + "kindred-spaniel-479": 1029039, + "youthful-sheep-931": 1029044, + "fine-terrier-463": 1029045, + "small-ocelot-701": 1029055, + "precious-pigeon-475": 1029056, + "amicable-leopard-955": 1029062, + "dutiful-elephant-24": 1029063, + "reliable-anaconda-499": 1029115, + "doting-camel-942": 1029116, + "helpful-crane-428": 1029133, + "superb-bass-877": 1029134, + "adjoining-puma-260": 1029170, + "uncommon-grouse-798": 1029172, + "tangible-squirrel-519": 1029172, + "small-wren-272": 1029220, + "harmless-bulldog-104": 1029221, + "perceptive-buzzard-290": 1029222, + "graceful-basilisk-193": 1029224, + "frugal-toucan-637": 1029225, + "youthful-oyster-744": 1029226, + "third-puma-638": 1029227, + "fabulous-elk-367": 1029228, + "small-stork-373": 1029229, + "wonderful-duck-952": 1029230, + "precise-dog-506": 1029231, + "capable-alpaca-966": 1029232, + "third-mockingbird-269": 1029233, + "brilliant-guineapig-640": 1029234, + "ceaseless-duck-766": 1029235, + "proper-mastiff-693": 1029236, + "impressive-donkey-804": 1029237, + "famous-partridge-998": 1029238, + "incredible-platypus-450": 1029239, + "confident-vulture-888": 1029241, + "third-buffalo-55": 1029250, + "helpful-ox-145": 1029251, + "rugged-horse-400": 1029252, + "small-opossum-468": 1029253, + "kindhearted-reindeer-245": 1029254, + "quick-bird-696": 1029255, + "aware-poodle-62": 1029256, + "perceptive-cricket-961": 1029257, + "keen-bulldog-600": 1029258, + "usable-grasshopper-540": 1029259, + "little-ibis-275": 1029260, + "quixotic-rook-214": 1029261, + "valiant-grasshopper-98": 1029262, + "optimistic-hyena-644": 1029263, + "majestic-moose-117": 1029264, + "majestic-mosquito-349": 1029265, + "dutiful-mosquito-981": 1029266, + "good-chipmunk-201": 1029267, + "wry-peccary-777": 1029268, + "beloved-grasshopper-669": 1029269, + "clean-mink-769": 1029276, + "laudable-armadillo-472": 1029277, + "clever-hummingbird-10": 1029278, + "acrobatic-viper-106": 1029279, + "deafening-hare-4": 1029279, + "gregarious-squid-737": 1029289, + "acoustic-spaniel-978": 1029290, + "decisive-chameleon-82": 1029301, + "neighborly-sturgeon-984": 1029302, + "dependable-canary-890": 1029304, + "watchful-lemur-782": 1029305, + "next-mouse-180": 1029332, + "grandiose-octopus-918": 1029333, + "robust-basilisk-746": 1029365, + "steady-peccary-878": 1029366, + "whimsical-mole-185": 1029372, + "merry-minnow-742": 1029373, + "fastidious-hedgehog-826": 1029374, + "giant-marmot-264": 1029375, + "third-vole-149": 1029376, + "glorious-octopus-866": 1029376, + "energized-dotterel-744": 1029377, + "warmhearted-terrier-567": 1029380, + "deafening-dog-550": 1029381, + "knowing-dragon-489": 1029389, + "quirky-spider-267": 1029467, + "dazzling-axolotl-891": 1029468, + "elegant-sardine-657": 1029504, + "proficient-skunk-458": 1029505, + "moonlit-mole-875": 1029505, + "dazzling-possum-199": 1029510, + "flexible-mockingbird-550": 1029512, + "lovable-mongoose-369": 1029512, + "neat-mouse-65": 1029516, + "bright-bee-11": 1029517, + "majestic-narwhal-253": 1029522, + "honorable-seal-996": 1029523, + "groovy-armadillo-145": 1029526, + "jovial-pheasant-792": 1029527, + "curious-ibex-874": 1029541, + "ardent-gerbil-409": 1029542, + "spotted-chihuahua-735": 1029542, + "benevolent-sturgeon-766": 1029568, + "chatty-moose-828": 1029569, + "majestic-anteater-748": 1029569, + "striped-ox-331": 1029577, + "little-chipmunk-503": 1029578, + "glorious-rat-504": 1029579, + "calculating-bulldog-770": 1029580, + "spotted-duck-192": 1029585, + "courteous-raccoon-758": 1029586, + "modest-parrot-792": 1029592, + "bold-bloodhound-942": 1029593, + "content-gopher-655": 1029598, + "pleasant-kookabura-870": 1029599, + "famous-kiwi-934": 1029600, + "wary-porpoise-59": 1029601, + "uncommon-zebra-693": 1029601, + "loyal-jay-921": 1029606, + "honorable-caiman-79": 1029607, + "tame-camel-983": 1029612, + "resolute-duck-124": 1029613, + "helpful-wren-363": 1029629, + "helpful-chameleon-640": 1029630, + "sleek-chipmunk-202": 1029636, + "posh-spoonbill-81": 1029638, + "astute-finch-79": 1029642, + "dashing-snail-321": 1029643, + "tangible-marlin-207": 1029648, + "lovable-wombat-247": 1029649, + "artful-rabbit-964": 1029651, + "wooden-wolverine-235": 1029652, + "elegant-egret-826": 1029665, + "descriptive-bobcat-890": 1029666, + "enduring-toad-484": 1029668, + "befitting-frog-876": 1029669, + "moonlit-moose-112": 1029678, + "fortunate-chinchilla-539": 1029680, + "scrupulous-herring-721": 1029691, + "acrobatic-caribou-802": 1029692, + "groovy-kudu-44": 1029698, + "chatty-schnauzer-438": 1029699, + "intent-bison-740": 1029711, + "fortunate-moose-471": 1029712, + "moonlit-perch-220": 1029715, + "oceanic-toucan-776": 1029716, + "qualified-boar-724": 1029716, + "effervescent-porcupine-902": 1029725, + "exciting-pig-708": 1029726, + "moonlit-seahorse-182": 1029731, + "handsome-mosquito-765": 1029733, + "brainy-snake-158": 1029739, + "dapper-spider-575": 1029740, + "adamant-lobster-115": 1029741, + "determined-ibis-298": 1029742, + "little-rat-519": 1029745, + "basic-kangaroo-43": 1029747, + "secret-owl-380": 1029750, + "tremendous-goldfinch-46": 1029751, + "determined-antelope-729": 1029763, + "coordinated-gerbil-474": 1029764, + "hushed-meadowlark-688": 1029764, + "successful-penguin-67": 1029765, + "keen-rhinoceros-404": 1029766, + "fortunate-meerkat-675": 1029766, + "lovable-bird-719": 1029772, + "sensible-hamster-93": 1029773, + "neat-cat-162": 1029789, + "quixotic-bobcat-872": 1029791, + "content-mandrill-546": 1029791, + "modest-parakeet-66": 1029803, + "vivid-eagle-199": 1029804, + "brainy-stingray-890": 1029808, + "original-bass-911": 1029809, + "accurate-sardine-706": 1029821, + "curious-reindeer-973": 1029822, + "handsome-dalmatian-582": 1029824, + "secret-sockeye-555": 1029825, + "optimistic-puffin-283": 1029840, + "blissful-hare-194": 1029841, + "whimsical-bat-405": 1029841, + "marvelous-partridge-411": 1029843, + "tremendous-possum-97": 1029845, + "colorful-bandicoot-659": 1029851, + "sleek-ermine-222": 1029852, + "harmless-basilisk-231": 1029883, + "grateful-hound-352": 1029884, + "chatty-hare-84": 1029903, + "qualified-marmot-896": 1029904, + "ceaseless-rook-843": 1029905, + "energized-hawk-760": 1029906, + "whimsical-perch-108": 1029914, + "vibrant-tiger-571": 1029916, + "oceanic-curlew-301": 1029918, + "mellow-shark-119": 1029919, + "adjoining-perch-256": 1029922, + "prestigious-spider-905": 1029923, + "loyal-lobster-415": 1029928, + "brainy-robin-274": 1029929, + "decisive-blackbird-566": 1029935, + "usable-robin-917": 1029936, + "uncommon-pigeon-990": 1029937, + "coordinated-weasel-658": 1029938, + "trustworthy-antelope-284": 1029938, + "limitless-chickadee-336": 1029951, + "enduring-chameleon-606": 1029952, + "capable-camel-681": 1029954, + "fearless-gerbil-980": 1029955, + "glorious-hummingbird-771": 1029973, + "judicious-wombat-551": 1029974, + "exuberant-otter-461": 1029978, + "opulent-starfish-9": 1029979, + "impressive-salmon-156": 1029982, + "effervescent-buffalo-713": 1029983, + "giant-wildcat-88": 1029984, + "acrobatic-flamingo-423": 1029985, + "courteous-jaguar-13": 1029985, + "blissful-crab-531": 1030013, + "enduring-hound-20": 1030014, + "giddy-iguana-119": 1030023, + "pastel-magpie-885": 1030024, + "grandiose-gnu-797": 1030026, + "precise-frog-285": 1030028, + "clever-impala-779": 1030035, + "knowing-tiger-536": 1030036, + "peaceful-frog-735": 1030042, + "honorable-possum-961": 1030044, + "elated-blackbird-709": 1030045, + "laudable-mole-608": 1030046, + "artful-rat-157": 1030046, + "healthy-mammoth-654": 1030049, + "kindhearted-poodle-396": 1030050, + "quaint-chicken-140": 1030057, + "rare-pigeon-477": 1030058, + "lovable-duck-996": 1030060, + "quirky-rook-270": 1030061, + "hallowed-dotterel-260": 1030061, + "zany-raccoon-282": 1030081, + "exciting-robin-200": 1030082, + "strong-goldfinch-404": 1030083, + "judicious-skunk-937": 1030084, + "formal-hamster-927": 1030091, + "impartial-lapwing-811": 1030093, + "elated-salamander-276": 1030099, + "determined-rabbit-589": 1030100, + "kindhearted-pig-988": 1030101, + "neat-jellyfish-409": 1030102, + "scintillating-vole-323": 1030108, + "tremendous-mosquito-801": 1030109, + "wonderful-guanaco-912": 1030117, + "resolute-pony-969": 1030118, + "original-echidna-460": 1030120, + "knowing-ermine-809": 1030121, + "quaint-ox-159": 1030125, + "wary-sardine-456": 1030126, + "opulent-goose-207": 1030126, + "gallant-hummingbird-481": 1030133, + "flexible-mockingbird-614": 1030135, + "successful-bass-467": 1030135, + "greedy-malamute-526": 1030137, + "agile-bee-365": 1030138, + "precious-hamster-443": 1030139, + "robust-frog-534": 1030140, + "hardy-warbler-407": 1030166, + "different-viper-355": 1030167, + "shiny-goose-985": 1030180, + "silent-antelope-439": 1030181, + "amicable-sandpiper-375": 1030187, + "pleasant-mandrill-479": 1030188, + "frugal-guanaco-44": 1030193, + "amicable-perch-336": 1030194, + "veracious-firefly-253": 1030216, + "fine-egret-216": 1030217, + "tough-rabbit-213": 1030220, + "blissful-owl-285": 1030221, + "formal-lapwing-980": 1030231, + "utmost-pheasant-254": 1030232, + "combative-cardinal-72": 1030232, + "charming-ant-72": 1030233, + "quiet-ermine-757": 1030235, + "polite-shepherd-704": 1030235, + "peaceful-gazelle-805": 1030240, + "quaint-spoonbill-136": 1030241, + "clean-squid-683": 1030243, + "graceful-sturgeon-957": 1030245, + "necessary-puffin-175": 1030255, + "bright-pigeon-960": 1030256, + "peaceful-squid-253": 1030256, + "gregarious-anaconda-561": 1030260, + "rare-narwhal-462": 1030261, + "confident-dove-837": 1030274, + "accurate-wombat-980": 1030276, + "agreeable-oriole-640": 1030278, + "adorable-rhinoceros-550": 1030279, + "mild-lobster-387": 1030281, + "tremendous-labrador-448": 1030283, + "festive-dinosaur-61": 1030285, + "incredible-panda-515": 1030286, + "hidden-snail-144": 1030295, + "tame-raccoon-974": 1030297, + "energetic-hamster-606": 1030307, + "neat-capybara-419": 1030308, + "academic-caterpillar-743": 1030321, + "graceful-retriever-661": 1030322, + "unique-chipmunk-95": 1030322, + "befitting-meadowlark-145": 1030328, + "hardy-koala-274": 1030329, + "adamant-vulture-265": 1030334, + "blissful-porpoise-270": 1030335, + "shiny-ostrich-935": 1030350, + "energetic-caterpillar-785": 1030351, + "marvelous-porcupine-889": 1030351, + "reliable-marten-711": 1030355, + "next-firefly-133": 1030356, + "quaint-crane-420": 1030357, + "friendly-squirrel-58": 1030357, + "knowing-lobster-457": 1030358, + "impartial-sockeye-702": 1030364, + "oceanic-mule-597": 1030366, + "formal-otter-625": 1030366, + "secret-dodo-947": 1030370, + "blissful-sturgeon-231": 1030371, + "nautical-caribou-256": 1030376, + "accurate-ladybug-695": 1030377, + "mild-minnow-871": 1030377, + "glorious-wombat-837": 1030380, + "little-canary-416": 1030381, + "bold-jay-196": 1030381, + "colorful-sockeye-819": 1030383, + "colorful-gazelle-156": 1030384, + "opulent-dog-984": 1030392, + "chatty-orca-208": 1030394, + "fleet-herring-792": 1030394, + "dynamic-anaconda-47": 1030395, + "exuberant-ibex-19": 1030396, + "blessed-meadowlark-353": 1030397, + "lovable-swordfish-131": 1030398, + "quiet-gnat-931": 1030409, + "tremendous-caiman-951": 1030410, + "groovy-gull-872": 1030416, + "sensible-hawk-748": 1030417, + "frugal-squid-936": 1030432, + "resolute-condor-726": 1030433, + "friendly-chihuahua-627": 1030433, + "content-orca-298": 1030438, + "tough-goshawk-479": 1030439, + "wooden-buffalo-96": 1030448, + "elegant-toad-42": 1030449, + "dazzling-armadillo-225": 1030451, + "adjoining-lobster-954": 1030452, + "careful-giraffe-50": 1030452, + "colorless-axolotl-613": 1030458, + "limitless-swan-845": 1030460, + "effervescent-oyster-549": 1030471, + "aware-orca-299": 1030472, + "spotted-dinosaur-971": 1030475, + "fortunate-dolphin-8": 1030476, + "accomplished-roadrunner-718": 1030479, + "blessed-coyote-639": 1030480, + "neighborly-dotterel-228": 1030494, + "dazzling-raven-916": 1030495, + "different-stork-599": 1030501, + "keen-mole-528": 1030502, + "unique-moose-855": 1030509, + "fiery-raccoon-514": 1030510, + "resilient-snake-988": 1030514, + "mellow-buffalo-222": 1030515, + "quixotic-woodpecker-799": 1030517, + "academic-weasel-620": 1030518, + "enduring-cuttlefish-20": 1030521, + "impressive-badger-553": 1030522, + "next-mallard-458": 1030527, + "enchanted-reindeer-744": 1030528, + "valuable-toucan-727": 1030531, + "gregarious-cormorant-430": 1030533, + "veracious-wren-305": 1030536, + "earnest-snake-203": 1030537, + "confident-anaconda-852": 1030543, + "focused-wildebeest-272": 1030544, + "grateful-elk-870": 1030545, + "benevolent-ram-740": 1030546, + "fantastic-lynx-899": 1030548, + "disciplined-antelope-355": 1030549, + "neighborly-mule-486": 1030550, + "hearty-giraffe-101": 1030551, + "clever-husky-268": 1030551, + "notable-spoonbill-443": 1030553, + "robust-caribou-99": 1030554, + "successful-stork-311": 1030559, + "dapper-antelope-857": 1030560, + "usable-camel-468": 1030564, + "hip-snake-908": 1030565, + "agreeable-blackbird-381": 1030566, + "courteous-starfish-342": 1030567, + "kindred-wolf-96": 1030572, + "little-lynx-399": 1030573, + "exuberant-gecko-491": 1030584, + "benevolent-jaguar-465": 1030585, + "sensible-lark-335": 1030591, + "first-wolverine-300": 1030593, + "beloved-shrimp-904": 1030601, + "perceptive-dove-205": 1030603, + "fortunate-mammoth-944": 1030606, + "sincere-zebra-701": 1030607, + "enduring-mammoth-524": 1030615, + "cool-aardvark-677": 1030616, + "cheery-mink-273": 1030636, + "scintillating-barracuda-374": 1030637, + "harmless-mole-196": 1030638, + "tough-rook-83": 1030655, + "bold-ram-281": 1030656, + "affable-blackbird-649": 1030657, + "vivid-wolverine-219": 1030658, + "outstanding-finch-19": 1030658, + "quick-crane-623": 1030664, + "elated-dove-922": 1030665, + "acoustic-peccary-423": 1030666, + "vivid-perch-468": 1030667, + "zealous-wombat-189": 1030669, + "brainy-snake-219": 1030670, + "neat-gnat-575": 1030675, + "knowing-toucan-668": 1030676, + "scintillating-oriole-66": 1030682, + "sensible-parakeet-718": 1030683, + "bright-badger-991": 1030704, + "reminiscent-pig-632": 1030705, + "proficient-pelican-756": 1030718, + "grandiose-bee-671": 1030721, + "strong-mole-573": 1030723, + "neat-sparrow-665": 1030724, + "glad-poodle-248": 1030727, + "hip-goose-650": 1030728, + "descriptive-gull-606": 1030737, + "quaint-hawk-775": 1030738, + "animated-cormorant-629": 1030739, + "diligent-elephant-740": 1030742, + "sincere-vulture-4": 1030763, + "tremendous-caiman-570": 1030765, + "clever-echidna-597": 1030767, + "next-kiwi-208": 1030768, + "limitless-scorpion-973": 1030770, + "clear-partridge-101": 1030771, + "wandering-mastiff-143": 1030790, + "polished-bandicoot-388": 1030791, + "merry-orca-76": 1030826, + "formal-mockingbird-559": 1030827, + "avid-oyster-537": 1030835, + "gallant-gecko-836": 1030836, + "accurate-grasshopper-849": 1030847, + "incredible-mink-132": 1030848, + "polite-bat-468": 1030901, + "cheery-bandicoot-947": 1030903, + "opulent-swan-841": 1030903, + "good-yak-549": 1030911, + "posh-spaniel-366": 1030912, + "effervescent-herring-941": 1030921, + "vibrant-nightingale-139": 1030922, + "outgoing-echidna-300": 1030930, + "cautious-antelope-201": 1030931, + "canny-schnauzer-430": 1030933, + "honorable-gazelle-140": 1030934, + "blessed-crane-823": 1030982, + "disciplined-wolf-757": 1030983, + "merry-crow-704": 1031002, + "artful-clam-387": 1031003, + "animated-hedgehog-357": 1031006, + "groovy-ibis-346": 1031007, + "optimistic-otter-249": 1031014, + "groovy-corgi-418": 1031015, + "wandering-gerbil-191": 1031022, + "moonlit-badger-769": 1031023, + "zany-snake-56": 1031039, + "quixotic-narwhal-36": 1031041, + "usable-grouse-439": 1031042, + "mild-porcupine-547": 1031043, + "giddy-orca-228": 1031046, + "famous-gull-627": 1031048, + "adamant-duck-476": 1031048, + "utmost-chihuahua-298": 1031061, + "impartial-herring-739": 1031062, + "neat-quail-657": 1031067, + "utmost-anteater-239": 1031068, + "amiable-starling-532": 1031069, + "moonlit-albatross-627": 1031071, + "compassionate-alpaca-805": 1031072, + "rugged-minnow-487": 1031074, + "beloved-buffalo-502": 1031074, + "doting-ptarmigan-39": 1031075, + "keen-alpaca-945": 1031076, + "uncommon-moose-681": 1031077, + "ideal-spoonbill-127": 1031078, + "shiny-boar-149": 1031078, + "chatty-bullfrog-694": 1031080, + "bold-walrus-923": 1031081, + "hip-blackbird-425": 1031082, + "wooden-oyster-727": 1031083, + "good-canary-970": 1031089, + "brazen-puma-699": 1031090, + "fearless-iguana-487": 1031093, + "compassionate-buffalo-496": 1031094, + "avid-possum-684": 1031098, + "animated-raven-119": 1031099, + "rare-puffin-33": 1031107, + "zany-armadillo-141": 1031108, + "greedy-sockeye-687": 1031108, + "basic-akita-920": 1031110, + "exciting-greyhound-628": 1031111, + "successful-anaconda-381": 1031115, + "veracious-gnat-377": 1031116, + "polished-rook-623": 1031118, + "fearless-clam-232": 1031119, + "acoustic-kangaroo-113": 1031120, + "acrobatic-peacock-351": 1031121, + "fantastic-hippopotamus-771": 1031125, + "strong-cormorant-909": 1031126, + "shiny-ladybug-465": 1031130, + "determined-deer-150": 1031131, + "rare-narwhal-94": 1031131, + "decisive-dalmatian-440": 1031132, + "agile-crow-829": 1031133, + "dependable-guanaco-277": 1031137, + "brave-spaniel-138": 1031138, + "acrobatic-okapi-602": 1031142, + "calculating-bloodhound-403": 1031143, + "adorable-chicken-798": 1031153, + "hidden-wren-927": 1031154, + "grateful-minnow-89": 1031161, + "frugal-meadowlark-844": 1031162, + "calm-porcupine-239": 1031170, + "fabulous-bandicoot-374": 1031171, + "quiet-grasshopper-967": 1031172, + "fearless-vole-612": 1031173, + "intent-civet-471": 1031173, + "glorious-dachshund-964": 1031181, + "accurate-kookabura-986": 1031182, + "third-wildebeest-51": 1031205, + "polite-rabbit-34": 1031207, + "animated-fly-134": 1031207, + "efficient-mammoth-829": 1031215, + "focused-hornet-207": 1031216, + "combative-camel-14": 1031224, + "earnest-albatross-142": 1031225, + "adorable-duck-824": 1031228, + "earnest-blackbird-333": 1031229, + "fast-giraffe-632": 1031255, + "harmless-chameleon-167": 1031256, + "marvelous-egret-571": 1031279, + "harmless-mammoth-613": 1031280, + "benevolent-chihuahua-447": 1031286, + "rapid-loris-38": 1031286, + "accurate-elk-345": 1031303, + "aromatic-guanaco-487": 1031304, + "cool-newt-184": 1031304, + "resilient-starfish-287": 1031322, + "original-labrador-419": 1031323, + "glorious-lark-867": 1031335, + "moonlit-pony-723": 1031336, + "moonlit-lemming-597": 1031338, + "dependable-meerkat-263": 1031339, + "insightful-goshawk-337": 1031339, + "woozy-chicken-505": 1031340, + "patient-jackal-782": 1031341, + "patient-chihuahua-262": 1031343, + "proficient-bandicoot-91": 1031344, + "grandiose-wren-316": 1031366, + "chatty-chickadee-606": 1031367, + "impartial-cuttlefish-268": 1031380, + "youthful-gull-266": 1031381, + "qualified-terrier-539": 1031386, + "impressive-snake-104": 1031387, + "wandering-dodo-955": 1031390, + "colorless-capybara-263": 1031394, + "rare-newt-901": 1031404, + "dusty-lemur-166": 1031405, + "academic-mockingbird-837": 1031412, + "cautious-tern-770": 1031413, + "quiet-donkey-836": 1031413, + "friendly-kudu-976": 1031444, + "aware-caribou-665": 1031445, + "scrupulous-crow-879": 1031457, + "acrobatic-lynx-92": 1031458, + "sleek-lapwing-464": 1031460, + "enchanted-reindeer-959": 1031461, + "brazen-mink-756": 1031461, + "lovable-armadillo-740": 1031473, + "clear-flamingo-113": 1031474, + "original-duck-150": 1031474, + "silent-flamingo-892": 1031483, + "valiant-albatross-768": 1031484, + "mellow-tiger-899": 1031484, + "qualified-greyhound-506": 1031488, + "adamant-akita-634": 1031489, + "brave-sandpiper-670": 1031512, + "expert-robin-33": 1031513, + "lovely-snake-79": 1031526, + "sleek-partridge-994": 1031529, + "mild-viper-203": 1031532, + "proper-orca-4": 1031533, + "unique-flamingo-842": 1031539, + "notable-lark-568": 1031541, + "honorable-salamander-31": 1031542, + "loyal-husky-641": 1031568, + "incredible-ostrich-376": 1031569, + "scintillating-finch-380": 1031572, + "watchful-dog-693": 1031573, + "clear-goat-610": 1031578, + "grandiose-gull-829": 1031579, + "outstanding-ox-965": 1031582, + "giant-buzzard-647": 1031583, + "ardent-zebra-84": 1031595, + "colorful-leopard-349": 1031596, + "ardent-ocelot-576": 1031601, + "laudable-fly-586": 1031602, + "rare-kookabura-644": 1031603, + "dusty-squid-636": 1031604, + "hushed-frog-5": 1031609, + "wooden-okapi-693": 1031610, + "impressive-wildcat-560": 1031610, + "beaming-crab-838": 1031618, + "reminiscent-woodpecker-942": 1031619, + "fine-anteater-698": 1031619, + "calm-possum-720": 1031624, + "neat-hound-551": 1031625, + "majestic-eel-175": 1031629, + "accurate-warbler-556": 1031630, + "zealous-gnu-790": 1031630, + "little-cardinal-634": 1031632, + "peaceful-minnow-69": 1031633, + "clear-elk-552": 1031633, + "pastel-greyhound-293": 1031640, + "fabulous-fox-423": 1031641, + "expert-tern-243": 1031641, + "incredible-horse-699": 1031645, + "brilliant-mole-504": 1031646, + "beloved-starling-465": 1031656, + "adept-schnauzer-802": 1031657, + "cautious-bird-746": 1031659, + "good-oriole-891": 1031660, + "tacit-hedgehog-314": 1031660, + "agreeable-hummingbird-545": 1031665, + "dynamic-hyena-271": 1031666, + "cool-cassowary-278": 1031667, + "striped-chipmunk-224": 1031668, + "strong-pelican-607": 1031682, + "kindly-peacock-681": 1031683, + "sleek-dragon-472": 1031694, + "fantastic-peccary-292": 1031695, + "dynamic-rabbit-168": 1031699, + "merry-marten-888": 1031700, + "determined-goshawk-911": 1031707, + "enduring-leopard-639": 1031708, + "warmhearted-dodo-298": 1031715, + "opulent-seal-532": 1031717, + "content-panther-190": 1031718, + "polite-dog-820": 1031720, + "pastel-sheep-446": 1031740, + "tame-pika-220": 1031741, + "coordinated-peccary-688": 1031744, + "patient-chickadee-842": 1031745, + "hushed-cat-860": 1031745, + "gallant-whale-317": 1031754, + "zealous-kangaroo-138": 1031755, + "calm-monitor-600": 1031768, + "adorable-robin-609": 1031769, + "insightful-pony-708": 1031769, + "kindred-aardvark-77": 1031781, + "pleasant-owl-446": 1031783, + "descriptive-dachshund-91": 1031797, + "superb-guineapig-203": 1031798, + "clever-spider-671": 1031798, + "befitting-vole-709": 1031809, + "agreeable-pig-682": 1031810, + "animated-fennec-329": 1031811, + "fleet-peacock-811": 1031812, + "precious-salamander-555": 1031813, + "small-dragon-436": 1031814, + "perfect-dolphin-872": 1031815, + "terrific-ox-477": 1031816, + "enchanted-parrot-997": 1031817, + "compassionate-peccary-879": 1031818, + "determined-weasel-821": 1031819, + "neighborly-loris-756": 1031820, + "valuable-salmon-613": 1031821, + "tacit-hummingbird-612": 1031822, + "efficient-pheasant-485": 1031823, + "doting-hedgehog-160": 1031824, + "veracious-frog-528": 1031825, + "outgoing-mole-206": 1031826, + "exciting-bear-278": 1031827, + "reminiscent-emu-869": 1031828, + "doting-goldfinch-423": 1031829, + "expert-ferret-781": 1031843, + "handsome-panda-458": 1031844, + "jovial-anteater-911": 1031848, + "tidy-rhinoceros-816": 1031850, + "dapper-ostrich-683": 1031855, + "nautical-whale-516": 1031856, + "wandering-deer-339": 1031856, + "exuberant-hamster-345": 1031858, + "festive-pelican-528": 1031859, + "secret-toad-304": 1031879, + "vibrant-finch-289": 1031880, + "upbeat-porpoise-913": 1031880, + "successful-perch-279": 1031881, + "brainy-hound-250": 1031882, + "woozy-barracuda-922": 1031895, + "nautical-chipmunk-228": 1031897, + "outgoing-fennec-419": 1031899, + "scintillating-dove-851": 1031900, + "giant-octopus-229": 1031900, + "mellow-goldfish-842": 1031910, + "friendly-manatee-302": 1031911, + "savory-giraffe-462": 1031923, + "posh-chickadee-126": 1031924, + "resilient-jaguar-399": 1031924, + "abundant-viper-53": 1031930, + "kindred-cobra-697": 1031931, + "wonderful-crane-489": 1031944, + "quirky-mongoose-500": 1031945, + "academic-okapi-144": 1031946, + "perceptive-gerbil-465": 1031947, + "knowing-zebra-728": 1031948, + "agreeable-barracuda-220": 1031949, + "quick-spaniel-276": 1031950, + "secret-ptarmigan-734": 1031951, + "colorful-marmot-407": 1031952, + "proper-chickadee-239": 1031953, + "blissful-tortoise-18": 1031954, + "agreeable-giraffe-611": 1031955, + "perfect-tern-567": 1031956, + "cheerful-opossum-997": 1031957, + "quiet-meerkat-125": 1031958, + "watchful-chameleon-138": 1031959, + "admired-clam-34": 1031960, + "fiery-snake-822": 1031961, + "scrupulous-cricket-796": 1031962, + "patient-dachshund-970": 1031963, + "scintillating-akita-274": 1031974, + "perceptive-moose-89": 1031976, + "fine-hare-109": 1031976, + "elated-vole-27": 1031980, + "utmost-grouse-206": 1031981, + "fantastic-mongoose-545": 1031981, + "effervescent-goshawk-179": 1031990, + "chatty-canary-466": 1031991, + "cheery-cod-292": 1032009, + "precious-mole-264": 1032011, + "dapper-labrador-2": 1032034, + "kindhearted-mandrill-811": 1032035, + "hidden-roadrunner-392": 1032039, + "proficient-goose-445": 1032041, + "fine-kingfisher-515": 1032044, + "vivid-greyhound-301": 1032044, + "watchful-elephant-891": 1032060, + "strong-meadowlark-259": 1032062, + "quirky-bear-128": 1032076, + "mellow-zebra-271": 1032077, + "stoic-dragon-237": 1032090, + "energetic-goldfish-632": 1032091, + "cheerful-sheep-995": 1032091, + "peaceful-fly-255": 1032114, + "mellow-grouse-440": 1032115, + "upbeat-mammoth-534": 1032115, + "optimistic-mouse-666": 1032125, + "insightful-curlew-11": 1032126, + "dynamic-robin-912": 1032131, + "flexible-guineapig-553": 1032133, + "kindly-albatross-546": 1032134, + "silent-corgi-520": 1032135, + "energized-turtle-730": 1032140, + "pleasant-mongoose-228": 1032142, + "handsome-mole-349": 1032142, + "fiery-gopher-957": 1032157, + "marvelous-sockeye-77": 1032158, + "oceanic-emu-117": 1032165, + "academic-kingfisher-900": 1032166, + "terrific-newt-660": 1032167, + "zealous-panther-155": 1032168, + "shocking-leopard-52": 1032171, + "kindhearted-badger-827": 1032172, + "insightful-trout-988": 1032176, + "utmost-giraffe-883": 1032177, + "first-falcon-714": 1032198, + "merry-sandpiper-110": 1032199, + "prestigious-horse-956": 1032203, + "trustworthy-axolotl-199": 1032204, + "exuberant-bulldog-715": 1032242, + "zany-sardine-9": 1032243, + "fiery-shepherd-557": 1032251, + "acoustic-vulture-731": 1032253, + "utmost-blackbird-629": 1032253, + "adept-boar-130": 1032258, + "greedy-eagle-930": 1032259, + "fortunate-dolphin-123": 1032268, + "hallowed-possum-368": 1032271, + "descriptive-coyote-184": 1032272, + "grandiose-ptarmigan-291": 1032273, + "superb-meadowlark-781": 1032329, + "groovy-wolf-541": 1032331, + "tangible-sockeye-806": 1032331, + "rugged-chickadee-82": 1032333, + "hip-elephant-790": 1032334, + "little-bullfrog-291": 1032335, + "merry-donkey-545": 1032336, + "zealous-horse-958": 1032337, + "keen-fly-470": 1032338, + "calculating-toucan-743": 1032345, + "colorless-seahorse-22": 1032346, + "original-goshawk-576": 1032359, + "loyal-fish-323": 1032360, + "enchanted-otter-125": 1032362, + "dazzling-grouse-461": 1032363, + "formal-cormorant-479": 1032365, + "hidden-stingray-20": 1032366, + "fleet-ibis-198": 1032375, + "focused-goshawk-881": 1032376, + "rapid-koala-781": 1032382, + "pastel-cobra-572": 1032383, + "enchanted-clownfish-685": 1032387, + "expert-gazelle-831": 1032389, + "tidy-seal-527": 1032393, + "fearless-raccoon-176": 1032394, + "benevolent-wombat-581": 1032397, + "dazzling-dragon-314": 1032398, + "handsome-raven-544": 1032398, + "incredible-crane-928": 1032411, + "adorable-puma-622": 1032412, + "scrupulous-sheep-692": 1032435, + "watchful-lobster-921": 1032436, + "neat-pika-637": 1032446, + "formal-puma-374": 1032447, + "marvelous-elk-72": 1032447, + "flexible-turtle-870": 1032456, + "disciplined-possum-111": 1032457, + "fleet-ox-317": 1032466, + "wandering-gnat-505": 1032467, + "lovely-marten-858": 1032467, + "kindhearted-lemming-943": 1032493, + "rapid-hippopotamus-90": 1032494, + "scintillating-robin-915": 1032494, + "deafening-seahorse-270": 1032503, + "quixotic-kookabura-14": 1032504, + "decisive-malamute-504": 1032513, + "hip-tern-679": 1032514, + "oceanic-jellyfish-772": 1032517, + "prestigious-dinosaur-356": 1032519, + "clean-goose-309": 1032519, + "curious-jay-282": 1032525, + "grateful-dinosaur-499": 1032526, + "courteous-panda-248": 1032531, + "accomplished-kiwi-406": 1032533, + "adept-viper-629": 1032539, + "hip-setter-986": 1032541, + "clear-lobster-657": 1032541, + "striped-butterfly-194": 1032551, + "rightful-loris-415": 1032552, + "fantastic-pika-601": 1032558, + "striped-egret-224": 1032560, + "precious-yak-502": 1032580, + "hallowed-dinosaur-743": 1032581, + "neat-perch-483": 1032591, + "abundant-gnu-487": 1032592, + "earnest-mongoose-607": 1032592, + "dependable-vole-360": 1032601, + "steady-salamander-155": 1032602, + "content-seal-937": 1032602, + "beloved-grasshopper-409": 1032617, + "useful-cow-201": 1032618, + "glad-marmot-511": 1032619, + "lovely-penguin-725": 1032621, + "courteous-puma-415": 1032621, + "uncommon-caribou-186": 1032632, + "oceanic-pelican-936": 1032634, + "small-armadillo-703": 1032634, + "fabulous-turtle-502": 1032648, + "sleek-albatross-773": 1032650, + "rightful-mule-373": 1032660, + "outgoing-quail-391": 1032661, + "qualified-crocodile-169": 1032666, + "modest-setter-99": 1032667, + "reliable-peacock-462": 1032685, + "flexible-raven-453": 1032686, + "grateful-heron-508": 1032723, + "friendly-platypus-638": 1032724, + "polished-crab-375": 1032741, + "wandering-terrier-784": 1032742, + "festive-alligator-186": 1032762, + "rosy-marlin-130": 1032763, + "quirky-basilisk-729": 1032763, + "combative-okapi-163": 1032778, + "tangible-grasshopper-182": 1032779, + "stoic-anaconda-725": 1032797, + "fearless-terrier-751": 1032800, + "robust-salmon-456": 1032801, + "first-camel-789": 1032803, + "rapid-sockeye-640": 1032808, + "precise-camel-846": 1032809, + "scintillating-bobcat-965": 1032810, + "youthful-koala-411": 1032813, + "rugged-clownfish-581": 1032813, + "aromatic-narwhal-688": 1032821, + "lovable-rat-457": 1032822, + "dynamic-ox-589": 1032841, + "zealous-snake-221": 1032843, + "peaceful-marlin-357": 1032857, + "capable-dachshund-520": 1032860, + "careful-pig-485": 1032862, + "original-viper-819": 1032863, + "fabulous-turtle-950": 1032864, + "brainy-beagle-630": 1032865, + "nautical-penguin-365": 1032877, + "warmhearted-opossum-333": 1032878, + "aromatic-alpaca-134": 1032901, + "sincere-opossum-151": 1032902, + "fortunate-hornet-257": 1032910, + "jovial-firefly-308": 1032911, + "pastel-rook-240": 1032932, + "perceptive-viper-774": 1032933, + "elegant-platypus-668": 1032933, + "helpful-seal-593": 1032938, + "quaint-crow-368": 1032939, + "canny-sparrow-705": 1032947, + "dapper-pelican-29": 1032948, + "affable-bison-183": 1032948, + "aware-hummingbird-742": 1032949, + "confident-albatross-156": 1032950, + "giant-porcupine-116": 1032956, + "determined-cormorant-810": 1032957, + "amiable-cat-809": 1032983, + "fabulous-blackbird-819": 1032984, + "ideal-gecko-157": 1032999, + "famous-snail-814": 1033000, + "cheery-platypus-648": 1033002, + "veracious-alligator-136": 1033004, + "unique-ocelot-305": 1033005, + "outstanding-labrador-459": 1033006, + "grateful-peccary-783": 1033017, + "basic-partridge-207": 1033018, + "abundant-seahorse-830": 1033021, + "fortunate-porcupine-589": 1033022, + "joyous-impala-237": 1033022, + "tidy-badger-193": 1033025, + "colorful-hornet-980": 1033027, + "marvelous-armadillo-196": 1033048, + "agreeable-blackbird-748": 1033077, + "rapid-salamander-948": 1033078, + "watchful-goshawk-563": 1033083, + "standing-otter-987": 1033114, + "spotted-gopher-886": 1033115, + "savory-chickadee-711": 1033118, + "cheerful-greyhound-641": 1033120, + "adept-capybara-21": 1033120, + "affable-stoat-540": 1033122, + "festive-ibis-938": 1033123, + "effervescent-toucan-872": 1033126, + "intent-chinchilla-554": 1033128, + "veracious-bandicoot-440": 1033129, + "grand-wildcat-728": 1033130, + "lovely-oriole-416": 1033133, + "quaint-dove-277": 1033136, + "strong-stork-70": 1033143, + "astute-greyhound-843": 1033144, + "cool-oyster-854": 1033148, + "confident-gull-913": 1033149, + "abundant-opossum-951": 1033152, + "outstanding-donkey-62": 1033153, + "fiery-aardvark-41": 1033153, + "rosy-albatross-650": 1033168, + "proficient-falcon-343": 1033169, + "honorable-dolphin-186": 1033170, + "strong-hare-560": 1033171, + "knowing-poodle-598": 1033173, + "moonlit-walrus-348": 1033175, + "cool-ox-566": 1033185, + "upbeat-gecko-380": 1033187, + "oceanic-raccoon-990": 1033193, + "posh-ocelot-479": 1033194, + "rosy-blackbird-849": 1033195, + "rightful-wolf-660": 1033198, + "sensible-rooster-659": 1033210, + "shocking-nightingale-419": 1033211, + "kindhearted-raven-407": 1033212, + "adjoining-fennec-616": 1033213, + "original-aardvark-682": 1033214, + "wooden-heron-131": 1033216, + "flexible-zebra-352": 1033222, + "effervescent-malamute-712": 1033223, + "affable-donkey-103": 1033239, + "dependable-mole-326": 1033240, + "giant-robin-232": 1033246, + "quaint-toad-772": 1033247, + "opulent-woodpecker-231": 1033248, + "efficient-mammoth-673": 1033249, + "calculating-terrier-555": 1033253, + "necessary-lark-470": 1033254, + "adjoining-spider-696": 1033275, + "fastidious-dinosaur-856": 1033276, + "frugal-bat-544": 1033277, + "woozy-cardinal-2": 1033278, + "third-hyena-525": 1033284, + "descriptive-wombat-219": 1033285, + "charming-mallard-993": 1033289, + "usable-antelope-423": 1033290, + "fabulous-albatross-17": 1033291, + "usable-goose-904": 1033292, + "ideal-chicken-692": 1033327, + "loyal-salmon-723": 1033329, + "ceaseless-dolphin-795": 1033329, + "shiny-gazelle-881": 1033331, + "calm-marten-677": 1033332, + "jovial-narwhal-717": 1033333, + "dusty-kookabura-646": 1033334, + "optimistic-pony-172": 1033339, + "fine-wildebeest-395": 1033340, + "original-goldfish-16": 1033342, + "handsome-otter-926": 1033343, + "graceful-badger-674": 1033344, + "moonlit-otter-981": 1033351, + "standing-toad-510": 1033357, + "handsome-possum-50": 1033358, + "next-squid-303": 1033366, + "good-rabbit-530": 1033367, + "colorful-coyote-524": 1033378, + "fiery-pony-970": 1033380, + "fastidious-hummingbird-212": 1033393, + "vivid-firefly-285": 1033395, + "laudable-goshawk-563": 1033395, + "reminiscent-corgi-682": 1033396, + "merry-grasshopper-581": 1033397, + "rare-emu-716": 1033401, + "sleek-marten-472": 1033403, + "blissful-aardvark-778": 1033407, + "glorious-labrador-745": 1033408, + "affable-kingfisher-441": 1033417, + "dusty-meadowlark-433": 1033418, + "accurate-sheep-603": 1033423, + "tangible-ram-640": 1033424, + "courteous-blackbird-755": 1033433, + "chatty-ermine-903": 1033434, + "groovy-mule-527": 1033449, + "hearty-dodo-67": 1033450, + "dazzling-orca-229": 1033450, + "determined-cuttlefish-182": 1033470, + "sensible-elk-763": 1033472, + "aware-cat-317": 1033472, + "quirky-weasel-913": 1033484, + "bold-rook-807": 1033485, + "charming-pika-82": 1033485, + "incredible-crocodile-810": 1033490, + "stoic-weasel-259": 1033491, + "usable-avocet-566": 1033493, + "kindhearted-bison-561": 1033494, + "neighborly-ladybug-319": 1033494, + "peaceful-blackbird-847": 1033499, + "abundant-basilisk-90": 1033500, + "agile-toucan-311": 1033506, + "moonlit-condor-688": 1033507, + "zany-porcupine-52": 1033507, + "dapper-reindeer-384": 1033518, + "dependable-bass-872": 1033519, + "reliable-cobra-837": 1033519, + "neat-cricket-422": 1033520, + "scintillating-okapi-362": 1033521, + "calm-anteater-964": 1033526, + "next-kudu-900": 1033527, + "animated-dinosaur-697": 1033531, + "brilliant-deer-293": 1033532, + "calm-jellyfish-899": 1033532, + "exuberant-rat-896": 1033533, + "quirky-weasel-73": 1033535, + "dusty-rhinoceros-898": 1033535, + "robust-eel-70": 1033548, + "wandering-lark-469": 1033549, + "merry-malamute-709": 1033549, + "accomplished-fish-541": 1033550, + "helpful-spider-762": 1033551, + "accurate-falcon-176": 1033588, + "clever-partridge-782": 1033589, + "clean-firefly-644": 1033589, + "scrupulous-frog-405": 1033619, + "clean-loris-780": 1033620, + "determined-perch-598": 1033620, + "enduring-spoonbill-476": 1033631, + "calculating-orca-490": 1033632, + "agreeable-avocet-296": 1033646, + "clean-flamingo-890": 1033647, + "dapper-parrot-669": 1033664, + "adventurous-hedgehog-941": 1033666, + "clear-peacock-713": 1033691, + "aware-peacock-976": 1033692, + "careful-mule-143": 1033695, + "beloved-guineapig-970": 1033696, + "peaceful-barracuda-216": 1033697, + "intent-toucan-29": 1033698, + "grand-rooster-475": 1033723, + "kindred-lobster-599": 1033724, + "earnest-gnu-495": 1033731, + "festive-jaguar-645": 1033733, + "fleet-shrimp-106": 1033750, + "exciting-ladybug-218": 1033751, + "fearless-ptarmigan-754": 1033756, + "hallowed-snail-24": 1033757, + "lovely-dinosaur-21": 1033761, + "joyous-stork-886": 1033762, + "giddy-rook-378": 1033762, + "festive-pigeon-173": 1033764, + "curious-cow-242": 1033765, + "wary-owl-43": 1033767, + "lovable-possum-313": 1033768, + "precise-curlew-202": 1033777, + "keen-vole-342": 1033778, + "rapid-blackbird-64": 1033787, + "youthful-starfish-574": 1033789, + "greedy-hound-777": 1033798, + "healthy-mandrill-635": 1033799, + "opulent-moose-893": 1033803, + "bold-ocelot-525": 1033804, + "harmless-deer-687": 1033805, + "friendly-kingfisher-974": 1033806, + "zany-chinchilla-59": 1033811, + "usable-poodle-477": 1033812, + "formal-bobcat-37": 1033813, + "doting-chicken-340": 1033814, + "determined-dotterel-463": 1033834, + "neat-akita-559": 1033835, + "quiet-kudu-629": 1033841, + "necessary-nightingale-733": 1033842, + "resilient-husky-35": 1033842, + "acoustic-mammoth-927": 1033846, + "youthful-clownfish-495": 1033849, + "coordinated-elk-473": 1033851, + "little-rhinoceros-60": 1033852, + "judicious-mouse-893": 1033853, + "impressive-reindeer-57": 1033854, + "flexible-cobra-853": 1033862, + "ceaseless-cassowary-781": 1033863, + "disciplined-hawk-629": 1033872, + "glorious-panda-667": 1033873, + "animated-rooster-752": 1033874, + "opulent-sockeye-745": 1033880, + "quaint-hare-670": 1033881, + "famous-curlew-768": 1033887, + "outgoing-emu-347": 1033888, + "uncommon-goldfinch-304": 1033889, + "compassionate-whale-973": 1033890, + "lovely-stoat-616": 1033890, + "dutiful-retriever-0": 1033932, + "optimistic-raccoon-723": 1033933, + "formal-wombat-883": 1033956, + "exuberant-dotterel-897": 1033957, + "hidden-buffalo-510": 1033967, + "standing-fish-747": 1033968, + "sincere-viper-581": 1033972, + "energized-hamster-762": 1033973, + "steady-cardinal-322": 1033980, + "glorious-bullfrog-594": 1033981, + "proper-dove-543": 1033981, + "clean-mink-128": 1033989, + "calm-lapwing-98": 1033991, + "oceanic-lemur-588": 1033998, + "giant-sheep-139": 1033999, + "dapper-dinosaur-404": 1034000, + "fast-mule-274": 1034001, + "blessed-hippopotamus-863": 1034008, + "calm-coyote-23": 1034016, + "harmless-pika-59": 1034017, + "intent-avocet-608": 1034018, + "hidden-swordfish-627": 1034018, + "oceanic-rat-861": 1034019, + "quixotic-penguin-84": 1034025, + "vibrant-falcon-54": 1034026, + "tough-rooster-696": 1034026, + "fastidious-antelope-247": 1034027, + "savory-viper-690": 1034035, + "superb-boar-710": 1034038, + "neighborly-moose-714": 1034039, + "loyal-platypus-547": 1034049, + "amicable-bobcat-309": 1034052, + "dusty-mongoose-808": 1034053, + "groovy-ox-192": 1034068, + "usable-woodpecker-448": 1034070, + "grand-boar-274": 1034070, + "avid-malamute-353": 1034078, + "beaming-setter-517": 1034079, + "agreeable-mosquito-730": 1034079, + "intent-sardine-914": 1034091, + "bright-warthog-142": 1034092, + "tough-pelican-684": 1034101, + "dutiful-ibex-476": 1034102, + "jovial-mammoth-24": 1034113, + "kindred-alligator-864": 1034121, + "amicable-scorpion-283": 1034122, + "zealous-chipmunk-532": 1034128, + "resilient-deer-3": 1034129, + "astute-jackal-964": 1034135, + "fine-cardinal-547": 1034136, + "quaint-mallard-513": 1034156, + "utmost-crocodile-32": 1034157, + "handsome-hamster-642": 1034157, + "fiery-panther-328": 1034179, + "acrobatic-tern-652": 1034180, + "quiet-eagle-674": 1034180, + "rapid-wildebeest-905": 1034196, + "healthy-manatee-271": 1034197, + "glad-clam-704": 1034198, + "polite-spider-202": 1034200, + "outgoing-rabbit-673": 1034206, + "energized-meerkat-881": 1034207, + "usable-bloodhound-665": 1034207, + "adjoining-aardvark-795": 1034211, + "wandering-guanaco-997": 1034212, + "rugged-husky-115": 1034218, + "standing-cat-721": 1034219, + "avid-starfish-219": 1034220, + "wandering-robin-806": 1034221, + "warmhearted-cormorant-112": 1034239, + "academic-cod-297": 1034240, + "energetic-cheetah-995": 1034275, + "mild-caterpillar-223": 1034276, + "perceptive-basilisk-617": 1034287, + "colorless-caterpillar-518": 1034288, + "keen-wolf-614": 1034288, + "colorful-cobra-29": 1034289, + "compassionate-akita-685": 1034291, + "third-chinchilla-320": 1034297, + "bold-ladybug-962": 1034299, + "modest-octopus-550": 1034299, + "striped-stingray-324": 1034315, + "determined-toucan-429": 1034316, + "accomplished-owl-370": 1034316, + "outstanding-crab-117": 1034345, + "compassionate-quail-286": 1034346, + "kindly-cod-230": 1034351, + "aromatic-starling-283": 1034352, + "posh-mink-991": 1034353, + "savory-rabbit-204": 1034354, + "courteous-curlew-319": 1034372, + "valiant-lobster-220": 1034372, + "astute-cat-46": 1034373, + "enchanted-mandrill-918": 1034393, + "adept-gerbil-2": 1034396, + "kindhearted-pony-401": 1034397, + "tangible-dachshund-757": 1034398, + "elated-ermine-75": 1034398, + "colorful-emu-99": 1034418, + "beloved-mockingbird-662": 1034419, + "academic-cod-338": 1034424, + "famous-marmot-359": 1034425, + "original-otter-201": 1034439, + "acoustic-rat-191": 1034440, + "resilient-ram-457": 1034448, + "formal-chicken-178": 1034449, + "kindhearted-minnow-286": 1034453, + "honorable-coyote-860": 1034454, + "clear-lynx-202": 1034458, + "marvelous-ram-435": 1034459, + "quixotic-poodle-578": 1034473, + "neighborly-spoonbill-311": 1034475, + "trustworthy-nightingale-731": 1034476, + "moonlit-bee-392": 1034477, + "clear-whale-542": 1034483, + "neighborly-warbler-980": 1034484, + "mild-gerbil-657": 1034487, + "befitting-lion-177": 1034488, + "harmless-guanaco-130": 1034488, + "rapid-canary-293": 1034492, + "dusty-akita-884": 1034493, + "festive-tiger-512": 1034496, + "basic-butterfly-489": 1034497, + "terrific-anaconda-100": 1034501, + "majestic-cobra-959": 1034503, + "zealous-elk-244": 1034504, + "warmhearted-sturgeon-923": 1034513, + "efficient-magpie-582": 1034514, + "sleek-antelope-494": 1034525, + "shocking-rooster-509": 1034526, + "artful-cuttlefish-232": 1034543, + "dutiful-kangaroo-211": 1034544, + "deafening-buffalo-922": 1034544, + "bold-deer-834": 1034561, + "proper-ptarmigan-804": 1034562, + "scrupulous-pig-90": 1034562, + "combative-basilisk-175": 1034567, + "greedy-crow-925": 1034569, + "aware-spider-188": 1034610, + "frugal-schnauzer-121": 1034611, + "determined-mockingbird-189": 1034612, + "helpful-giraffe-173": 1034613, + "wry-goldfinch-612": 1034614, + "calm-beagle-327": 1034615, + "dazzling-squid-843": 1034616, + "accurate-camel-250": 1034617, + "careful-mink-671": 1034618, + "sensible-crab-143": 1034619, + "quaint-ocelot-874": 1034620, + "spotted-antelope-33": 1034621, + "qualified-pika-351": 1034622, + "clean-roadrunner-915": 1034623, + "pleasant-kookabura-546": 1034624, + "rapid-bullfrog-926": 1034624, + "canny-whale-378": 1034625, + "groovy-labrador-459": 1034626, + "rightful-stork-686": 1034627, + "quixotic-weasel-886": 1034628, + "vibrant-kingfisher-535": 1034629, + "colorless-monitor-843": 1034629, + "brave-cormorant-905": 1034632, + "tough-woodpecker-25": 1034633, + "helpful-sheep-781": 1034635, + "wandering-caiman-22": 1034636, + "cheerful-poodle-673": 1034644, + "different-oyster-755": 1034646, + "perceptive-chameleon-127": 1034655, + "bright-lark-568": 1034656, + "dynamic-flamingo-770": 1034656, + "opulent-ocelot-404": 1034660, + "flexible-ibis-904": 1034661, + "rosy-vulture-735": 1034663, + "coordinated-avocet-719": 1034664, + "diligent-penguin-804": 1034669, + "acrobatic-terrier-458": 1034671, + "sincere-spoonbill-350": 1034672, + "kindred-possum-971": 1034673, + "standing-dachshund-478": 1034674, + "bold-otter-143": 1034675, + "insightful-orca-705": 1034677, + "rare-civet-12": 1034678, + "cautious-loris-373": 1034688, + "knowing-barracuda-548": 1034689, + "joyous-goldfish-337": 1034694, + "small-giraffe-460": 1034695, + "famous-cheetah-639": 1034702, + "zany-reindeer-151": 1034703, + "accomplished-beagle-234": 1034713, + "wry-hippopotamus-169": 1034714, + "energetic-basilisk-940": 1034714, + "reminiscent-retriever-788": 1034720, + "youthful-hippopotamus-379": 1034721, + "earnest-yak-157": 1034722, + "insightful-civet-140": 1034723, + "quaint-albatross-216": 1034723, + "affable-badger-271": 1034724, + "majestic-cheetah-915": 1034725, + "healthy-lyrebird-869": 1034725, + "glad-mockingbird-863": 1034729, + "bright-puffin-939": 1034730, + "diligent-sheep-421": 1034744, + "fiery-raccoon-663": 1034745, + "dashing-kangaroo-427": 1034746, + "joyous-puma-267": 1034747, + "canny-bear-999": 1034748, + "dapper-civet-313": 1034749, + "trustworthy-schnauzer-796": 1034749, + "insightful-chipmunk-211": 1034750, + "oceanic-alpaca-950": 1034751, + "lovable-wildcat-767": 1034751, + "cool-pheasant-217": 1034753, + "dynamic-clownfish-625": 1034754, + "avid-puffin-196": 1034813, + "scintillating-sheep-8": 1034814, + "energetic-monitor-820": 1034815, + "affable-ram-892": 1034816, + "cautious-lynx-584": 1034833, + "grandiose-orca-801": 1034834, + "dapper-hyena-476": 1034835, + "tidy-peccary-790": 1034837, + "majestic-bison-674": 1034849, + "next-mink-714": 1034850, + "handsome-greyhound-649": 1034883, + "greedy-ram-148": 1034884, + "gregarious-ostrich-415": 1034920, + "majestic-ant-400": 1034921, + "adept-antelope-153": 1034923, + "tidy-moose-110": 1034924, + "quixotic-hyena-613": 1034943, + "fearless-hound-450": 1034944, + "wonderful-barracuda-92": 1034947, + "diligent-cobra-578": 1034948, + "rare-cardinal-692": 1034956, + "cheerful-pika-417": 1034957, + "diligent-gnu-632": 1034966, + "neighborly-magpie-151": 1034967, + "industrious-iguana-415": 1034968, + "combative-canary-187": 1034969, + "oceanic-bear-137": 1034977, + "abundant-swordfish-409": 1034978, + "ceaseless-possum-810": 1034992, + "cool-newt-503": 1034993, + "sleek-boar-518": 1034998, + "limitless-reindeer-930": 1035000, + "perceptive-labrador-924": 1035001, + "zealous-spoonbill-659": 1035001, + "warmhearted-armadillo-518": 1035003, + "rightful-scorpion-375": 1035005, + "flippant-salmon-722": 1035025, + "determined-ladybug-691": 1035026, + "keen-armadillo-401": 1035027, + "enchanted-eel-311": 1035028, + "fabulous-eel-394": 1035037, + "rugged-pelican-891": 1035039, + "agreeable-seal-715": 1035039, + "acrobatic-mockingbird-686": 1035044, + "vibrant-peccary-385": 1035045, + "avid-hound-0": 1035048, + "useful-opossum-842": 1035049, + "befitting-mink-683": 1035051, + "fearless-seahorse-470": 1035053, + "third-mastiff-713": 1035061, + "lovable-monitor-965": 1035062, + "fastidious-oriole-868": 1035069, + "lovely-stoat-336": 1035070, + "healthy-meadowlark-438": 1035071, + "woozy-frog-760": 1035072, + "small-chihuahua-880": 1035094, + "rosy-loris-656": 1035095, + "amicable-cormorant-897": 1035107, + "grateful-salamander-614": 1035108, + "festive-peccary-642": 1035131, + "knowing-panda-818": 1035132, + "secret-camel-176": 1035158, + "limitless-jackal-397": 1035159, + "resilient-wolf-777": 1035180, + "rosy-kookabura-524": 1035181, + "scrupulous-clam-54": 1035190, + "wandering-caribou-510": 1035191, + "fortunate-cow-111": 1035194, + "keen-retriever-299": 1035195, + "whimsical-marten-991": 1035196, + "dapper-hawk-24": 1035198, + "fantastic-marlin-99": 1035200, + "wary-hyena-810": 1035201, + "shocking-finch-590": 1035226, + "peaceful-goshawk-263": 1035227, + "handsome-chickadee-412": 1035232, + "grateful-giraffe-206": 1035233, + "terrific-viper-653": 1035234, + "gallant-magpie-94": 1035235, + "reminiscent-perch-431": 1035246, + "kindhearted-bird-994": 1035247, + "lovely-ocelot-25": 1035250, + "effervescent-salmon-74": 1035252, + "dusty-llama-337": 1035258, + "youthful-orca-582": 1035259, + "secret-jackal-159": 1035259, + "friendly-axolotl-984": 1035260, + "cool-dove-40": 1035261, + "small-partridge-991": 1035271, + "accomplished-firefly-396": 1035272, + "blissful-dachshund-504": 1035272, + "savory-bobcat-964": 1035280, + "wry-parakeet-894": 1035281, + "neat-falcon-315": 1035301, + "coordinated-bass-794": 1035303, + "patient-gazelle-293": 1035308, + "rightful-stoat-191": 1035308, + "careful-vulture-206": 1035351, + "perfect-wombat-506": 1035352, + "unique-malamute-527": 1035352, + "next-otter-523": 1035358, + "moonlit-hyena-277": 1035359, + "secret-hamster-559": 1035372, + "strong-axolotl-921": 1035373, + "academic-raven-970": 1035379, + "tremendous-llama-541": 1035380, + "rare-monitor-341": 1035395, + "fleet-jaguar-613": 1035396, + "focused-sockeye-495": 1035436, + "tough-swordfish-508": 1035437, + "utmost-greyhound-497": 1035443, + "calculating-chinchilla-974": 1035444, + "precise-hornet-597": 1035452, + "perceptive-ox-600": 1035453, + "adamant-cassowary-810": 1035467, + "wry-herring-592": 1035468, + "famous-yak-775": 1035473, + "resolute-herring-458": 1035474, + "flippant-shark-594": 1035474, + "dazzling-hare-311": 1035475, + "cheery-dogfish-224": 1035477, + "shiny-camel-320": 1035483, + "disciplined-anteater-606": 1035484, + "greedy-stoat-93": 1035504, + "aware-minnow-860": 1035505, + "rapid-loris-616": 1035509, + "clever-corgi-743": 1035510, + "different-fish-155": 1035579, + "dynamic-octopus-440": 1035580, + "veracious-sheep-355": 1035590, + "disciplined-mammoth-144": 1035591, + "flippant-fly-650": 1035595, + "astute-aardvark-185": 1035596, + "gallant-elk-339": 1035596, + "charming-fox-760": 1035618, + "compassionate-goldfish-938": 1035620, + "shiny-goose-963": 1035620, + "rosy-marmot-220": 1035635, + "woozy-kingfisher-275": 1035636, + "optimistic-skunk-627": 1035651, + "academic-bandicoot-245": 1035652, + "giddy-goose-491": 1035675, + "prestigious-scorpion-566": 1035676, + "uncommon-dachshund-438": 1035676, + "vibrant-dogfish-23": 1035678, + "neighborly-blackbird-151": 1035679, + "hardy-ermine-27": 1035679, + "robust-bulldog-475": 1035688, + "hushed-whale-566": 1035689, + "fantastic-firefly-741": 1035691, + "descriptive-crow-950": 1035692, + "ideal-toad-710": 1035706, + "successful-duck-234": 1035707, + "calculating-penguin-120": 1035711, + "quaint-lyrebird-710": 1035713, + "adept-platypus-139": 1035713, + "dapper-porcupine-110": 1035757, + "laudable-opossum-71": 1035758, + "silent-dinosaur-509": 1035764, + "exuberant-civet-553": 1035765, + "flexible-tortoise-914": 1035772, + "standing-rook-857": 1035773, + "kindly-pheasant-153": 1035773, + "greedy-lemming-507": 1035776, + "zealous-coyote-775": 1035777, + "diligent-warbler-34": 1035779, + "precious-barracuda-32": 1035780, + "content-wildebeest-984": 1035783, + "friendly-bloodhound-646": 1035784, + "enduring-crab-153": 1035788, + "elated-ram-431": 1035789, + "notable-skunk-557": 1035804, + "giddy-greyhound-313": 1035805, + "benevolent-mole-988": 1035816, + "valiant-wildcat-754": 1035817, + "posh-spaniel-60": 1035821, + "neat-malamute-92": 1035822, + "helpful-capybara-160": 1035823, + "agreeable-goose-692": 1035824, + "frugal-toad-954": 1035824, + "trustworthy-squid-483": 1035826, + "careful-spaniel-13": 1035827, + "aware-zebra-674": 1035827, + "pastel-nightingale-81": 1035832, + "gregarious-clownfish-858": 1035833, + "friendly-camel-629": 1035834, + "courteous-impala-932": 1035835, + "honorable-sardine-511": 1035836, + "hidden-fox-624": 1035837, + "clear-ostrich-847": 1035839, + "astute-coyote-194": 1035840, + "energized-peacock-69": 1035841, + "astute-gopher-702": 1035842, + "fantastic-buffalo-360": 1035845, + "hidden-zebra-191": 1035847, + "basic-reindeer-262": 1035856, + "majestic-goshawk-839": 1035857, + "dashing-grouse-32": 1035860, + "savory-bandicoot-779": 1035861, + "astute-toad-734": 1035861, + "elated-hyena-567": 1035863, + "necessary-mockingbird-45": 1035864, + "glad-fly-85": 1035868, + "first-blackbird-276": 1035869, + "precise-trout-424": 1035873, + "tangible-kiwi-196": 1035874, + "brave-shark-413": 1035883, + "lovely-salmon-69": 1035884, + "acoustic-pony-843": 1035890, + "knowing-goat-719": 1035891, + "kindred-caribou-346": 1035901, + "exciting-kingfisher-191": 1035902, + "merry-weasel-791": 1035911, + "focused-dinosaur-352": 1035912, + "silent-badger-890": 1035914, + "quaint-ibex-390": 1035915, + "adventurous-boar-7": 1035927, + "bright-jay-383": 1035928, + "dependable-fennec-424": 1035928, + "impartial-fly-123": 1035936, + "avid-chickadee-646": 1035937, + "small-lemming-911": 1035940, + "polite-skunk-986": 1035941, + "brazen-otter-804": 1035944, + "utmost-giraffe-972": 1035945, + "ardent-fly-449": 1035946, + "scintillating-mallard-255": 1035947, + "gallant-chinchilla-857": 1035948, + "brave-hound-179": 1035949, + "acoustic-salmon-86": 1035950, + "honorable-sardine-955": 1035951, + "bold-poodle-943": 1035952, + "bold-flamingo-516": 1035953, + "academic-gerbil-98": 1035954, + "healthy-meadowlark-241": 1035955, + "wooden-giraffe-474": 1035956, + "accomplished-grouse-183": 1035957, + "dynamic-raccoon-522": 1035958, + "festive-bulldog-112": 1035959, + "energized-nightingale-283": 1035960, + "harmless-mandrill-507": 1035961, + "quaint-wolf-914": 1035962, + "gregarious-goldfinch-278": 1035963, + "spotted-puffin-518": 1035967, + "perceptive-horse-404": 1035969, + "veracious-otter-735": 1035970, + "outgoing-boar-516": 1035971, + "artful-shark-351": 1035975, + "wry-manatee-324": 1035976, + "descriptive-lion-912": 1035980, + "flippant-giraffe-122": 1035981, + "woozy-octopus-286": 1035983, + "proficient-panda-471": 1035984, + "joyous-ptarmigan-150": 1035987, + "terrific-crow-855": 1035988, + "adamant-trout-908": 1035992, + "tame-deer-248": 1035993, + "amiable-ox-750": 1035993, + "adept-cod-623": 1035996, + "determined-meadowlark-696": 1035997, + "original-leopard-201": 1035997, + "acoustic-pelican-233": 1035998, + "fiery-dogfish-379": 1036000, + "affable-fox-150": 1036001, + "precious-perch-338": 1036002, + "calm-flamingo-317": 1036003, + "graceful-panda-957": 1036004, + "basic-koala-298": 1036010, + "dapper-malamute-38": 1036011, + "clear-basilisk-631": 1036025, + "resilient-bat-993": 1036027, + "dependable-spaniel-347": 1036028, + "spotted-woodpecker-867": 1036029, + "usable-mule-150": 1036032, + "standing-bullfrog-897": 1036033, + "woozy-peccary-204": 1036033, + "whimsical-mole-354": 1036036, + "watchful-starling-69": 1036037, + "energized-armadillo-805": 1036041, + "artful-gopher-259": 1036042, + "fine-ant-380": 1036072, + "dependable-sockeye-765": 1036073, + "friendly-panda-878": 1036075, + "basic-sandpiper-323": 1036076, + "fantastic-pigeon-172": 1036079, + "energetic-tiger-943": 1036080, + "trustworthy-goldfish-882": 1036096, + "avid-impala-716": 1036097, + "quixotic-lobster-344": 1036098, + "dazzling-dove-257": 1036099, + "beaming-dotterel-614": 1036108, + "grandiose-anaconda-808": 1036109, + "acrobatic-stingray-844": 1036111, + "modest-grouse-95": 1036112, + "confident-spoonbill-179": 1036123, + "rightful-chicken-641": 1036125, + "robust-buffalo-259": 1036125, + "efficient-curlew-646": 1036127, + "silent-fish-442": 1036130, + "tremendous-antelope-195": 1036131, + "tremendous-ocelot-343": 1036134, + "polished-blackbird-720": 1036134, + "abundant-spaniel-667": 1036135, + "savory-wolf-269": 1036140, + "woozy-roadrunner-787": 1036141, + "aromatic-frog-870": 1036142, + "steady-turtle-74": 1036143, + "sleek-sheep-316": 1036147, + "lovely-leopard-78": 1036148, + "flexible-starfish-573": 1036153, + "basic-cheetah-363": 1036154, + "merry-lyrebird-80": 1036157, + "determined-weasel-896": 1036158, + "good-squirrel-726": 1036163, + "wooden-tortoise-843": 1036164, + "striped-duck-276": 1036166, + "fleet-stork-340": 1036167, + "courteous-stoat-700": 1036172, + "wandering-hedgehog-117": 1036173, + "precious-bobcat-992": 1036190, + "acrobatic-raccoon-753": 1036191, + "youthful-nightingale-13": 1036191, + "cheery-ferret-980": 1036199, + "nautical-blackbird-326": 1036201, + "focused-porpoise-577": 1036201, + "pleasant-monitor-65": 1036203, + "decisive-cassowary-454": 1036205, + "beloved-chickadee-291": 1036205, + "utmost-blackbird-677": 1036209, + "hearty-otter-125": 1036210, + "hip-seal-482": 1036217, + "capable-lemming-489": 1036218, + "strong-ant-770": 1036220, + "nautical-okapi-879": 1036221, + "formal-llama-700": 1036224, + "rosy-marlin-734": 1036225, + "fastidious-poodle-683": 1036234, + "resolute-opossum-658": 1036235, + "prestigious-puffin-116": 1036235, + "dynamic-turtle-874": 1036237, + "ideal-vole-774": 1036238, + "zealous-parakeet-179": 1036242, + "wooden-barracuda-584": 1036243, + "colorful-salamander-454": 1036249, + "capable-snail-22": 1036250, + "clever-starfish-771": 1036250, + "tangible-dodo-549": 1036254, + "artful-salmon-363": 1036255, + "ceaseless-cheetah-313": 1036257, + "brainy-lemur-452": 1036258, + "successful-starfish-945": 1036271, + "useful-marten-962": 1036272, + "content-aardvark-550": 1036282, + "utmost-marmot-415": 1036284, + "quiet-ladybug-870": 1036286, + "giddy-hamster-108": 1036287, + "hushed-monitor-745": 1036296, + "blissful-grasshopper-872": 1036297, + "notable-owl-755": 1036301, + "artful-aardvark-406": 1036303, + "useful-pigeon-690": 1036303, + "standing-ptarmigan-440": 1036305, + "ceaseless-deer-983": 1036306, + "energetic-canary-169": 1036306, + "wonderful-tortoise-939": 1036311, + "youthful-seahorse-765": 1036312, + "clever-buzzard-725": 1036312, + "giddy-boar-206": 1036314, + "judicious-bird-11": 1036316, + "healthy-porcupine-235": 1036317, + "fabulous-narwhal-872": 1036318, + "fabulous-herring-876": 1036320, + "energetic-penguin-876": 1036321, + "ceaseless-possum-925": 1036323, + "spotted-bulldog-577": 1036324, + "polished-dinosaur-99": 1036328, + "grateful-mouse-696": 1036330, + "abundant-moose-30": 1036336, + "fantastic-cricket-228": 1036337, + "descriptive-husky-772": 1036339, + "ardent-kiwi-130": 1036340, + "exuberant-condor-218": 1036350, + "bold-marlin-432": 1036351, + "opulent-greyhound-533": 1036354, + "wary-albatross-290": 1036355, + "disciplined-cassowary-426": 1036361, + "energetic-husky-870": 1036362, + "clever-gecko-752": 1036363, + "spotted-zebra-27": 1036364, + "secret-puffin-183": 1036365, + "healthy-chickadee-359": 1036366, + "merry-octopus-87": 1036372, + "acoustic-oriole-381": 1036373, + "enduring-ocelot-178": 1036373, + "hallowed-ox-796": 1036375, + "festive-butterfly-164": 1036376, + "incredible-snail-401": 1036378, + "reliable-hummingbird-425": 1036379, + "outstanding-anaconda-499": 1036380, + "festive-canary-943": 1036381, + "wonderful-frog-399": 1036390, + "befitting-possum-87": 1036391, + "silent-ladybug-509": 1036394, + "fastidious-cricket-286": 1036395, + "accurate-wombat-849": 1036418, + "merry-wolf-326": 1036419, + "sleek-emu-287": 1036419, + "calm-chipmunk-563": 1036424, + "rare-cuttlefish-94": 1036425, + "dashing-dodo-59": 1036436, + "adorable-gopher-941": 1036437, + "superb-snake-892": 1036440, + "blissful-crocodile-393": 1036441, + "enchanted-kingfisher-839": 1036442, + "hidden-possum-119": 1036443, + "wooden-starfish-632": 1036446, + "warmhearted-frog-408": 1036447, + "canny-coyote-451": 1036452, + "nautical-armadillo-987": 1036453, + "ideal-otter-226": 1036457, + "tremendous-rabbit-938": 1036457, + "tough-herring-710": 1036458, + "quaint-crane-887": 1036459, + "next-porcupine-495": 1036464, + "rapid-gerbil-637": 1036465, + "precious-buzzard-411": 1036468, + "elated-pelican-163": 1036469, + "amiable-gazelle-257": 1036472, + "enduring-mastiff-838": 1036473, + "ardent-partridge-972": 1036473, + "silent-butterfly-441": 1036480, + "secret-peacock-31": 1036482, + "knowing-dinosaur-769": 1036483, + "combative-malamute-377": 1036484, + "glorious-bird-112": 1036492, + "adjoining-gazelle-219": 1036493, + "wry-bison-2": 1036493, + "moonlit-bee-220": 1036497, + "steady-guanaco-971": 1036498, + "benevolent-pig-119": 1036498, + "rightful-crane-519": 1036499, + "fastidious-llama-708": 1036500, + "precious-perch-488": 1036500, + "benevolent-husky-860": 1036505, + "cool-cuttlefish-47": 1036506, + "deafening-mouse-754": 1036508, + "kindhearted-eel-206": 1036509, + "little-octopus-851": 1036512, + "canny-oriole-420": 1036513, + "cool-mammoth-30": 1036516, + "limitless-wildcat-376": 1036517, + "handsome-axolotl-532": 1036519, + "proficient-spoonbill-530": 1036520, + "gallant-lemur-286": 1036521, + "sensible-fennec-951": 1036522, + "bright-ostrich-90": 1036523, + "brazen-sockeye-547": 1036524, + "canny-panther-147": 1036524, + "dutiful-iguana-469": 1036527, + "posh-sockeye-943": 1036528, + "judicious-porpoise-527": 1036528, + "curious-avocet-202": 1036530, + "opulent-falcon-237": 1036531, + "agreeable-mammoth-817": 1036536, + "spotted-fox-890": 1036537, + "good-cardinal-277": 1036563, + "standing-moose-501": 1036564, + "grand-jellyfish-156": 1036564, + "calculating-kookabura-299": 1036569, + "knowing-horse-614": 1036570, + "strong-ptarmigan-636": 1036571, + "descriptive-eagle-185": 1036572, + "animated-trout-956": 1036579, + "adamant-fly-815": 1036580, + "accomplished-axolotl-640": 1036581, + "healthy-seal-6": 1036582, + "giant-flamingo-656": 1036583, + "joyous-gull-706": 1036584, + "beaming-warbler-897": 1036589, + "accurate-marmot-376": 1036590, + "doting-gnat-583": 1036600, + "shocking-mink-375": 1036601, + "energetic-cormorant-49": 1036603, + "glorious-chinchilla-827": 1036604, + "silent-bandicoot-858": 1036608, + "helpful-mule-548": 1036609, + "capable-monitor-954": 1036616, + "hearty-starling-973": 1036617, + "hidden-seahorse-744": 1036623, + "healthy-eel-470": 1036625, + "fiery-dodo-980": 1036627, + "mellow-avocet-519": 1036628, + "careful-owl-437": 1036629, + "dusty-narwhal-710": 1036631, + "tame-ptarmigan-665": 1036631, + "woozy-condor-536": 1036632, + "modest-sockeye-761": 1036633, + "nautical-gecko-212": 1036636, + "sensible-spaniel-990": 1036638, + "steady-oriole-747": 1036648, + "necessary-puffin-755": 1036649, + "polite-moose-246": 1036650, + "mellow-ant-931": 1036651, + "wary-jay-905": 1036652, + "wonderful-mongoose-316": 1036653, + "knowing-raven-744": 1036659, + "descriptive-hare-579": 1036660, + "adventurous-herring-684": 1036661, + "savory-hamster-266": 1036662, + "zany-cod-828": 1036663, + "tacit-alpaca-868": 1036664, + "vivid-caiman-765": 1036668, + "hip-wolverine-641": 1036669, + "doting-chickadee-670": 1036672, + "judicious-jay-725": 1036673, + "reliable-okapi-858": 1036675, + "notable-sparrow-799": 1036676, + "cautious-basilisk-754": 1036678, + "incredible-flamingo-310": 1036679, + "lovely-tapir-622": 1036683, + "lovely-axolotl-401": 1036684, + "colorless-hamster-12": 1036687, + "incredible-blackbird-841": 1036687, + "effervescent-beagle-142": 1036688, + "fortunate-armadillo-194": 1036689, + "cheery-shrimp-353": 1036690, + "calculating-condor-64": 1036691, + "adventurous-trout-711": 1036692, + "amicable-lemur-809": 1036694, + "aromatic-quail-875": 1036695, + "aware-loris-213": 1036699, + "colorless-crab-382": 1036700, + "woozy-shepherd-586": 1036701, + "courteous-hummingbird-901": 1036703, + "academic-mule-949": 1036715, + "marvelous-gull-439": 1036716, + "fine-blackbird-285": 1036716, + "adorable-walrus-935": 1036718, + "brilliant-elephant-880": 1036720, + "tame-elk-848": 1036726, + "graceful-otter-742": 1036727, + "peaceful-pika-514": 1036731, + "gregarious-finch-963": 1036732, + "greedy-gecko-410": 1036733, + "diligent-seal-325": 1036734, + "amicable-octopus-921": 1036738, + "savory-schnauzer-983": 1036739, + "colorless-warthog-878": 1036741, + "incredible-bandicoot-430": 1036742, + "glad-panther-651": 1036748, + "fine-poodle-157": 1036748, + "accomplished-frog-413": 1036749, + "outstanding-weasel-617": 1036754, + "adventurous-turtle-434": 1036755, + "brave-shepherd-283": 1036759, + "efficient-gerbil-37": 1036760, + "scintillating-rabbit-993": 1036777, + "avid-oyster-517": 1036778, + "mild-spider-398": 1036787, + "efficient-toad-156": 1036789, + "prestigious-kudu-118": 1036796, + "bold-bulldog-705": 1036798, + "terrific-blackbird-196": 1036798, + "grand-tortoise-920": 1036804, + "judicious-lion-539": 1036805, + "watchful-beagle-975": 1036810, + "capable-dodo-146": 1036811, + "aware-dodo-734": 1036854, + "descriptive-wren-35": 1036855, + "limitless-ibis-7": 1036856, + "neighborly-rat-718": 1036876, + "mellow-parrot-260": 1036881, + "wandering-schnauzer-442": 1036882, + "steady-cobra-612": 1036882, + "enduring-anteater-983": 1036884, + "compassionate-goose-533": 1036885, + "trustworthy-finch-6": 1036892, + "quick-reindeer-371": 1036893, + "aromatic-elk-544": 1036896, + "upbeat-wombat-847": 1036898, + "hallowed-oyster-314": 1036905, + "brazen-mandrill-848": 1036906, + "clean-skunk-47": 1036915, + "next-mosquito-179": 1036916, + "bright-hummingbird-396": 1036921, + "striped-snake-971": 1036922, + "nautical-ptarmigan-379": 1036922, + "friendly-llama-608": 1036928, + "incredible-dolphin-153": 1036929, + "tame-gnat-120": 1036930, + "flexible-dogfish-540": 1036930, + "oceanic-shrimp-487": 1036931, + "warmhearted-marmot-194": 1036953, + "brilliant-basilisk-283": 1036954, + "impressive-scorpion-514": 1036957, + "gregarious-ermine-990": 1036958, + "grandiose-pheasant-508": 1036960, + "tangible-ibis-565": 1036961, + "brazen-chameleon-266": 1036962, + "calm-clownfish-168": 1036963, + "brilliant-fish-926": 1036966, + "brave-quail-265": 1036967, + "mellow-cassowary-665": 1036969, + "grateful-puma-736": 1036970, + "astute-scorpion-175": 1036978, + "vivid-yak-589": 1036979, + "enchanted-lobster-623": 1036980, + "striped-yak-371": 1036981, + "standing-cuttlefish-608": 1036983, + "lovable-seal-950": 1036984, + "gregarious-tern-791": 1036989, + "trustworthy-hornet-569": 1036991, + "quaint-cardinal-202": 1036995, + "patient-fennec-784": 1036996, + "formal-rat-325": 1036996, + "fast-jaguar-3": 1037000, + "shiny-caribou-533": 1037001, + "diligent-starling-918": 1037013, + "gallant-hound-295": 1037014, + "tacit-goat-20": 1037014, + "fearless-lark-377": 1037016, + "friendly-ferret-344": 1037017, + "tidy-roadrunner-942": 1037029, + "tangible-dog-22": 1037031, + "greedy-weasel-419": 1037031, + "insightful-jay-832": 1037039, + "standing-tiger-488": 1037042, + "strong-snake-156": 1037057, + "usable-hound-18": 1037058, + "wary-camel-553": 1037067, + "reliable-hummingbird-659": 1037068, + "rare-hippopotamus-885": 1037069, + "curious-hound-772": 1037071, + "clean-tapir-444": 1037076, + "lovable-vole-600": 1037077, + "amicable-quail-894": 1037084, + "hidden-minnow-60": 1037085, + "brave-cardinal-251": 1037085, + "helpful-magpie-689": 1037087, + "whimsical-gerbil-723": 1037088, + "precious-dolphin-65": 1037088, + "courteous-gull-487": 1037089, + "decisive-crow-463": 1037091, + "successful-civet-646": 1037096, + "artful-setter-759": 1037097, + "glad-dinosaur-375": 1037097, + "tough-sandpiper-569": 1037100, + "dazzling-lynx-243": 1037102, + "admired-mink-208": 1037103, + "agreeable-kiwi-898": 1037104, + "tidy-bloodhound-153": 1037104, + "kindred-ox-602": 1037105, + "curious-sandpiper-266": 1037106, + "jovial-ferret-169": 1037108, + "accomplished-ibis-572": 1037109, + "clever-shark-658": 1037112, + "fastidious-pika-505": 1037113, + "chatty-ox-596": 1037114, + "efficient-greyhound-394": 1037115, + "fiery-rabbit-42": 1037115, + "spotted-puma-93": 1037119, + "perfect-jackal-475": 1037120, + "enchanted-shrimp-384": 1037129, + "quirky-giraffe-740": 1037130, + "combative-rooster-624": 1037130, + "ceaseless-bullfrog-849": 1037138, + "watchful-salamander-688": 1037140, + "fantastic-hornet-215": 1037155, + "basic-frog-736": 1037156, + "basic-guanaco-124": 1037159, + "outgoing-chipmunk-343": 1037160, + "wandering-eel-302": 1037162, + "doting-mink-56": 1037163, + "outstanding-hedgehog-986": 1037163, + "loyal-cormorant-61": 1037165, + "dashing-labrador-457": 1037166, + "savory-mosquito-818": 1037166, + "quirky-toad-677": 1037168, + "tremendous-mallard-47": 1037169, + "kindhearted-bat-169": 1037171, + "agreeable-roadrunner-347": 1037172, + "youthful-roadrunner-243": 1037182, + "necessary-grasshopper-30": 1037183, + "brazen-panther-717": 1037183, + "spotted-stoat-236": 1037189, + "healthy-minnow-707": 1037190, + "judicious-turtle-440": 1037203, + "avid-eel-667": 1037204, + "first-wolf-258": 1037205, + "chatty-manatee-42": 1037206, + "hardy-seal-250": 1037209, + "dependable-corgi-610": 1037210, + "kindred-elk-498": 1037212, + "charming-warbler-508": 1037212, + "dynamic-herring-233": 1037213, + "quiet-pigeon-517": 1037213, + "tremendous-spoonbill-727": 1037215, + "frugal-hornet-268": 1037216, + "dashing-aardvark-640": 1037217, + "fiery-setter-961": 1037218, + "artful-stingray-946": 1037220, + "tremendous-mosquito-88": 1037221, + "impartial-ptarmigan-746": 1037222, + "industrious-swordfish-484": 1037223, + "outgoing-narwhal-666": 1037243, + "exciting-salmon-556": 1037244, + "combative-skunk-653": 1037252, + "colorful-jaguar-531": 1037253, + "ceaseless-squid-523": 1037257, + "loyal-rooster-920": 1037258, + "polished-husky-858": 1037267, + "focused-cormorant-879": 1037268, + "moonlit-buffalo-721": 1037279, + "elated-caterpillar-950": 1037280, + "helpful-iguana-210": 1037284, + "curious-pig-455": 1037285, + "precious-blackbird-942": 1037285, + "trustworthy-koala-886": 1037287, + "vivid-hamster-828": 1037289, + "fastidious-ostrich-432": 1037293, + "benevolent-walrus-161": 1037295, + "beloved-sturgeon-375": 1037295, + "confident-monitor-652": 1037302, + "admired-hamster-180": 1037303, + "outgoing-guineapig-996": 1037304, + "focused-alpaca-203": 1037305, + "scintillating-emu-146": 1037308, + "perfect-kiwi-765": 1037309, + "incredible-canary-139": 1037313, + "adjoining-civet-621": 1037314, + "intent-quail-660": 1037315, + "amicable-opossum-31": 1037316, + "quixotic-whale-831": 1037317, + "terrific-husky-106": 1037318, + "hidden-toucan-535": 1037322, + "intent-rabbit-828": 1037323, + "hallowed-blackbird-585": 1037332, + "animated-kookabura-372": 1037333, + "stoic-pheasant-11": 1037333, + "cool-beagle-498": 1037337, + "beloved-sockeye-729": 1037338, + "valiant-finch-691": 1037369, + "colorless-jellyfish-313": 1037370, + "hearty-oriole-498": 1037371, + "optimistic-egret-337": 1037373, + "posh-bulldog-584": 1037374, + "sleek-goshawk-941": 1037375, + "intent-ibex-550": 1037404, + "adjoining-retriever-354": 1037405, + "amicable-magpie-938": 1037409, + "moonlit-caribou-61": 1037411, + "adamant-fox-260": 1037417, + "outgoing-woodpecker-743": 1037417, + "helpful-koala-237": 1037421, + "disciplined-crocodile-582": 1037422, + "laudable-impala-739": 1037423, + "quiet-ox-975": 1037424, + "brilliant-mammoth-721": 1037425, + "striped-ferret-35": 1037425, + "handsome-elephant-997": 1037426, + "successful-heron-345": 1037434, + "optimistic-salamander-596": 1037435, + "precious-donkey-604": 1037452, + "scrupulous-kangaroo-336": 1037453, + "wandering-pigeon-679": 1037457, + "grandiose-bass-828": 1037458, + "greedy-elk-835": 1037461, + "academic-opossum-944": 1037462, + "keen-jay-230": 1037462, + "formal-orca-665": 1037471, + "mellow-porpoise-722": 1037473, + "successful-shark-944": 1037474, + "dapper-herring-243": 1037475, + "frugal-kiwi-950": 1037477, + "moonlit-butterfly-538": 1037479, + "avid-impala-363": 1037480, + "dutiful-squid-463": 1037481, + "benevolent-butterfly-529": 1037488, + "sensible-pigeon-854": 1037489, + "descriptive-starfish-496": 1037506, + "optimistic-husky-346": 1037507, + "enduring-falcon-304": 1037510, + "silent-ptarmigan-356": 1037511, + "avid-hamster-619": 1037534, + "amiable-setter-927": 1037535, + "valiant-giraffe-377": 1037535, + "ceaseless-setter-485": 1037543, + "ceaseless-setter-668": 1037544, + "aware-gnu-655": 1037544, + "proper-caterpillar-276": 1037558, + "coordinated-badger-689": 1037559, + "small-giraffe-117": 1037559, + "graceful-lyrebird-399": 1037561, + "effervescent-horse-582": 1037562, + "perceptive-hummingbird-477": 1037568, + "reliable-pigeon-749": 1037569, + "small-cobra-428": 1037573, + "acrobatic-akita-610": 1037574, + "proficient-anteater-260": 1037579, + "tremendous-starfish-658": 1037580, + "energized-firefly-840": 1037592, + "artful-skunk-837": 1037593, + "dashing-gnat-642": 1037594, + "impressive-rooster-255": 1037595, + "hallowed-magpie-341": 1037605, + "enduring-possum-820": 1037606, + "energized-impala-999": 1037622, + "polite-jaguar-752": 1037623, + "wandering-warthog-699": 1037637, + "patient-pika-551": 1037639, + "reminiscent-bat-625": 1037640, + "brainy-stork-285": 1037641, + "elated-parrot-322": 1037645, + "zany-jackal-17": 1037646, + "bright-raccoon-579": 1037646, + "flippant-cormorant-387": 1037656, + "calculating-cobra-827": 1037658, + "energized-lyrebird-4": 1037658, + "festive-bison-73": 1037673, + "beaming-poodle-307": 1037674, + "adjoining-snail-858": 1037679, + "notable-spoonbill-685": 1037682, + "polished-bear-224": 1037688, + "canny-platypus-140": 1037689, + "compassionate-hyena-331": 1037700, + "valiant-falcon-515": 1037701, + "aromatic-cod-484": 1037719, + "useful-gerbil-427": 1037720, + "beloved-chickadee-528": 1037721, + "reminiscent-bee-985": 1037722, + "adept-oriole-17": 1037723, + "abundant-fox-876": 1037725, + "resolute-elk-234": 1037725, + "limitless-dog-796": 1037731, + "quaint-seahorse-700": 1037732, + "joyous-manatee-906": 1037733, + "amicable-meadowlark-795": 1037735, + "descriptive-seahorse-5": 1037737, + "agreeable-whale-489": 1037738, + "quaint-pheasant-734": 1037738, + "rapid-cricket-179": 1037745, + "limitless-shark-11": 1037746, + "terrific-manatee-372": 1037769, + "calm-terrier-759": 1037770, + "knowing-oyster-398": 1037776, + "grand-dolphin-812": 1037777, + "cheerful-fox-576": 1037789, + "brave-goat-192": 1037790, + "savory-wildebeest-748": 1037794, + "tacit-wolf-320": 1037795, + "wooden-seal-598": 1037822, + "strong-tern-866": 1037823, + "academic-camel-868": 1037828, + "hardy-lemming-180": 1037829, + "elegant-starling-975": 1037833, + "decisive-stingray-775": 1037834, + "successful-marten-446": 1037839, + "disciplined-cormorant-911": 1037840, + "modest-ferret-468": 1037842, + "polished-ibis-511": 1037843, + "notable-hummingbird-489": 1037843, + "deafening-dolphin-819": 1037856, + "savory-gazelle-938": 1037857, + "bright-donkey-322": 1037879, + "glorious-rhinoceros-73": 1037881, + "resilient-greyhound-607": 1037885, + "pleasant-camel-784": 1037886, + "fantastic-badger-915": 1037887, + "small-seal-60": 1037888, + "lovely-camel-149": 1037895, + "wry-monitor-810": 1037896, + "hallowed-buzzard-76": 1037900, + "robust-chipmunk-598": 1037901, + "capable-husky-594": 1037916, + "lovely-mastiff-192": 1037917, + "abundant-toad-924": 1037919, + "curious-stingray-969": 1037920, + "charming-capybara-209": 1037924, + "tremendous-bloodhound-921": 1037928, + "exuberant-salamander-37": 1037934, + "adept-mule-74": 1037936, + "oceanic-retriever-806": 1037938, + "tremendous-ferret-567": 1037939, + "bold-goose-157": 1037944, + "dusty-basilisk-709": 1037945, + "proper-jaguar-238": 1037946, + "insightful-hare-824": 1037947, + "kindly-roadrunner-240": 1037983, + "adventurous-lemming-716": 1037984, + "blissful-fly-826": 1037986, + "aware-dachshund-775": 1037987, + "patient-lemming-560": 1037993, + "dynamic-meadowlark-752": 1037994, + "prestigious-husky-596": 1037996, + "incredible-ferret-326": 1037997, + "rugged-axolotl-546": 1037997, + "kindred-hyena-232": 1038000, + "grandiose-bobcat-261": 1038001, + "tough-jellyfish-924": 1038003, + "secret-ostrich-258": 1038004, + "woozy-shark-414": 1038009, + "academic-gull-409": 1038010, + "incredible-ocelot-350": 1038020, + "quaint-mosquito-847": 1038021, + "proper-skunk-941": 1038029, + "fine-mosquito-18": 1038031, + "vivid-panda-194": 1038041, + "neat-blackbird-338": 1038042, + "first-firefly-724": 1038047, + "pastel-shark-639": 1038048, + "decisive-chihuahua-304": 1038049, + "fearless-perch-540": 1038050, + "upbeat-hedgehog-941": 1038050, + "admired-donkey-183": 1038054, + "graceful-bison-764": 1038055, + "charming-gull-961": 1038063, + "rightful-roadrunner-179": 1038064, + "calm-bat-96": 1038065, + "grandiose-condor-383": 1038066, + "precious-raven-811": 1038067, + "proper-mouse-148": 1038068, + "glad-salmon-65": 1038069, + "abundant-dolphin-491": 1038070, + "proper-kangaroo-389": 1038077, + "next-chameleon-221": 1038078, + "avid-vole-139": 1038107, + "kindhearted-bison-752": 1038108, + "spotted-mink-679": 1038110, + "cool-eel-838": 1038111, + "amiable-stoat-793": 1038113, + "gallant-panda-834": 1038114, + "energetic-labrador-351": 1038119, + "proficient-mole-228": 1038120, + "dependable-lion-542": 1038122, + "third-sparrow-279": 1038124, + "wooden-tern-635": 1038134, + "courteous-schnauzer-263": 1038136, + "fiery-akita-852": 1038146, + "blissful-hyena-234": 1038147, + "terrific-salamander-228": 1038149, + "decisive-clownfish-877": 1038150, + "shiny-rooster-91": 1038150, + "knowing-marlin-653": 1038162, + "adjoining-ram-326": 1038163, + "valiant-chipmunk-524": 1038164, + "grateful-raccoon-377": 1038165, + "colorless-swan-85": 1038165, + "trustworthy-ferret-405": 1038180, + "elegant-dinosaur-683": 1038181, + "curious-whale-208": 1038182, + "proficient-shepherd-770": 1038184, + "little-ram-772": 1038188, + "superb-frog-326": 1038190, + "tough-gnat-866": 1038190, + "fiery-buffalo-465": 1038194, + "glorious-eagle-895": 1038195, + "patient-okapi-5": 1038196, + "kindly-dolphin-984": 1038197, + "fabulous-cobra-752": 1038201, + "elated-clownfish-559": 1038202, + "hardy-hyena-433": 1038209, + "spotted-guineapig-533": 1038210, + "gregarious-butterfly-580": 1038212, + "cheerful-heron-465": 1038213, + "befitting-narwhal-96": 1038220, + "animated-tiger-338": 1038221, + "hardy-goldfish-660": 1038221, + "secret-cobra-986": 1038225, + "deafening-oyster-13": 1038227, + "exciting-giraffe-388": 1038232, + "accurate-malamute-172": 1038233, + "lovely-mouse-301": 1038255, + "calm-buzzard-928": 1038256, + "ceaseless-scorpion-772": 1038256, + "aromatic-jaguar-688": 1038273, + "capable-bulldog-684": 1038275, + "fleet-goat-126": 1038294, + "sleek-puma-409": 1038295, + "adorable-guanaco-615": 1038319, + "dashing-crocodile-982": 1038320, + "dependable-marmot-992": 1038321, + "neighborly-wolverine-415": 1038322, + "resilient-dog-754": 1038336, + "lovely-kangaroo-426": 1038338, + "silent-gerbil-945": 1038347, + "agile-bird-998": 1038348, + "clean-robin-290": 1038349, + "courteous-bass-706": 1038351, + "content-bison-809": 1038354, + "gregarious-tapir-829": 1038355, + "vibrant-ptarmigan-556": 1038361, + "dusty-buffalo-821": 1038362, + "marvelous-partridge-363": 1038378, + "keen-panther-248": 1038379, + "quiet-peacock-19": 1038384, + "glad-clam-483": 1038385, + "neat-spaniel-163": 1038387, + "industrious-crow-478": 1038388, + "decisive-oriole-687": 1038389, + "warmhearted-tapir-98": 1038390, + "qualified-wombat-15": 1038390, + "small-roadrunner-504": 1038403, + "small-meerkat-505": 1038404, + "bold-badger-361": 1038405, + "intent-seal-2": 1038406, + "grateful-blackbird-465": 1038416, + "laudable-kookabura-248": 1038417, + "compassionate-pika-834": 1038437, + "grateful-mole-380": 1038439, + "accurate-octopus-732": 1038442, + "coordinated-mandrill-727": 1038443, + "wooden-peccary-512": 1038447, + "beloved-bass-625": 1038448, + "veracious-dog-107": 1038453, + "good-nightingale-216": 1038454, + "exuberant-wolverine-557": 1038462, + "tough-caribou-55": 1038463, + "disciplined-bison-836": 1038478, + "decisive-hare-993": 1038479, + "befitting-roadrunner-394": 1038480, + "brazen-frog-877": 1038481, + "confident-meadowlark-118": 1038492, + "reminiscent-jellyfish-995": 1038493, + "usable-seahorse-288": 1038500, + "acrobatic-opossum-158": 1038501, + "aromatic-goldfish-518": 1038515, + "little-reindeer-456": 1038516, + "unique-donkey-813": 1038516, + "majestic-dinosaur-647": 1038525, + "wary-porcupine-830": 1038526, + "groovy-coyote-568": 1038548, + "tremendous-gerbil-478": 1038549, + "adamant-ermine-275": 1038549, + "fantastic-monitor-443": 1038560, + "decisive-vulture-45": 1038562, + "perfect-snake-888": 1038562, + "coordinated-ferret-298": 1038570, + "quiet-elephant-331": 1038572, + "formal-bulldog-209": 1038581, + "scintillating-dodo-886": 1038582, + "outgoing-marmot-675": 1038592, + "adept-bison-721": 1038593, + "peaceful-magpie-498": 1038594, + "blessed-ladybug-139": 1038595, + "neat-meerkat-786": 1038595, + "proper-mockingbird-754": 1038600, + "insightful-tapir-972": 1038601, + "hearty-moose-642": 1038619, + "artful-hedgehog-647": 1038620, + "healthy-pelican-401": 1038620, + "colorless-retriever-810": 1038636, + "astute-deer-151": 1038637, + "basic-cat-165": 1038641, + "befitting-partridge-352": 1038642, + "acoustic-condor-834": 1038648, + "third-puffin-714": 1038649, + "affable-dove-917": 1038654, + "blissful-mole-89": 1038655, + "small-vulture-650": 1038696, + "formal-wildcat-870": 1038697, + "keen-parakeet-823": 1038718, + "aware-kookabura-418": 1038719, + "loyal-eel-672": 1038721, + "hidden-setter-753": 1038722, + "fearless-sandpiper-742": 1038730, + "intent-lyrebird-12": 1038731, + "energetic-spoonbill-110": 1038738, + "greedy-falcon-338": 1038740, + "industrious-camel-995": 1038754, + "healthy-finch-967": 1038755, + "upbeat-dogfish-622": 1038784, + "adorable-hornet-315": 1038785, + "exuberant-pigeon-239": 1038800, + "spotted-buzzard-838": 1038801, + "cool-impala-56": 1038806, + "fantastic-scorpion-129": 1038807, + "enduring-cassowary-850": 1038807, + "fabulous-tern-313": 1038830, + "abundant-penguin-406": 1038831, + "robust-porpoise-576": 1038834, + "qualified-goldfinch-258": 1038835, + "canny-goat-615": 1038844, + "coordinated-hummingbird-874": 1038845, + "laudable-kookabura-811": 1038846, + "joyous-bulldog-187": 1038847, + "lovely-dachshund-405": 1038849, + "posh-minnow-606": 1038851, + "wandering-ermine-494": 1038856, + "handsome-tapir-699": 1038857, + "hidden-goshawk-126": 1038861, + "majestic-tiger-812": 1038862, + "youthful-bee-840": 1038866, + "knowing-marlin-540": 1038867, + "nautical-clam-6": 1038877, + "brainy-giraffe-386": 1038878, + "fleet-marten-329": 1038898, + "brilliant-spaniel-186": 1038899, + "proper-fox-520": 1038900, + "youthful-guineapig-902": 1038901, + "festive-snail-54": 1038910, + "sleek-turtle-605": 1038911, + "amiable-sandpiper-714": 1038912, + "clear-grasshopper-485": 1038914, + "nautical-parakeet-122": 1038914, + "rosy-labrador-484": 1038943, + "quaint-herring-580": 1038944, + "useful-goshawk-615": 1038950, + "resolute-gazelle-854": 1038951, + "enduring-hare-0": 1038955, + "woozy-rhinoceros-304": 1038956, + "limitless-coyote-125": 1038959, + "effervescent-hedgehog-210": 1038960, + "necessary-perch-236": 1038960, + "flippant-mallard-611": 1038964, + "adventurous-rook-835": 1038966, + "honorable-heron-204": 1038967, + "elated-ladybug-39": 1038968, + "astute-magpie-945": 1038972, + "cautious-lynx-477": 1038973, + "compassionate-mosquito-984": 1038976, + "helpful-fox-708": 1038978, + "beloved-rat-577": 1038979, + "keen-ant-827": 1038980, + "hallowed-crow-929": 1038996, + "nautical-lapwing-237": 1038997, + "giddy-chicken-860": 1038997, + "exuberant-guineapig-931": 1039016, + "shocking-hedgehog-256": 1039017, + "canny-owl-576": 1039023, + "knowing-wolf-7": 1039024, + "bold-clam-317": 1039029, + "prestigious-bulldog-440": 1039030, + "acrobatic-tern-833": 1039030, + "tremendous-reindeer-652": 1039031, + "accomplished-elk-455": 1039032, + "moonlit-bee-483": 1039037, + "expert-nightingale-449": 1039039, + "expert-hornet-697": 1039039, + "next-hedgehog-106": 1039043, + "wry-dodo-804": 1039044, + "hearty-snake-872": 1039044, + "third-firefly-700": 1039053, + "acoustic-clam-822": 1039054, + "blissful-seahorse-774": 1039054, + "ceaseless-jaguar-898": 1039069, + "graceful-retriever-911": 1039070, + "little-goldfish-895": 1039075, + "giant-bee-920": 1039076, + "ideal-fish-417": 1039089, + "greedy-wren-946": 1039090, + "peaceful-wildebeest-798": 1039092, + "fantastic-mouse-752": 1039093, + "chatty-buzzard-707": 1039099, + "stoic-deer-417": 1039100, + "giddy-malamute-917": 1039107, + "ideal-toad-41": 1039108, + "upbeat-grouse-751": 1039109, + "shiny-stingray-981": 1039110, + "descriptive-jellyfish-162": 1039114, + "elegant-mallard-782": 1039115, + "fine-mosquito-991": 1039127, + "agile-pika-251": 1039128, + "keen-toad-834": 1039140, + "sincere-mosquito-200": 1039141, + "polite-kiwi-745": 1039172, + "veracious-emu-753": 1039173, + "terrific-marmot-409": 1039178, + "lovable-scorpion-494": 1039180, + "beloved-raven-556": 1039182, + "successful-dragon-654": 1039183, + "clever-ladybug-656": 1039191, + "precise-lark-252": 1039192, + "adept-crocodile-208": 1039198, + "coordinated-sockeye-631": 1039199, + "efficient-clam-600": 1039199, + "standing-basilisk-779": 1039205, + "joyous-flamingo-494": 1039206, + "graceful-anaconda-110": 1039207, + "successful-cricket-609": 1039208, + "wry-dinosaur-238": 1039212, + "pastel-hare-365": 1039213, + "dependable-lion-912": 1039213, + "charming-porcupine-773": 1039221, + "decisive-terrier-521": 1039222, + "perceptive-cheetah-893": 1039223, + "proficient-nightingale-392": 1039226, + "upbeat-nightingale-244": 1039231, + "resolute-ant-37": 1039232, + "quick-fly-132": 1039235, + "reminiscent-gnu-123": 1039237, + "greedy-bird-407": 1039237, + "combative-spider-426": 1039241, + "harmless-roadrunner-542": 1039244, + "rapid-sockeye-660": 1039247, + "quirky-basilisk-183": 1039249, + "sincere-bass-647": 1039254, + "modest-trout-255": 1039255, + "befitting-spaniel-260": 1039269, + "patient-salmon-812": 1039271, + "necessary-porpoise-58": 1039281, + "acoustic-crane-856": 1039283, + "resilient-bird-214": 1039285, + "ardent-condor-591": 1039286, + "scintillating-minnow-659": 1039291, + "expert-terrier-88": 1039292, + "majestic-corgi-55": 1039296, + "focused-owl-511": 1039297, + "decisive-crow-945": 1039297, + "dependable-pelican-891": 1039301, + "cautious-seahorse-559": 1039302, + "sensible-badger-93": 1039303, + "keen-bass-512": 1039304, + "mild-duck-227": 1039316, + "disciplined-kudu-431": 1039317, + "tame-caribou-508": 1039324, + "neat-cuttlefish-488": 1039326, + "intent-bee-98": 1039360, + "insightful-caterpillar-930": 1039361, + "aware-falcon-776": 1039361, + "fearless-squirrel-957": 1039379, + "elated-anaconda-493": 1039383, + "benevolent-okapi-614": 1039396, + "kindred-capybara-264": 1039397, + "accomplished-cormorant-93": 1039397, + "modest-cardinal-816": 1039399, + "wooden-gazelle-695": 1039400, + "little-hummingbird-638": 1039408, + "amiable-whale-704": 1039409, + "impressive-antelope-874": 1039409, + "industrious-eagle-437": 1039414, + "efficient-kingfisher-256": 1039415, + "whimsical-marlin-265": 1039424, + "harmless-sturgeon-598": 1039425, + "knowing-gnat-562": 1039427, + "charming-penguin-879": 1039428, + "zany-puffin-15": 1039436, + "adamant-clownfish-419": 1039437, + "accurate-gecko-755": 1039440, + "affable-zebra-868": 1039441, + "moonlit-sturgeon-777": 1039441, + "different-buzzard-590": 1039442, + "dutiful-gopher-23": 1039443, + "loyal-giraffe-953": 1039443, + "aromatic-dolphin-690": 1039445, + "striped-antelope-738": 1039446, + "third-wren-334": 1039446, + "strong-alligator-456": 1039447, + "glad-llama-450": 1039448, + "posh-bison-876": 1039466, + "brazen-seal-659": 1039467, + "jovial-terrier-372": 1039472, + "tidy-hornet-684": 1039474, + "artful-marten-677": 1039474, + "diligent-mink-595": 1039475, + "fastidious-grasshopper-325": 1039476, + "healthy-condor-839": 1039478, + "diligent-spoonbill-695": 1039479, + "elegant-squirrel-228": 1039479, + "rapid-bear-488": 1039490, + "scintillating-frog-522": 1039491, + "dutiful-spoonbill-190": 1039493, + "dapper-chicken-282": 1039494, + "posh-cod-819": 1039502, + "sensible-akita-658": 1039503, + "notable-dinosaur-733": 1039503, + "opulent-hornet-782": 1039504, + "standing-gerbil-49": 1039508, + "calm-cobra-186": 1039517, + "effervescent-fish-964": 1039518, + "doting-zebra-357": 1039518, + "cool-robin-798": 1039519, + "reminiscent-narwhal-459": 1039520, + "bright-mastiff-317": 1039520, + "fantastic-wildebeest-830": 1039521, + "next-rhinoceros-120": 1039523, + "polished-camel-201": 1039525, + "abundant-bear-120": 1039526, + "third-dolphin-219": 1039535, + "wry-sockeye-279": 1039536, + "tidy-vulture-883": 1039540, + "quaint-chipmunk-301": 1039541, + "rapid-retriever-367": 1039552, + "artful-orca-366": 1039553, + "steady-jellyfish-715": 1039554, + "laudable-buzzard-466": 1039555, + "outgoing-oyster-927": 1039568, + "zany-elephant-286": 1039569, + "resilient-toucan-792": 1039572, + "brave-dalmatian-57": 1039573, + "pastel-goat-944": 1039579, + "grandiose-bee-78": 1039580, + "dependable-rabbit-639": 1039595, + "opulent-capybara-848": 1039596, + "quirky-jaguar-284": 1039599, + "brilliant-butterfly-205": 1039600, + "adorable-goose-937": 1039604, + "reliable-chickadee-388": 1039605, + "dapper-sparrow-467": 1039611, + "modest-shrimp-294": 1039612, + "ardent-sheep-159": 1039616, + "lovely-kookabura-510": 1039617, + "lovable-skunk-214": 1039617, + "deafening-llama-653": 1039619, + "clever-nightingale-973": 1039620, + "diligent-puffin-587": 1039623, + "focused-jay-442": 1039624, + "shocking-sandpiper-19": 1039632, + "wary-badger-696": 1039633, + "perfect-turtle-139": 1039644, + "necessary-stoat-630": 1039645, + "wary-otter-370": 1039654, + "disciplined-wombat-765": 1039656, + "artful-civet-956": 1039656, + "careful-raven-740": 1039671, + "amiable-trout-738": 1039672, + "descriptive-opossum-965": 1039676, + "kindred-ibis-285": 1039677, + "moonlit-condor-450": 1039680, + "aware-spoonbill-61": 1039681, + "zealous-grouse-271": 1039694, + "vivid-clownfish-128": 1039695, + "blessed-pigeon-67": 1039699, + "savory-caiman-561": 1039700, + "disciplined-ocelot-806": 1039702, + "acoustic-reindeer-860": 1039703, + "energetic-warbler-837": 1039710, + "vibrant-mouse-932": 1039712, + "laudable-oyster-481": 1039722, + "cool-falcon-766": 1039723, + "deafening-bear-177": 1039723, + "tough-elephant-150": 1039726, + "wry-panther-317": 1039727, + "intent-crow-238": 1039748, + "adorable-mole-517": 1039749, + "modest-cod-539": 1039756, + "modest-lynx-388": 1039758, + "elegant-deer-554": 1039778, + "useful-starling-802": 1039779, + "efficient-trout-944": 1039779, + "prestigious-impala-424": 1039809, + "secret-bird-897": 1039810, + "rugged-armadillo-482": 1039819, + "hip-egret-410": 1039820, + "stoic-bobcat-420": 1039821, + "adept-dalmatian-566": 1039822, + "curious-walrus-586": 1039822, + "elated-echidna-53": 1039836, + "affable-caribou-246": 1039837, + "kindly-jaguar-11": 1039838, + "dutiful-leopard-134": 1039839, + "tame-shark-52": 1039878, + "healthy-alpaca-12": 1039880, + "effervescent-stoat-951": 1039893, + "savory-condor-615": 1039894, + "giddy-clam-465": 1039900, + "healthy-dragon-936": 1039901, + "polished-condor-500": 1039908, + "polished-nightingale-527": 1039909, + "beaming-okapi-869": 1039912, + "fabulous-elephant-757": 1039913, + "graceful-retriever-520": 1039935, + "handsome-raccoon-17": 1039936, + "mild-swan-748": 1039953, + "fine-cricket-782": 1039954, + "dynamic-civet-16": 1039965, + "adept-partridge-913": 1039966, + "incredible-anteater-577": 1039985, + "acrobatic-raccoon-28": 1039987, + "bold-squirrel-791": 1039999, + "watchful-caiman-343": 1040001, + "terrific-jellyfish-814": 1040010, + "adventurous-starfish-32": 1040011, + "blessed-robin-907": 1040028, + "agile-crocodile-437": 1040029, + "lovely-peccary-799": 1040037, + "diligent-buffalo-528": 1040038, + "prestigious-sandpiper-309": 1040043, + "outstanding-avocet-848": 1040044, + "adorable-mule-879": 1040048, + "descriptive-sparrow-768": 1040049, + "scrupulous-meerkat-361": 1040050, + "acrobatic-badger-429": 1040051, + "shocking-chameleon-407": 1040059, + "effervescent-cormorant-199": 1040060, + "proper-buzzard-356": 1040061, + "abundant-cod-545": 1040062, + "graceful-beagle-852": 1040067, + "content-chipmunk-732": 1040068, + "unique-hyena-354": 1040094, + "chatty-coyote-699": 1040095, + "oceanic-caribou-893": 1040096, + "combative-spoonbill-121": 1040097, + "greedy-reindeer-203": 1040112, + "bright-spaniel-156": 1040113, + "striped-fly-543": 1040114, + "clean-woodpecker-460": 1040115, + "tacit-alpaca-116": 1040116, + "industrious-stoat-313": 1040117, + "befitting-jackal-246": 1040118, + "dynamic-hippopotamus-109": 1040119, + "determined-bass-192": 1040120, + "precious-owl-28": 1040121, + "dazzling-rabbit-222": 1040122, + "benevolent-seal-852": 1040123, + "exciting-ostrich-670": 1040124, + "groovy-guanaco-782": 1040125, + "groovy-labrador-508": 1040126, + "gallant-badger-510": 1040127, + "giant-anaconda-953": 1040128, + "quixotic-poodle-41": 1040129, + "gregarious-mastiff-676": 1040130, + "bold-pelican-348": 1040131, + "exciting-ibis-220": 1040144, + "secret-parakeet-688": 1040145, + "necessary-mongoose-277": 1040158, + "admired-moose-964": 1040159, + "calculating-mosquito-344": 1040159, + "elated-pigeon-216": 1040170, + "content-goat-304": 1040171, + "tame-crow-516": 1040175, + "groovy-cricket-663": 1040176, + "impressive-toad-334": 1040177, + "effervescent-labrador-175": 1040178, + "calm-penguin-602": 1040179, + "blessed-crow-545": 1040180, + "helpful-frog-744": 1040181, + "tremendous-squid-342": 1040182, + "energized-bison-345": 1040183, + "tremendous-crab-583": 1040184, + "fastidious-eel-649": 1040185, + "rightful-goldfinch-693": 1040186, + "adamant-ibex-827": 1040187, + "pleasant-schnauzer-275": 1040188, + "decisive-starling-369": 1040189, + "woozy-moose-0": 1040190, + "spotted-meerkat-784": 1040192, + "friendly-platypus-126": 1040193, + "adorable-chameleon-798": 1040194, + "uncommon-barracuda-566": 1040195, + "agile-wildebeest-118": 1040197, + "adjoining-yak-207": 1040198, + "third-hippopotamus-87": 1040206, + "zany-magpie-526": 1040207, + "striped-gnat-646": 1040213, + "tidy-shark-342": 1040214, + "ceaseless-narwhal-305": 1040215, + "giant-ant-429": 1040216, + "curious-dalmatian-486": 1040217, + "adorable-hyena-778": 1040218, + "rare-stingray-524": 1040219, + "quiet-mammoth-191": 1040220, + "opulent-giraffe-159": 1040221, + "hearty-wildcat-677": 1040222, + "scrupulous-bullfrog-124": 1040223, + "intent-eel-476": 1040224, + "amicable-bloodhound-247": 1040225, + "adjoining-hummingbird-123": 1040226, + "famous-wombat-820": 1040227, + "reminiscent-butterfly-855": 1040228, + "optimistic-quail-130": 1040229, + "blessed-armadillo-879": 1040230, + "graceful-clownfish-499": 1040231, + "decisive-lemming-272": 1040232, + "befitting-cow-582": 1040233, + "animated-kangaroo-937": 1040234, + "tremendous-mongoose-857": 1040242, + "compassionate-frog-152": 1040243, + "reminiscent-crocodile-810": 1040248, + "shiny-husky-670": 1040249, + "greedy-tiger-349": 1040251, + "coordinated-anaconda-75": 1040252, + "quirky-lapwing-963": 1040253, + "fabulous-magpie-939": 1040254, + "glorious-dalmatian-215": 1040265, + "kindhearted-clam-603": 1040266, + "clear-capybara-716": 1040268, + "focused-wildcat-411": 1040269, + "keen-spider-757": 1040270, + "dashing-wolverine-519": 1040271, + "secret-barracuda-805": 1040282, + "pleasant-lynx-292": 1040284, + "hardy-rat-609": 1040285, + "loyal-otter-442": 1040289, + "moonlit-rat-298": 1040291, + "formal-lemming-305": 1040302, + "qualified-cobra-43": 1040303, + "intent-mastiff-14": 1040304, + "dapper-cheetah-15": 1040305, + "woozy-beagle-646": 1040342, + "peaceful-wombat-134": 1040343, + "tangible-oyster-611": 1040347, + "watchful-ibis-827": 1040348, + "dazzling-roadrunner-431": 1040348, + "capable-koala-597": 1040363, + "pleasant-ibis-453": 1040364, + "grand-heron-318": 1040382, + "warmhearted-alpaca-753": 1040383, + "compassionate-warbler-117": 1040385, + "polished-finch-544": 1040386, + "rightful-vole-792": 1040414, + "spotted-caterpillar-869": 1040415, + "different-dog-103": 1040416, + "outstanding-lark-947": 1040417, + "quick-dogfish-764": 1040420, + "deafening-goshawk-257": 1040423, + "artful-tortoise-359": 1040430, + "festive-crow-967": 1040431, + "zany-cassowary-440": 1040437, + "capable-akita-518": 1040438, + "amicable-sardine-680": 1040441, + "successful-newt-948": 1040442, + "courteous-toad-115": 1040443, + "harmless-retriever-887": 1040444, + "woozy-cheetah-393": 1040448, + "neat-wren-735": 1040449, + "wonderful-cormorant-809": 1040460, + "sleek-weasel-368": 1040461, + "energetic-anteater-651": 1040463, + "tidy-otter-341": 1040464, + "fleet-dogfish-602": 1040484, + "hearty-civet-273": 1040485, + "enduring-alpaca-45": 1040496, + "acoustic-gopher-100": 1040497, + "enduring-penguin-258": 1040514, + "quaint-cricket-322": 1040515, + "admired-hippopotamus-387": 1040515, + "tremendous-tiger-124": 1040531, + "watchful-starfish-809": 1040532, + "healthy-buffalo-600": 1040540, + "energized-badger-939": 1040549, + "cautious-lark-652": 1040550, + "fastidious-cardinal-665": 1040559, + "neighborly-skunk-184": 1040560, + "ceaseless-firefly-235": 1040568, + "scrupulous-panther-139": 1040569, + "original-sturgeon-366": 1040582, + "patient-sockeye-726": 1040583, + "brainy-moose-189": 1040583, + "impartial-schnauzer-470": 1040586, + "fearless-panther-134": 1040587, + "reliable-pigeon-205": 1040593, + "adamant-impala-140": 1040594, + "agreeable-buzzard-786": 1040608, + "cheerful-puffin-986": 1040609, + "resolute-hare-429": 1040611, + "mellow-parakeet-35": 1040612, + "terrific-donkey-956": 1040614, + "accurate-sparrow-798": 1040615, + "gregarious-ptarmigan-138": 1040617, + "amiable-antelope-920": 1040619, + "wonderful-squid-218": 1040630, + "tidy-herring-632": 1040631, + "bright-perch-911": 1040635, + "fiery-monitor-135": 1040636, + "perfect-giraffe-932": 1040651, + "effervescent-cormorant-568": 1040652, + "prestigious-penguin-501": 1040653, + "healthy-oyster-157": 1040655, + "courteous-porcupine-45": 1040656, + "mild-skunk-749": 1040657, + "aware-ocelot-948": 1040658, + "clear-sockeye-521": 1040659, + "effervescent-llama-186": 1040660, + "kindhearted-dogfish-960": 1040661, + "mild-cuttlefish-155": 1040662, + "superb-llama-505": 1040663, + "wry-wombat-397": 1040664, + "wonderful-puma-583": 1040665, + "dependable-basilisk-505": 1040666, + "little-woodpecker-944": 1040667, + "beloved-stork-256": 1040668, + "dynamic-snail-397": 1040669, + "small-bullfrog-374": 1040670, + "formal-horse-798": 1040671, + "secret-hummingbird-69": 1040675, + "wry-penguin-276": 1040676, + "utmost-goat-366": 1040678, + "first-marten-151": 1040679, + "marvelous-emu-740": 1040685, + "tacit-corgi-428": 1040686, + "formal-echidna-851": 1040686, + "tangible-wildebeest-446": 1040690, + "precise-salamander-414": 1040692, + "doting-condor-682": 1040695, + "third-caribou-232": 1040696, + "knowing-meadowlark-425": 1040700, + "blissful-kudu-574": 1040701, + "brainy-snake-222": 1040708, + "clever-cuttlefish-116": 1040715, + "industrious-flamingo-157": 1040716, + "grandiose-greyhound-383": 1040718, + "adventurous-toad-370": 1040719, + "grateful-toad-200": 1040721, + "valiant-sheep-839": 1040722, + "outgoing-tern-703": 1040722, + "elated-caterpillar-71": 1040724, + "elegant-husky-869": 1040725, + "basic-ostrich-565": 1040727, + "artful-ermine-920": 1040728, + "wooden-anteater-891": 1040728, + "flippant-goat-727": 1040730, + "frugal-cobra-913": 1040731, + "gallant-peccary-147": 1040732, + "little-mammoth-805": 1040733, + "unique-dinosaur-977": 1040734, + "exuberant-camel-148": 1040735, + "fleet-rook-173": 1040736, + "upbeat-ladybug-106": 1040737, + "sensible-hare-611": 1040738, + "amiable-wildebeest-522": 1040739, + "doting-puma-638": 1040740, + "usable-cricket-532": 1040741, + "precious-porcupine-259": 1040742, + "clever-vulture-88": 1040743, + "striped-axolotl-955": 1040744, + "calculating-starfish-46": 1040745, + "silent-swan-68": 1040746, + "rugged-zebra-742": 1040747, + "opulent-wombat-584": 1040748, + "small-clam-704": 1040749, + "efficient-fly-621": 1040750, + "perfect-ibis-584": 1040751, + "fearless-gecko-844": 1040755, + "clear-bat-787": 1040756, + "giddy-tortoise-804": 1040756, + "trustworthy-oyster-934": 1040757, + "uncommon-pelican-128": 1040758, + "tremendous-dragon-195": 1040760, + "quick-dolphin-682": 1040761, + "nautical-meerkat-665": 1040762, + "original-sheep-514": 1040763, + "graceful-otter-308": 1040764, + "cautious-setter-121": 1040765, + "fleet-grouse-154": 1040766, + "agile-woodpecker-223": 1040767, + "lovely-robin-97": 1040768, + "small-anaconda-482": 1040769, + "dashing-mule-773": 1040770, + "quirky-ox-966": 1040771, + "judicious-monitor-332": 1040772, + "benevolent-ermine-543": 1040773, + "joyous-cod-997": 1040774, + "different-sockeye-252": 1040775, + "tough-labrador-270": 1040776, + "pastel-platypus-810": 1040777, + "insightful-gopher-289": 1040778, + "tidy-wren-888": 1040779, + "limitless-vulture-306": 1040785, + "scintillating-goldfinch-823": 1040786, + "blissful-bison-470": 1040787, + "neighborly-dog-481": 1040788, + "adjoining-eagle-51": 1040798, + "exciting-heron-230": 1040799, + "quixotic-dotterel-204": 1040800, + "elated-swan-668": 1040801, + "elated-salamander-830": 1040802, + "impartial-lobster-372": 1040803, + "grandiose-bloodhound-889": 1040804, + "earnest-gull-81": 1040805, + "first-viper-321": 1040806, + "loyal-bass-144": 1040807, + "kindhearted-cardinal-66": 1040808, + "perceptive-rhinoceros-19": 1040810, + "deafening-ibex-128": 1040811, + "dapper-newt-601": 1040812, + "colorful-bee-522": 1040813, + "nautical-bobcat-561": 1040814, + "enduring-bee-27": 1040815, + "fabulous-donkey-852": 1040816, + "brilliant-toad-310": 1040817, + "lovely-alligator-426": 1040818, + "merry-shrimp-443": 1040827, + "beloved-mastiff-420": 1040828, + "fiery-lyrebird-967": 1040829, + "hearty-setter-223": 1040830, + "benevolent-lemming-745": 1040833, + "outstanding-hamster-720": 1040834, + "upbeat-orca-474": 1040835, + "scrupulous-cow-354": 1040836, + "pleasant-fox-125": 1040837, + "charming-skunk-658": 1040838, + "tacit-roadrunner-414": 1040839, + "clear-newt-577": 1040840, + "warmhearted-swan-474": 1040841, + "greedy-sturgeon-634": 1040842, + "knowing-moose-177": 1040843, + "modest-possum-954": 1040844, + "gallant-fennec-819": 1040845, + "honorable-finch-66": 1040846, + "rare-cobra-281": 1040847, + "accomplished-chickadee-639": 1040848, + "famous-peacock-98": 1040849, + "ardent-mosquito-815": 1040850, + "necessary-badger-807": 1040851, + "veracious-oriole-458": 1040852, + "charming-sandpiper-618": 1040858, + "elegant-terrier-975": 1040859, + "vibrant-grasshopper-984": 1040860, + "helpful-flamingo-148": 1040861, + "woozy-crocodile-519": 1040875, + "fortunate-crocodile-292": 1040876, + "strong-toad-697": 1040877, + "tacit-ibis-979": 1040878, + "effervescent-goldfish-881": 1040879, + "stoic-quail-477": 1040880, + "exuberant-sheep-455": 1040881, + "rosy-salmon-85": 1040882, + "rare-seahorse-618": 1040883, + "adamant-butterfly-710": 1040884, + "wandering-ermine-907": 1040885, + "reliable-bird-741": 1040886, + "zany-gopher-329": 1040887, + "proper-caiman-886": 1040888, + "adamant-horse-380": 1040889, + "hushed-sheep-990": 1040890, + "flippant-falcon-338": 1040891, + "agreeable-bat-928": 1040892, + "blessed-ptarmigan-979": 1040893, + "amicable-jellyfish-106": 1040894, + "modest-bandicoot-913": 1040904, + "befitting-grasshopper-469": 1040905, + "befitting-warbler-691": 1040907, + "intent-hyena-441": 1040908, + "graceful-emu-394": 1040909, + "frugal-impala-55": 1040910, + "tacit-cassowary-836": 1040910, + "clean-wildebeest-920": 1040913, + "sensible-bobcat-694": 1040914, + "upbeat-deer-552": 1040915, + "stoic-mastiff-953": 1040916, + "savory-frog-587": 1040917, + "necessary-sardine-732": 1040918, + "little-corgi-318": 1040919, + "focused-badger-289": 1040920, + "disciplined-roadrunner-674": 1040921, + "famous-mastiff-573": 1040922, + "polished-retriever-461": 1040923, + "first-elk-510": 1040924, + "outgoing-puma-455": 1040925, + "energized-sockeye-476": 1040926, + "strong-quail-527": 1040927, + "hushed-octopus-896": 1040928, + "determined-oyster-352": 1040929, + "quirky-anaconda-899": 1040930, + "formal-gerbil-952": 1040931, + "grand-herring-422": 1040932, + "quaint-egret-168": 1040934, + "steady-peacock-913": 1040935, + "proficient-jackal-483": 1040935, + "academic-bass-689": 1040938, + "effervescent-rat-326": 1040939, + "brilliant-snake-361": 1040939, + "strong-puma-254": 1040942, + "flippant-ostrich-673": 1040943, + "friendly-rooster-695": 1040945, + "patient-starfish-858": 1040946, + "brilliant-vole-179": 1040948, + "shiny-jay-294": 1040949, + "grandiose-lion-395": 1040950, + "majestic-peacock-580": 1040951, + "helpful-crab-874": 1040952, + "quiet-curlew-916": 1040953, + "optimistic-cormorant-700": 1040954, + "fastidious-porpoise-227": 1040955, + "cool-civet-444": 1040956, + "knowing-chihuahua-875": 1040957, + "brave-elephant-220": 1040958, + "amicable-elk-592": 1040959, + "quirky-perch-270": 1040960, + "abundant-newt-158": 1040961, + "canny-jellyfish-524": 1040962, + "academic-kookabura-269": 1040963, + "graceful-duck-999": 1040964, + "laudable-wildcat-861": 1040965, + "youthful-rook-628": 1040966, + "trustworthy-lyrebird-256": 1040967, + "lovely-husky-587": 1040968, + "posh-rooster-850": 1040969, + "watchful-meadowlark-282": 1040974, + "polished-ladybug-46": 1040975, + "unique-ostrich-690": 1040980, + "upbeat-hawk-532": 1040981, + "affable-crocodile-93": 1040984, + "fine-canary-157": 1040985, + "charming-chipmunk-819": 1040987, + "incredible-gecko-466": 1040988, + "dynamic-alpaca-416": 1040989, + "next-oyster-221": 1040990, + "descriptive-hippopotamus-762": 1040991, + "amiable-zebra-190": 1040992, + "determined-mandrill-816": 1040993, + "lovely-mosquito-110": 1040994, + "vibrant-marten-739": 1040995, + "fearless-ocelot-513": 1040996, + "astute-herring-11": 1040997, + "academic-hound-117": 1040998, + "necessary-lynx-287": 1040999, + "lovable-porcupine-194": 1041000, + "industrious-mongoose-933": 1041001, + "valiant-reindeer-286": 1041002, + "ideal-koala-215": 1041003, + "whimsical-tapir-320": 1041004, + "keen-bobcat-402": 1041005, + "trustworthy-hedgehog-980": 1041006, + "rare-reindeer-480": 1041007, + "wary-stoat-59": 1041008, + "rosy-chicken-40": 1041009, + "tacit-condor-513": 1041019, + "friendly-raccoon-962": 1041021, + "curious-zebra-493": 1041025, + "polite-porpoise-82": 1041026, + "merry-gull-843": 1041029, + "scrupulous-wildebeest-152": 1041030, + "animated-raven-332": 1041031, + "beloved-guineapig-788": 1041032, + "agreeable-spoonbill-84": 1041032, + "enchanted-bobcat-528": 1041041, + "tame-peacock-633": 1041042, + "quixotic-crab-154": 1041047, + "grand-turtle-430": 1041048, + "neat-seal-126": 1041049, + "clever-sandpiper-57": 1041051, + "hidden-ladybug-234": 1041051, + "outgoing-greyhound-127": 1041053, + "adept-puffin-138": 1041054, + "precious-sheep-206": 1041054, + "unique-lobster-331": 1041055, + "rapid-guineapig-367": 1041056, + "calm-marlin-336": 1041057, + "capable-nightingale-652": 1041058, + "intent-frog-795": 1041061, + "shiny-partridge-656": 1041062, + "next-tortoise-590": 1041062, + "amicable-fly-583": 1041063, + "quaint-trout-119": 1041064, + "glad-orca-519": 1041064, + "adorable-anteater-235": 1041069, + "affable-badger-915": 1041070, + "bright-bird-936": 1041072, + "beaming-puffin-384": 1041073, + "rosy-spider-303": 1041075, + "dazzling-akita-514": 1041076, + "lovable-puffin-692": 1041080, + "careful-gull-966": 1041081, + "agile-lemur-239": 1041083, + "sensible-gazelle-355": 1041084, + "beloved-bat-978": 1041089, + "neighborly-fennec-473": 1041090, + "cool-lyrebird-187": 1041098, + "scrupulous-marten-146": 1041099, + "hardy-loris-921": 1041100, + "honorable-fennec-420": 1041101, + "clever-stork-274": 1041104, + "artful-whale-313": 1041105, + "descriptive-shepherd-901": 1041108, + "dapper-sparrow-873": 1041109, + "tough-chipmunk-419": 1041111, + "fortunate-wildcat-415": 1041112, + "accomplished-chihuahua-918": 1041115, + "ceaseless-goshawk-517": 1041116, + "knowing-ocelot-737": 1041118, + "adept-meerkat-408": 1041119, + "energized-nightingale-479": 1041119, + "academic-elk-415": 1041123, + "capable-clam-656": 1041124, + "upbeat-badger-397": 1041127, + "hearty-iguana-832": 1041128, + "fortunate-cobra-151": 1041136, + "adventurous-owl-709": 1041137, + "brainy-grouse-518": 1041142, + "adorable-boar-382": 1041143, + "tacit-swordfish-692": 1041144, + "kindly-turtle-885": 1041146, + "intent-armadillo-888": 1041147, + "impressive-bear-980": 1041148, + "knowing-guineapig-142": 1041151, + "doting-hedgehog-17": 1041152, + "aware-chameleon-119": 1041153, + "adept-puma-901": 1041154, + "hip-capybara-252": 1041155, + "grand-kingfisher-259": 1041156, + "artful-iguana-399": 1041157, + "brave-ibis-243": 1041158, + "rosy-pony-355": 1041159, + "dynamic-rhinoceros-494": 1041160, + "disciplined-impala-167": 1041162, + "original-bass-811": 1041163, + "diligent-giraffe-551": 1041164, + "lovely-whale-474": 1041165, + "notable-husky-75": 1041166, + "limitless-snail-653": 1041167, + "perceptive-chickadee-892": 1041168, + "different-salmon-614": 1041169, + "determined-butterfly-390": 1041170, + "hardy-crane-985": 1041171, + "fearless-kookabura-973": 1041173, + "patient-hare-100": 1041174, + "limitless-wombat-542": 1041180, + "dapper-pony-338": 1041181, + "clear-aardvark-804": 1041184, + "tame-heron-599": 1041185, + "beaming-rhinoceros-429": 1041189, + "combative-elk-274": 1041190, + "enchanted-manatee-24": 1041201, + "curious-dragon-89": 1041203, + "artful-antelope-712": 1041210, + "lovely-gazelle-485": 1041211, + "precious-stork-27": 1041212, + "brilliant-ostrich-737": 1041213, + "fiery-impala-746": 1041215, + "sensible-flamingo-13": 1041216, + "wry-mockingbird-563": 1041219, + "proficient-meerkat-526": 1041220, + "merry-curlew-580": 1041221, + "descriptive-spoonbill-443": 1041223, + "combative-barracuda-471": 1041223, + "dapper-whale-667": 1041232, + "adjoining-alpaca-258": 1041233, + "upbeat-guineapig-515": 1041236, + "modest-kingfisher-936": 1041237, + "resolute-crab-983": 1041243, + "lovely-salamander-811": 1041244, + "rightful-cuttlefish-12": 1041244, + "original-chicken-435": 1041248, + "honorable-eel-31": 1041249, + "incredible-ram-349": 1041253, + "different-lapwing-544": 1041254, + "graceful-retriever-836": 1041254, + "flexible-whale-551": 1041255, + "kindly-dolphin-1": 1041256, + "capable-akita-400": 1041264, + "mild-boar-553": 1041265, + "earnest-firefly-44": 1041265, + "adorable-porpoise-735": 1041272, + "tidy-frog-20": 1041274, + "flippant-spider-971": 1041277, + "bright-parakeet-387": 1041278, + "confident-gecko-397": 1041284, + "dynamic-squid-837": 1041285, + "joyous-chipmunk-422": 1041289, + "secret-dove-647": 1041290, + "clever-penguin-225": 1041297, + "little-okapi-312": 1041298, + "adjoining-pika-651": 1041303, + "different-woodpecker-714": 1041304, + "sleek-porpoise-708": 1041307, + "fortunate-puma-964": 1041308, + "groovy-emu-502": 1041308, + "giant-tern-341": 1041320, + "artful-peccary-274": 1041321, + "healthy-chipmunk-544": 1041321, + "quaint-elephant-882": 1041322, + "harmless-bat-502": 1041323, + "striped-sockeye-377": 1041324, + "affable-reindeer-265": 1041325, + "cautious-partridge-111": 1041325, + "tough-kookabura-117": 1041329, + "joyous-horse-761": 1041330, + "doting-anaconda-58": 1041349, + "reliable-donkey-529": 1041350, + "hallowed-salmon-344": 1041351, + "grandiose-panda-458": 1041352, + "friendly-buffalo-269": 1041361, + "perceptive-fox-740": 1041362, + "avid-chameleon-945": 1041367, + "cheerful-reindeer-622": 1041368, + "majestic-fly-183": 1041370, + "accomplished-bandicoot-302": 1041371, + "giant-magpie-831": 1041394, + "resilient-finch-402": 1041395, + "jovial-shepherd-534": 1041412, + "qualified-jackal-995": 1041413, + "patient-hummingbird-122": 1041419, + "third-boar-798": 1041420, + "cool-minnow-676": 1041425, + "adorable-anteater-799": 1041426, + "rightful-grouse-358": 1041428, + "moonlit-alpaca-440": 1041429, + "brave-shrimp-697": 1041431, + "admired-mongoose-641": 1041431, + "superb-swan-262": 1041432, + "standing-heron-798": 1041433, + "agreeable-roadrunner-302": 1041436, + "wooden-crocodile-737": 1041437, + "amicable-bulldog-849": 1041440, + "intent-gull-720": 1041441, + "shocking-fish-830": 1041449, + "keen-gnat-731": 1041451, + "bold-husky-610": 1041454, + "accomplished-vulture-662": 1041455, + "dynamic-rat-394": 1041459, + "dependable-ram-309": 1041461, + "elated-echidna-469": 1041461, + "standing-opossum-612": 1041469, + "trustworthy-schnauzer-32": 1041470, + "kindly-chinchilla-433": 1041470, + "cautious-seahorse-970": 1041472, + "tidy-hawk-602": 1041473, + "astute-fish-201": 1041474, + "uncommon-hippopotamus-313": 1041475, + "giddy-puma-44": 1041483, + "enduring-finch-987": 1041484, + "brainy-alpaca-829": 1041487, + "animated-newt-859": 1041488, + "flippant-spider-226": 1041488, + "coordinated-wildebeest-868": 1041489, + "clean-spider-739": 1041490, + "outstanding-goshawk-303": 1041496, + "content-flamingo-436": 1041498, + "silent-bee-837": 1041506, + "unique-impala-247": 1041507, + "polite-squirrel-478": 1041514, + "sensible-meerkat-983": 1041515, + "valiant-badger-69": 1041523, + "knowing-lobster-902": 1041524, + "next-koala-316": 1041524, + "veracious-cassowary-14": 1041528, + "peaceful-wolf-861": 1041529, + "tough-raven-788": 1041545, + "peaceful-sturgeon-296": 1041546, + "striped-goose-712": 1041555, + "patient-warthog-771": 1041556, + "proficient-fennec-694": 1041561, + "prestigious-kingfisher-752": 1041562, + "qualified-ibis-720": 1041562, + "accomplished-shepherd-813": 1041563, + "majestic-rat-126": 1041564, + "grandiose-pelican-712": 1041571, + "polite-owl-360": 1041572, + "content-puffin-59": 1041576, + "precious-perch-47": 1041577, + "compassionate-puma-559": 1041577, + "hidden-tortoise-487": 1041583, + "ideal-dinosaur-526": 1041584, + "knowing-wildcat-40": 1041585, + "rare-bullfrog-939": 1041586, + "kindred-bloodhound-269": 1041588, + "good-mouse-331": 1041589, + "marvelous-goldfish-355": 1041589, + "valiant-bee-664": 1041590, + "helpful-akita-199": 1041591, + "rightful-newt-364": 1041601, + "hip-corgi-539": 1041602, + "small-chickadee-435": 1041608, + "fast-okapi-215": 1041609, + "steady-goldfinch-530": 1041614, + "bright-hound-508": 1041615, + "quaint-whale-215": 1041620, + "stoic-pika-495": 1041621, + "necessary-magpie-818": 1041625, + "tough-mallard-187": 1041626, + "honorable-fox-42": 1041626, + "admired-herring-14": 1041629, + "determined-ocelot-671": 1041630, + "loyal-civet-574": 1041634, + "giant-fox-87": 1041635, + "proficient-ferret-771": 1041635, + "strong-wildcat-182": 1041637, + "enduring-squirrel-538": 1041638, + "exuberant-spaniel-247": 1041638, + "admired-cassowary-485": 1041644, + "festive-ladybug-501": 1041645, + "clean-cobra-728": 1041649, + "vivid-raccoon-575": 1041650, + "cheery-pelican-816": 1041661, + "insightful-basilisk-421": 1041662, + "chatty-donkey-943": 1041665, + "knowing-tortoise-702": 1041666, + "quaint-egret-981": 1041674, + "efficient-ladybug-5": 1041675, + "content-wombat-728": 1041677, + "amiable-warthog-709": 1041678, + "admired-grouse-784": 1041678, + "gregarious-walrus-260": 1041685, + "youthful-bandicoot-870": 1041689, + "brazen-opossum-947": 1041690, + "abundant-owl-574": 1041694, + "first-dinosaur-578": 1041695, + "cautious-moose-328": 1041697, + "brave-shark-784": 1041698, + "flexible-dog-945": 1041699, + "quiet-corgi-204": 1041703, + "giant-magpie-579": 1041704, + "warmhearted-crane-76": 1041705, + "different-kookabura-468": 1041706, + "spotted-skunk-749": 1041708, + "healthy-boar-424": 1041709, + "rosy-stork-17": 1041710, + "determined-coyote-169": 1041712, + "marvelous-llama-741": 1041722, + "standing-blackbird-136": 1041725, + "standing-horse-7": 1041729, + "careful-stingray-665": 1041730, + "graceful-bandicoot-855": 1041732, + "honorable-shrimp-666": 1041733, + "precise-pelican-932": 1041745, + "standing-albatross-4": 1041746, + "fiery-oriole-396": 1041755, + "good-oriole-519": 1041756, + "enchanted-cow-52": 1041758, + "wooden-lobster-830": 1041759, + "cool-hornet-50": 1041766, + "artful-chihuahua-888": 1041767, + "stoic-iguana-687": 1041768, + "lovable-dachshund-804": 1041769, + "hidden-frog-214": 1041770, + "watchful-kookabura-477": 1041771, + "quiet-ocelot-724": 1041775, + "adamant-rat-678": 1041776, + "tame-malamute-791": 1041784, + "terrific-cod-795": 1041785, + "striped-bat-786": 1041785, + "adjoining-hippopotamus-579": 1041797, + "flexible-pelican-466": 1041800, + "mellow-roadrunner-411": 1041801, + "reliable-viper-968": 1041801, + "modest-bandicoot-916": 1041802, + "zany-warthog-13": 1041803, + "quirky-butterfly-933": 1041805, + "agile-ptarmigan-828": 1041806, + "grand-hedgehog-204": 1041807, + "affable-peccary-929": 1041810, + "careful-chinchilla-735": 1041811, + "zealous-crocodile-219": 1041811, + "confident-crane-636": 1041815, + "neat-hamster-694": 1041816, + "content-mandrill-99": 1041820, + "zealous-marten-997": 1041821, + "wary-schnauzer-93": 1041821, + "industrious-shark-210": 1041824, + "combative-vulture-41": 1041825, + "elegant-hare-788": 1041829, + "robust-greyhound-249": 1041830, + "peaceful-turtle-933": 1041830, + "cheerful-donkey-931": 1041850, + "quixotic-snail-165": 1041851, + "rapid-fly-790": 1041853, + "outgoing-tapir-343": 1041854, + "diligent-lyrebird-992": 1041860, + "kindred-curlew-668": 1041861, + "hearty-elk-894": 1041862, + "animated-stork-63": 1041863, + "giddy-donkey-5": 1041868, + "blissful-fish-611": 1041869, + "beloved-pelican-687": 1041869, + "beloved-dogfish-674": 1041871, + "insightful-snake-209": 1041872, + "jovial-turtle-598": 1041879, + "merry-swordfish-24": 1041880, + "qualified-hyena-643": 1041895, + "tacit-bulldog-652": 1041896, + "clear-beagle-123": 1041901, + "adventurous-pony-438": 1041902, + "lovable-cassowary-754": 1041909, + "proper-egret-338": 1041912, + "glad-rooster-963": 1041915, + "merry-dove-63": 1041916, + "avid-dodo-34": 1041926, + "perceptive-hyena-151": 1041927, + "fine-dog-343": 1041929, + "patient-pony-532": 1041930, + "harmless-magpie-530": 1041931, + "acrobatic-newt-918": 1041932, + "youthful-reindeer-101": 1041935, + "canny-hippopotamus-236": 1041936, + "useful-possum-847": 1041942, + "rugged-stingray-351": 1041943, + "diligent-rabbit-799": 1041952, + "fortunate-warbler-212": 1041953, + "incredible-narwhal-222": 1041954, + "perceptive-salmon-160": 1041955, + "graceful-eagle-799": 1041956, + "polished-goshawk-438": 1041958, + "superb-quail-443": 1041960, + "proper-grasshopper-405": 1041961, + "effervescent-guanaco-4": 1041967, + "mellow-goshawk-317": 1041969, + "festive-tortoise-795": 1041970, + "abundant-tortoise-841": 1041971, + "healthy-boar-22": 1041973, + "doting-gnat-819": 1041974, + "harmless-wildebeest-64": 1041975, + "avid-manatee-456": 1041976, + "cool-shepherd-413": 1041978, + "famous-spaniel-626": 1041979, + "savory-swordfish-711": 1041988, + "strong-sparrow-100": 1041989, + "unique-tiger-880": 1041993, + "fastidious-cassowary-345": 1041994, + "effervescent-cheetah-337": 1042000, + "insightful-fennec-322": 1042001, + "superb-deer-900": 1042018, + "vibrant-cow-232": 1042019, + "abundant-anaconda-783": 1042038, + "dynamic-pig-639": 1042039, + "insightful-hawk-82": 1042041, + "majestic-marten-458": 1042043, + "enchanted-mallard-344": 1042048, + "fabulous-clam-173": 1042049, + "accurate-cat-118": 1042051, + "animated-alpaca-703": 1042052, + "peaceful-cow-913": 1042054, + "amiable-opossum-640": 1042055, + "rare-snail-133": 1042062, + "steady-spoonbill-815": 1042063, + "compassionate-pig-192": 1042067, + "vivid-buffalo-616": 1042068, + "precious-tapir-523": 1042085, + "quirky-sheep-693": 1042086, + "vibrant-ocelot-306": 1042095, + "elegant-sardine-604": 1042096, + "sincere-mole-640": 1042096, + "content-beagle-62": 1042099, + "beloved-buzzard-98": 1042100, + "ceaseless-elk-202": 1042107, + "calculating-retriever-767": 1042110, + "third-perch-970": 1042111, + "admired-rooster-256": 1042112, + "steady-yak-637": 1042115, + "sincere-salamander-942": 1042116, + "mild-boar-706": 1042119, + "kindred-crane-419": 1042120, + "accurate-lobster-712": 1042124, + "hallowed-clam-251": 1042126, + "impartial-puma-519": 1042130, + "fiery-hummingbird-538": 1042131, + "reminiscent-echidna-447": 1042131, + "rosy-mallard-18": 1042132, + "avid-nightingale-57": 1042133, + "handsome-pig-123": 1042137, + "sensible-orca-81": 1042138, + "blessed-toad-761": 1042139, + "savory-bird-424": 1042140, + "precise-marten-383": 1042140, + "hidden-jay-339": 1042141, + "dashing-fish-476": 1042142, + "neighborly-shepherd-349": 1042148, + "focused-blackbird-342": 1042149, + "fortunate-antelope-91": 1042150, + "grateful-llama-770": 1042151, + "canny-giraffe-916": 1042151, + "jovial-chinchilla-995": 1042154, + "valiant-wolverine-971": 1042155, + "brilliant-impala-848": 1042157, + "festive-egret-240": 1042159, + "calm-dalmatian-741": 1042160, + "accurate-kangaroo-973": 1042161, + "dutiful-cuttlefish-46": 1042161, + "loyal-lynx-476": 1042166, + "wary-gnat-263": 1042167, + "wary-gnu-509": 1042177, + "youthful-llama-601": 1042178, + "fastidious-bulldog-74": 1042178, + "keen-duck-559": 1042183, + "dynamic-goat-511": 1042184, + "colorful-rooster-337": 1042186, + "accomplished-butterfly-714": 1042187, + "giddy-wombat-149": 1042189, + "mild-beagle-182": 1042190, + "next-leopard-762": 1042190, + "upbeat-magpie-161": 1042198, + "steady-llama-418": 1042199, + "efficient-kudu-401": 1042202, + "calm-curlew-229": 1042203, + "adorable-platypus-414": 1042204, + "valiant-loris-563": 1042205, + "modest-hummingbird-119": 1042209, + "handsome-roadrunner-528": 1042210, + "laudable-dog-513": 1042216, + "accurate-weasel-279": 1042219, + "resolute-swordfish-139": 1042221, + "terrific-gecko-694": 1042227, + "sincere-porpoise-20": 1042228, + "cautious-albatross-483": 1042233, + "tidy-goose-713": 1042234, + "academic-dinosaur-270": 1042236, + "efficient-alligator-674": 1042237, + "trustworthy-vulture-88": 1042244, + "polite-duck-15": 1042245, + "knowing-retriever-949": 1042246, + "strong-sheep-586": 1042247, + "greedy-dragon-603": 1042247, + "posh-cheetah-207": 1042275, + "healthy-emu-839": 1042276, + "brainy-octopus-226": 1042279, + "giddy-bee-616": 1042280, + "keen-mole-302": 1042292, + "third-crane-141": 1042293, + "first-pheasant-481": 1042293, + "resilient-spaniel-506": 1042299, + "spotted-jaguar-132": 1042300, + "astute-herring-697": 1042301, + "harmless-basilisk-244": 1042302, + "proper-bison-732": 1042303, + "enchanted-clownfish-788": 1042304, + "efficient-gerbil-142": 1042313, + "qualified-cat-3": 1042314, + "calculating-kangaroo-614": 1042318, + "wooden-nightingale-842": 1042319, + "content-starfish-954": 1042321, + "youthful-vole-919": 1042322, + "incredible-loris-314": 1042324, + "majestic-armadillo-780": 1042325, + "grand-echidna-453": 1042327, + "uncommon-kingfisher-19": 1042328, + "usable-goose-783": 1042331, + "opulent-dragon-890": 1042332, + "tough-perch-969": 1042338, + "chatty-lyrebird-313": 1042341, + "cheery-newt-948": 1042345, + "earnest-goat-914": 1042347, + "loyal-cat-445": 1042361, + "vivid-guineapig-804": 1042362, + "amicable-sturgeon-461": 1042364, + "brave-warthog-470": 1042365, + "giant-dinosaur-205": 1042377, + "healthy-caribou-854": 1042378, + "dynamic-deer-488": 1042378, + "combative-octopus-777": 1042382, + "prestigious-dove-690": 1042383, + "tough-wren-513": 1042388, + "hidden-kingfisher-268": 1042389, + "laudable-vole-179": 1042391, + "curious-vulture-617": 1042392, + "greedy-orca-370": 1042393, + "third-squid-266": 1042394, + "wry-buffalo-371": 1042398, + "perceptive-kiwi-447": 1042399, + "gregarious-grouse-611": 1042405, + "canny-viper-212": 1042407, + "fortunate-platypus-490": 1042409, + "brilliant-albatross-58": 1042410, + "glad-civet-606": 1042410, + "whimsical-parrot-312": 1042411, + "joyous-eel-719": 1042412, + "fearless-labrador-409": 1042416, + "useful-grasshopper-161": 1042417, + "jovial-egret-858": 1042418, + "modest-jaguar-782": 1042419, + "grateful-puffin-534": 1042427, + "veracious-starfish-546": 1042428, + "polite-gnu-698": 1042429, + "adventurous-giraffe-872": 1042430, + "academic-dalmatian-439": 1042444, + "tough-salmon-961": 1042445, + "content-eagle-896": 1042451, + "shocking-sturgeon-192": 1042452, + "cheerful-platypus-974": 1042457, + "brave-peacock-75": 1042458, + "judicious-sardine-876": 1042468, + "next-impala-934": 1042469, + "kindly-otter-188": 1042471, + "hallowed-partridge-43": 1042472, + "keen-civet-114": 1042476, + "enduring-snake-768": 1042478, + "stoic-eagle-924": 1042486, + "hidden-horse-279": 1042487, + "successful-toucan-579": 1042488, + "moonlit-labrador-545": 1042490, + "third-owl-357": 1042505, + "enchanted-tern-278": 1042506, + "chatty-ibis-630": 1042507, + "nautical-bulldog-530": 1042508, + "valuable-goat-389": 1042513, + "healthy-rat-863": 1042514, + "acoustic-duck-871": 1042518, + "glad-mammoth-809": 1042520, + "judicious-mallard-499": 1042522, + "blessed-skunk-322": 1042523, + "fastidious-panda-476": 1042534, + "artful-seal-271": 1042535, + "deafening-ptarmigan-590": 1042535, + "fine-partridge-308": 1042537, + "adventurous-panda-794": 1042538, + "dashing-dragon-988": 1042540, + "academic-penguin-532": 1042541, + "canny-bird-393": 1042543, + "valuable-terrier-480": 1042544, + "scrupulous-spaniel-527": 1042546, + "focused-dove-716": 1042547, + "cool-okapi-167": 1042547, + "helpful-hyena-917": 1042548, + "frugal-cat-636": 1042549, + "quirky-whale-907": 1042557, + "secret-toucan-717": 1042559, + "elated-eagle-528": 1042560, + "mild-finch-824": 1042561, + "grateful-panther-351": 1042562, + "adorable-fox-306": 1042564, + "decisive-dove-372": 1042564, + "tremendous-hound-21": 1042567, + "first-alligator-131": 1042568, + "kindred-malamute-832": 1042570, + "sensible-lark-122": 1042571, + "standing-dalmatian-15": 1042574, + "focused-badger-273": 1042575, + "tangible-ferret-529": 1042587, + "majestic-pig-173": 1042588, + "rugged-cardinal-898": 1042590, + "tidy-wolverine-188": 1042592, + "patient-lapwing-331": 1042600, + "wooden-caterpillar-544": 1042601, + "incredible-possum-500": 1042603, + "hip-firefly-721": 1042604, + "compassionate-sardine-467": 1042610, + "artful-donkey-200": 1042612, + "cheery-goldfish-369": 1042617, + "wry-yak-531": 1042618, + "good-badger-621": 1042621, + "wandering-jaguar-631": 1042622, + "resolute-vole-349": 1042649, + "beloved-goshawk-953": 1042650, + "glad-hedgehog-539": 1042651, + "abundant-bird-660": 1042652, + "amicable-impala-400": 1042653, + "pastel-basilisk-26": 1042654, + "grandiose-jay-660": 1042659, + "knowing-spider-756": 1042660, + "elegant-lemur-610": 1042660, + "stoic-mockingbird-601": 1042662, + "oceanic-crocodile-220": 1042663, + "mellow-sparrow-64": 1042665, + "proper-basilisk-604": 1042667, + "affable-parrot-109": 1042677, + "scrupulous-partridge-121": 1042678, + "notable-marten-37": 1042678, + "aromatic-possum-892": 1042683, + "confident-guanaco-116": 1042684, + "savory-rabbit-476": 1042684, + "shiny-butterfly-882": 1042693, + "notable-goat-795": 1042695, + "descriptive-mink-892": 1042697, + "woozy-cricket-728": 1042698, + "cheery-woodpecker-875": 1042700, + "honorable-zebra-324": 1042701, + "calculating-ox-917": 1042712, + "grandiose-eagle-627": 1042713, + "vibrant-spoonbill-568": 1042714, + "sensible-setter-945": 1042715, + "patient-bat-42": 1042715, + "trustworthy-antelope-476": 1042724, + "astute-hound-328": 1042725, + "impressive-trout-773": 1042734, + "cheery-hare-492": 1042737, + "original-sturgeon-517": 1042745, + "mild-crab-956": 1042746, + "avid-raven-345": 1042753, + "dynamic-goldfinch-696": 1042754, + "vibrant-caterpillar-413": 1042775, + "intent-meerkat-877": 1042776, + "elated-puffin-108": 1042776, + "formal-mouse-195": 1042786, + "ideal-spoonbill-953": 1042787, + "silent-caiman-349": 1042788, + "fleet-starfish-454": 1042789, + "limitless-dragon-152": 1042791, + "animated-porcupine-443": 1042792, + "hip-monitor-100": 1042804, + "efficient-squid-443": 1042805, + "incredible-hamster-816": 1042814, + "canny-cobra-851": 1042815, + "effervescent-okapi-225": 1042816, + "incredible-hornet-365": 1042817, + "tangible-robin-722": 1042833, + "greedy-trout-48": 1042834, + "outstanding-quail-114": 1042835, + "accurate-herring-955": 1042836, + "content-woodpecker-406": 1042840, + "frugal-dinosaur-979": 1042842, + "hearty-badger-624": 1042866, + "harmless-peacock-384": 1042867, + "proper-deer-988": 1042872, + "expert-scorpion-919": 1042873, + "laudable-turtle-687": 1042875, + "hidden-gazelle-471": 1042876, + "wry-meerkat-596": 1042877, + "disciplined-meadowlark-884": 1042877, + "striped-turtle-171": 1042878, + "neighborly-firefly-650": 1042878, + "warmhearted-bird-863": 1042879, + "lovely-tortoise-719": 1042880, + "perceptive-panda-865": 1042880, + "loyal-condor-861": 1042883, + "neat-cassowary-696": 1042884, + "dusty-cheetah-308": 1042887, + "silent-jay-759": 1042888, + "quick-elephant-757": 1042889, + "upbeat-sheep-32": 1042890, + "lovely-ram-521": 1042893, + "watchful-quail-204": 1042894, + "lovable-dodo-255": 1042919, + "helpful-dolphin-636": 1042921, + "silent-turtle-834": 1042924, + "quick-camel-815": 1042925, + "accurate-badger-944": 1042926, + "woozy-ibis-552": 1042927, + "quixotic-impala-643": 1042928, + "quick-mongoose-537": 1042929, + "spotted-walrus-388": 1042936, + "polished-cormorant-721": 1042937, + "zealous-sockeye-898": 1042939, + "reminiscent-bullfrog-560": 1042940, + "uncommon-chinchilla-244": 1042953, + "fortunate-vole-297": 1042954, + "elegant-raccoon-848": 1042955, + "woozy-blackbird-397": 1042956, + "dependable-warthog-947": 1042956, + "uncommon-skunk-817": 1042961, + "flexible-badger-174": 1042962, + "spotted-clownfish-64": 1042966, + "kindhearted-gopher-398": 1042967, + "optimistic-minnow-316": 1042998, + "clear-goshawk-800": 1042999, + "terrific-schnauzer-943": 1043000, + "uncommon-cobra-353": 1043012, + "mild-condor-773": 1043013, + "prestigious-lion-763": 1043030, + "scrupulous-setter-127": 1043032, + "chatty-pheasant-974": 1043038, + "hallowed-spider-360": 1043039, + "animated-eel-316": 1043041, + "giddy-elephant-652": 1043042, + "fastidious-hare-284": 1043046, + "calm-mandrill-623": 1043046, + "majestic-nightingale-657": 1043061, + "grateful-hummingbird-733": 1043062, + "fortunate-goshawk-409": 1043062, + "elated-gecko-680": 1043063, + "hardy-retriever-718": 1043064, + "steady-hare-712": 1043076, + "bold-caribou-712": 1043077, + "amiable-albatross-34": 1043077, + "tame-yak-201": 1043088, + "wary-salmon-875": 1043089, + "marvelous-terrier-765": 1043089, + "admired-cat-879": 1043094, + "optimistic-deer-532": 1043095, + "notable-flamingo-596": 1043099, + "abundant-trout-571": 1043100, + "reliable-cow-621": 1043101, + "acoustic-kookabura-505": 1043102, + "chatty-guanaco-871": 1043109, + "unique-boar-540": 1043110, + "upbeat-husky-13": 1043113, + "bold-wolverine-365": 1043114, + "gallant-toad-826": 1043132, + "confident-marmot-711": 1043133, + "blessed-hamster-594": 1043133, + "rare-wildcat-640": 1043135, + "trustworthy-hare-302": 1043136, + "resolute-llama-338": 1043141, + "hardy-penguin-306": 1043143, + "quick-trout-706": 1043144, + "coordinated-quail-576": 1043145, + "canny-magpie-488": 1043148, + "amicable-akita-824": 1043149, + "veracious-sturgeon-184": 1043151, + "colorless-crow-728": 1043152, + "merry-alligator-283": 1043152, + "secret-opossum-496": 1043154, + "terrific-moose-746": 1043155, + "shocking-snail-755": 1043162, + "bold-wildebeest-45": 1043163, + "energized-giraffe-832": 1043165, + "grateful-schnauzer-912": 1043166, + "accurate-donkey-143": 1043172, + "friendly-boar-654": 1043174, + "vibrant-giraffe-270": 1043176, + "confident-coyote-618": 1043177, + "scintillating-hummingbird-286": 1043179, + "rightful-impala-549": 1043180, + "dashing-falcon-303": 1043185, + "stoic-iguana-880": 1043186, + "efficient-peccary-859": 1043186, + "pastel-quail-210": 1043200, + "polished-dodo-250": 1043201, + "diligent-panda-166": 1043207, + "dashing-grasshopper-389": 1043208, + "standing-dog-361": 1043215, + "pleasant-gecko-261": 1043216, + "colorless-raccoon-728": 1043216, + "knowing-canary-547": 1043218, + "quirky-capybara-890": 1043219, + "kindhearted-hippopotamus-859": 1043220, + "clever-blackbird-890": 1043221, + "dependable-otter-957": 1043224, + "accurate-hippopotamus-103": 1043226, + "handsome-lark-918": 1043233, + "notable-ladybug-408": 1043234, + "successful-cardinal-242": 1043240, + "tacit-scorpion-222": 1043241, + "rare-lapwing-165": 1043244, + "bold-quail-349": 1043246, + "first-shrimp-327": 1043273, + "disciplined-okapi-610": 1043274, + "determined-frog-550": 1043274, + "tremendous-viper-777": 1043276, + "flippant-clownfish-907": 1043277, + "steady-dog-322": 1043285, + "exuberant-puffin-180": 1043286, + "diligent-pigeon-902": 1043292, + "grandiose-badger-201": 1043293, + "helpful-alpaca-519": 1043307, + "earnest-woodpecker-387": 1043308, + "secret-giraffe-679": 1043308, + "sleek-cricket-701": 1043309, + "knowing-gopher-497": 1043310, + "polished-pony-167": 1043310, + "modest-goldfinch-354": 1043311, + "dashing-hound-2": 1043312, + "brave-stingray-599": 1043313, + "wonderful-akita-576": 1043314, + "limitless-alpaca-828": 1043321, + "zany-aardvark-608": 1043322, + "enduring-marmot-417": 1043322, + "wonderful-barracuda-504": 1043323, + "colorful-corgi-919": 1043324, + "laudable-dachshund-357": 1043327, + "strong-chinchilla-713": 1043329, + "striped-mole-376": 1043330, + "famous-ram-210": 1043332, + "impressive-crab-670": 1043333, + "usable-giraffe-66": 1043334, + "quiet-marten-141": 1043335, + "merry-manatee-756": 1043338, + "proper-oriole-501": 1043339, + "hearty-curlew-899": 1043341, + "descriptive-avocet-70": 1043342, + "intent-chameleon-739": 1043344, + "little-spaniel-492": 1043345, + "silent-jay-273": 1043352, + "keen-cod-410": 1043353, + "original-owl-443": 1043362, + "clean-shark-301": 1043363, + "charming-beagle-133": 1043369, + "benevolent-hound-729": 1043370, + "brainy-ibis-640": 1043371, + "grand-mule-781": 1043372, + "reminiscent-panda-2": 1043373, + "pleasant-octopus-165": 1043379, + "wooden-schnauzer-435": 1043380, + "expert-mammoth-327": 1043380, + "opulent-poodle-425": 1043384, + "wry-stoat-40": 1043385, + "abundant-gecko-926": 1043385, + "energized-raccoon-596": 1043390, + "stoic-puma-5": 1043391, + "hidden-panther-389": 1043392, + "silent-spaniel-376": 1043394, + "determined-narwhal-400": 1043402, + "determined-mandrill-768": 1043403, + "pastel-salamander-802": 1043407, + "accomplished-alligator-728": 1043408, + "beaming-rat-400": 1043411, + "knowing-egret-579": 1043412, + "adept-vole-170": 1043415, + "dapper-mockingbird-993": 1043416, + "qualified-bird-375": 1043421, + "fabulous-manatee-820": 1043422, + "useful-sheep-343": 1043423, + "clean-parakeet-972": 1043424, + "pleasant-hedgehog-98": 1043424, + "doting-grouse-762": 1043425, + "quixotic-raccoon-187": 1043426, + "dashing-retriever-825": 1043426, + "effervescent-ibex-779": 1043428, + "vibrant-labrador-748": 1043429, + "ceaseless-marmot-681": 1043430, + "amicable-monitor-828": 1043431, + "disciplined-hawk-762": 1043437, + "brave-marmot-841": 1043438, + "oceanic-weasel-11": 1043438, + "utmost-mole-336": 1043446, + "lovely-mouse-811": 1043447, + "neighborly-falcon-297": 1043452, + "animated-malamute-714": 1043453, + "little-crane-980": 1043465, + "third-bear-815": 1043466, + "mild-marmot-12": 1043468, + "marvelous-kiwi-29": 1043469, + "dependable-spaniel-117": 1043478, + "chatty-akita-789": 1043479, + "whimsical-grouse-226": 1043493, + "quiet-cheetah-802": 1043494, + "elegant-ptarmigan-638": 1043499, + "astute-salamander-322": 1043500, + "patient-panther-435": 1043503, + "robust-starling-980": 1043504, + "hardy-buzzard-618": 1043511, + "agreeable-pigeon-949": 1043513, + "trustworthy-possum-153": 1043515, + "elegant-hornet-832": 1043515, + "outgoing-husky-469": 1043531, + "tame-hummingbird-74": 1043532, + "judicious-bass-858": 1043542, + "hallowed-swan-603": 1043543, + "lovable-tiger-429": 1043545, + "rare-crocodile-586": 1043546, + "brazen-wolf-540": 1043548, + "opulent-weasel-386": 1043549, + "confident-axolotl-91": 1043553, + "perceptive-mink-280": 1043554, + "precious-anaconda-811": 1043563, + "compassionate-robin-125": 1043564, + "clever-hedgehog-165": 1043565, + "doting-butterfly-35": 1043576, + "focused-panther-86": 1043577, + "superb-basilisk-246": 1043577, + "aromatic-panda-306": 1043580, + "ideal-donkey-87": 1043581, + "effervescent-orca-292": 1043583, + "confident-crane-565": 1043584, + "loyal-panda-691": 1043584, + "confident-guineapig-238": 1043599, + "grateful-jay-407": 1043600, + "joyous-basilisk-178": 1043602, + "fantastic-ram-189": 1043603, + "dutiful-mastiff-226": 1043616, + "ceaseless-kudu-616": 1043617, + "precise-bison-440": 1043629, + "hardy-bullfrog-967": 1043630, + "jovial-axolotl-966": 1043659, + "academic-puma-462": 1043660, + "upbeat-hamster-376": 1043661, + "warmhearted-chihuahua-685": 1043662, + "necessary-buzzard-717": 1043663, + "aware-stork-726": 1043664, + "ceaseless-aardvark-893": 1043673, + "grateful-spoonbill-649": 1043674, + "pleasant-zebra-766": 1043688, + "adorable-stork-112": 1043689, + "reminiscent-kingfisher-543": 1043719, + "quaint-gopher-203": 1043720, + "dynamic-mole-17": 1043724, + "calm-loris-723": 1043725, + "calculating-salmon-340": 1043728, + "diligent-wildebeest-927": 1043729, + "gallant-toucan-366": 1043735, + "ideal-wombat-831": 1043736, + "lovely-wolverine-466": 1043743, + "moonlit-dalmatian-224": 1043744, + "glorious-toad-380": 1043749, + "clean-mockingbird-503": 1043750, + "gregarious-loris-10": 1043751, + "abundant-spoonbill-19": 1043752, + "superb-hornet-80": 1043753, + "calculating-dodo-776": 1043753, + "grandiose-magpie-515": 1043756, + "basic-dodo-830": 1043757, + "kindred-eel-576": 1043759, + "festive-pika-25": 1043763, + "laudable-dodo-419": 1043765, + "doting-dog-12": 1043766, + "grandiose-panda-24": 1043775, + "perfect-oyster-335": 1043777, + "flippant-owl-108": 1043791, + "wooden-roadrunner-759": 1043793, + "exuberant-bee-171": 1043801, + "exuberant-shark-966": 1043802, + "wry-guanaco-431": 1043806, + "curious-ostrich-636": 1043807, + "diligent-grasshopper-536": 1043829, + "exuberant-possum-813": 1043830, + "perceptive-owl-481": 1043855, + "ideal-toucan-26": 1043856, + "opulent-corgi-155": 1043859, + "resilient-mallard-684": 1043860, + "adorable-mammoth-994": 1043865, + "exciting-bass-723": 1043866, + "silent-impala-163": 1043874, + "brainy-bass-405": 1043875, + "courteous-owl-349": 1043892, + "jovial-giraffe-514": 1043893, + "acoustic-quail-219": 1043899, + "successful-chickadee-529": 1043900, + "adamant-trout-17": 1043904, + "dashing-hound-665": 1043905, + "accomplished-frog-284": 1043914, + "energetic-gecko-823": 1043915, + "famous-cow-138": 1043939, + "combative-jackal-191": 1043940, + "rapid-dog-164": 1043940, + "ideal-pelican-135": 1043941, + "fine-salmon-391": 1043942, + "greedy-hound-203": 1043942, + "shocking-lion-555": 1043947, + "clean-lyrebird-44": 1043948, + "shiny-possum-223": 1043948, + "combative-stoat-578": 1043951, + "grateful-rabbit-917": 1043952, + "cheerful-corgi-210": 1043954, + "superb-crane-644": 1043955, + "compassionate-sandpiper-305": 1043992, + "dapper-bat-82": 1043993, + "hip-koala-213": 1044006, + "friendly-retriever-232": 1044007, + "deafening-whale-685": 1044011, + "whimsical-platypus-465": 1044012, + "proper-caribou-155": 1044040, + "accurate-bat-603": 1044042, + "rightful-hamster-703": 1044046, + "blissful-cow-593": 1044047, + "courteous-ox-325": 1044050, + "aware-pheasant-708": 1044051, + "amicable-roadrunner-802": 1044061, + "wary-lynx-803": 1044062, + "whimsical-sheep-496": 1044074, + "pastel-caterpillar-825": 1044076, + "utmost-okapi-886": 1044078, + "wandering-squirrel-420": 1044079, + "silent-sheep-397": 1044079, + "posh-pig-615": 1044086, + "famous-meerkat-606": 1044088, + "dutiful-eel-80": 1044088, + "polite-fennec-321": 1044091, + "insightful-beagle-867": 1044092, + "fortunate-ostrich-819": 1044094, + "zany-mole-499": 1044095, + "confident-squid-677": 1044096, + "outstanding-sparrow-172": 1044097, + "benevolent-goose-852": 1044124, + "elegant-gnat-404": 1044125, + "fantastic-ferret-631": 1044137, + "fleet-capybara-451": 1044138, + "wonderful-peccary-638": 1044144, + "unique-cricket-864": 1044145, + "quirky-dogfish-434": 1044150, + "ceaseless-fish-286": 1044151, + "flippant-gerbil-923": 1044162, + "modest-caribou-642": 1044163, + "laudable-cheetah-271": 1044192, + "festive-loris-81": 1044193, + "cautious-dinosaur-760": 1044201, + "outgoing-bandicoot-473": 1044202, + "little-hippopotamus-622": 1044202, + "merry-falcon-613": 1044206, + "joyous-gecko-392": 1044207, + "handsome-dragon-583": 1044207, + "fortunate-perch-642": 1044211, + "adamant-canary-407": 1044212, + "brazen-mongoose-998": 1044231, + "giant-dolphin-354": 1044235, + "proper-hedgehog-604": 1044236, + "grateful-toucan-540": 1044236, + "hallowed-anteater-494": 1044242, + "hip-viper-657": 1044243, + "combative-ram-931": 1044258, + "resolute-woodpecker-454": 1044259, + "lovable-fly-921": 1044259, + "laudable-marlin-516": 1044262, + "different-basilisk-494": 1044263, + "fortunate-meadowlark-294": 1044263, + "outstanding-cheetah-959": 1044267, + "reliable-antelope-760": 1044268, + "valuable-shark-483": 1044269, + "precious-eagle-581": 1044270, + "cool-perch-105": 1044324, + "friendly-caribou-348": 1044330, + "confident-kiwi-141": 1044331, + "admired-aardvark-265": 1044335, + "industrious-eagle-356": 1044336, + "calm-marten-510": 1044376, + "dapper-otter-141": 1044377, + "outstanding-pigeon-348": 1044384, + "laudable-flamingo-628": 1044385, + "third-dragon-636": 1044385, + "oceanic-cobra-607": 1044396, + "merry-anaconda-339": 1044397, + "dazzling-loris-357": 1044397, + "proper-chickadee-87": 1044399, + "proficient-spider-334": 1044400, + "gregarious-chicken-532": 1044400, + "ideal-alpaca-324": 1044412, + "outstanding-ostrich-959": 1044413, + "beloved-moose-745": 1044417, + "outstanding-lobster-82": 1044418, + "honorable-opossum-693": 1044421, + "diligent-echidna-314": 1044422, + "knowing-canary-63": 1044425, + "beaming-chameleon-153": 1044427, + "hallowed-octopus-457": 1044439, + "hallowed-seahorse-346": 1044440, + "majestic-pelican-700": 1044472, + "utmost-malamute-113": 1044474, + "majestic-eel-279": 1044480, + "dapper-malamute-88": 1044481, + "determined-penguin-615": 1044483, + "flexible-marmot-861": 1044484, + "sleek-butterfly-319": 1044492, + "good-deer-714": 1044493, + "harmless-bullfrog-845": 1044493, + "helpful-monitor-521": 1044501, + "astute-hummingbird-137": 1044502, + "greedy-magpie-452": 1044502, + "rosy-cricket-43": 1044507, + "sincere-weasel-829": 1044510, + "oceanic-mouse-218": 1044535, + "good-lynx-435": 1044536, + "wandering-vole-361": 1044547, + "artful-anaconda-702": 1044548, + "cool-condor-730": 1044563, + "wonderful-moose-708": 1044564, + "cheery-fish-582": 1044564, + "savory-giraffe-302": 1044586, + "majestic-tiger-725": 1044587, + "hidden-rook-778": 1044589, + "famous-lobster-886": 1044590, + "disciplined-cheetah-28": 1044607, + "dashing-ibex-494": 1044608, + "utmost-dotterel-669": 1044626, + "aromatic-hare-933": 1044627, + "bright-spider-633": 1044639, + "majestic-caiman-895": 1044640, + "confident-heron-554": 1044640, + "uncommon-herring-794": 1044645, + "clever-hare-579": 1044646, + "enduring-mosquito-853": 1044648, + "disciplined-kookabura-519": 1044649, + "exciting-jaguar-21": 1044661, + "admired-perch-784": 1044662, + "glorious-mole-959": 1044662, + "shocking-dove-640": 1044664, + "calm-weasel-22": 1044665, + "knowing-gerbil-566": 1044665, + "agreeable-dove-967": 1044669, + "fabulous-chihuahua-204": 1044670, + "ideal-bison-918": 1044683, + "opulent-anteater-531": 1044685, + "fast-panda-716": 1044706, + "glorious-stingray-658": 1044707, + "brilliant-hound-619": 1044717, + "abundant-marmot-501": 1044718, + "watchful-antelope-45": 1044727, + "fast-kookabura-894": 1044728, + "adorable-pony-733": 1044730, + "expert-antelope-258": 1044731, + "admired-canary-997": 1044732, + "zany-perch-653": 1044733, + "neighborly-toucan-925": 1044738, + "neighborly-robin-153": 1044739, + "quirky-reindeer-687": 1044744, + "valuable-falcon-804": 1044745, + "strong-dalmatian-83": 1044748, + "affable-coyote-697": 1044749, + "giant-monitor-29": 1044754, + "combative-gerbil-427": 1044755, + "successful-jay-434": 1044781, + "industrious-bat-922": 1044782, + "acoustic-salamander-229": 1044783, + "exuberant-moose-2": 1044784, + "gallant-snake-937": 1044787, + "joyous-herring-377": 1044788, + "brave-eagle-557": 1044800, + "sleek-herring-10": 1044802, + "hidden-swan-2": 1044802, + "greedy-raven-612": 1044804, + "good-boar-88": 1044805, + "formal-retriever-124": 1044805, + "groovy-ostrich-533": 1044807, + "laudable-oyster-317": 1044808, + "giant-peccary-50": 1044818, + "giant-partridge-38": 1044819, + "tough-mandrill-283": 1044832, + "tangible-goose-167": 1044833, + "hidden-heron-522": 1044835, + "superb-dogfish-312": 1044836, + "dusty-caribou-128": 1044836, + "proper-hyena-944": 1044847, + "resolute-ladybug-182": 1044848, + "dusty-ram-944": 1044849, + "jovial-parakeet-783": 1044850, + "laudable-sockeye-779": 1044850, + "fortunate-coyote-636": 1044856, + "quixotic-whale-955": 1044857, + "wooden-panda-451": 1044868, + "dutiful-canary-584": 1044869, + "rare-buffalo-441": 1044870, + "neighborly-dinosaur-655": 1044871, + "descriptive-mongoose-757": 1044879, + "fine-ibis-217": 1044879, + "helpful-greyhound-760": 1044880, + "effervescent-octopus-259": 1044885, + "calm-alpaca-128": 1044886, + "glorious-butterfly-838": 1044886, + "giddy-yak-196": 1044895, + "brainy-beagle-698": 1044896, + "perfect-vulture-230": 1044896, + "nautical-horse-778": 1044898, + "third-setter-484": 1044899, + "adventurous-boar-607": 1044899, + "amiable-walrus-901": 1044902, + "acrobatic-spider-819": 1044903, + "ideal-ibis-909": 1044911, + "valuable-egret-360": 1044912, + "quick-alligator-54": 1044912, + "polite-octopus-526": 1044922, + "successful-akita-173": 1044923, + "handsome-guineapig-277": 1044923, + "basic-condor-572": 1044940, + "adjoining-caribou-489": 1044941, + "grand-mastiff-961": 1044941, + "dutiful-capybara-780": 1044949, + "focused-bear-542": 1044950, + "scrupulous-bass-247": 1044951, + "standing-hare-809": 1044952, + "focused-raven-182": 1044953, + "calculating-platypus-985": 1044954, + "fleet-wolf-185": 1044955, + "valiant-bee-316": 1044956, + "hallowed-pika-809": 1044957, + "rare-bullfrog-116": 1044958, + "rosy-snake-6": 1044959, + "grandiose-marten-33": 1044959, + "rapid-peacock-135": 1044960, + "stoic-avocet-623": 1044961, + "tame-alpaca-429": 1044962, + "colorless-swordfish-725": 1044963, + "warmhearted-shrimp-170": 1044964, + "dashing-ant-260": 1044965, + "posh-puma-493": 1044966, + "zealous-meadowlark-11": 1044967, + "glad-hornet-381": 1044968, + "exuberant-ferret-780": 1044969, + "outstanding-ibex-864": 1044969, + "vibrant-barracuda-369": 1044973, + "rightful-porpoise-160": 1044974, + "beaming-sockeye-59": 1044976, + "ardent-sparrow-881": 1044977, + "next-spoonbill-262": 1045005, + "usable-lapwing-738": 1045006, + "sincere-bee-602": 1045009, + "content-fox-243": 1045010, + "joyous-terrier-325": 1045010, + "academic-bear-876": 1045012, + "unique-whale-100": 1045013, + "proficient-marten-120": 1045014, + "keen-warthog-649": 1045015, + "useful-crocodile-964": 1045018, + "valiant-ibex-594": 1045019, + "perceptive-falcon-462": 1045019, + "cheerful-minnow-102": 1045028, + "deafening-duck-277": 1045029, + "good-chickadee-927": 1045035, + "jovial-meadowlark-861": 1045036, + "confident-jackal-730": 1045051, + "hearty-lark-339": 1045052, + "resolute-labrador-670": 1045065, + "efficient-alligator-471": 1045066, + "fastidious-ptarmigan-226": 1045074, + "enduring-bat-677": 1045093, + "proper-avocet-197": 1045094, + "elated-magpie-454": 1045094, + "brainy-toad-175": 1045096, + "helpful-stingray-479": 1045097, + "marvelous-cuttlefish-995": 1045100, + "determined-robin-108": 1045101, + "academic-cat-384": 1045102, + "decisive-ermine-696": 1045103, + "bold-goose-474": 1045111, + "pastel-penguin-829": 1045112, + "descriptive-skunk-441": 1045113, + "canny-frog-86": 1045114, + "uncommon-bulldog-640": 1045115, + "deafening-whale-488": 1045116, + "precious-chipmunk-660": 1045121, + "blessed-lapwing-388": 1045122, + "rugged-poodle-888": 1045125, + "brazen-avocet-188": 1045126, + "formal-mammoth-182": 1045128, + "mild-mouse-586": 1045129, + "honorable-labrador-438": 1045132, + "intent-sheep-257": 1045133, + "ardent-gopher-616": 1045133, + "descriptive-goldfinch-390": 1045134, + "amiable-deer-485": 1045135, + "dusty-gerbil-657": 1045135, + "energized-rooster-73": 1045141, + "successful-badger-673": 1045142, + "zany-greyhound-833": 1045143, + "wooden-elephant-907": 1045144, + "helpful-koala-748": 1045145, + "fastidious-starling-960": 1045146, + "proficient-platypus-980": 1045152, + "limitless-leopard-527": 1045153, + "mild-husky-694": 1045155, + "aware-sturgeon-691": 1045156, + "adjoining-mule-401": 1045158, + "cautious-penguin-639": 1045159, + "ceaseless-kangaroo-572": 1045159, + "grateful-jay-90": 1045161, + "scintillating-retriever-150": 1045162, + "ceaseless-mallard-223": 1045163, + "resolute-ladybug-176": 1045164, + "aromatic-spider-428": 1045167, + "merry-gecko-109": 1045168, + "amicable-partridge-15": 1045175, + "incredible-quail-508": 1045176, + "impressive-rat-308": 1045178, + "tidy-magpie-544": 1045179, + "lovely-jellyfish-402": 1045180, + "kindhearted-snake-789": 1045181, + "groovy-peacock-787": 1045195, + "adamant-blackbird-336": 1045196, + "dependable-seal-917": 1045200, + "valuable-spoonbill-290": 1045201, + "careful-salamander-709": 1045211, + "elated-mammoth-637": 1045212, + "grandiose-snail-827": 1045221, + "hallowed-possum-289": 1045222, + "sincere-fish-903": 1045233, + "fine-crow-806": 1045234, + "whimsical-bison-871": 1045243, + "shocking-trout-229": 1045244, + "zany-manatee-377": 1045245, + "honorable-panda-413": 1045249, + "grand-lapwing-642": 1045250, + "doting-firefly-932": 1045272, + "vivid-emu-149": 1045273, + "content-tern-189": 1045275, + "hushed-lark-868": 1045277, + "precise-ptarmigan-63": 1045289, + "stoic-bobcat-814": 1045290, + "dynamic-peccary-954": 1045293, + "scrupulous-hare-743": 1045294, + "savory-grouse-860": 1045295, + "affable-swordfish-153": 1045296, + "aromatic-otter-959": 1045298, + "exciting-kingfisher-713": 1045299, + "fastidious-hyena-317": 1045305, + "third-rhinoceros-813": 1045306, + "clear-quail-506": 1045307, + "whimsical-stingray-401": 1045308, + "beaming-panther-777": 1045315, + "jovial-greyhound-891": 1045316, + "giddy-quail-699": 1045322, + "notable-tortoise-80": 1045323, + "energetic-snail-253": 1045326, + "bright-parrot-429": 1045327, + "marvelous-buzzard-715": 1045349, + "laudable-crab-588": 1045350, + "fastidious-pika-749": 1045378, + "fabulous-wolf-641": 1045379, + "strong-armadillo-642": 1045381, + "quirky-leopard-162": 1045382, + "patient-bobcat-735": 1045383, + "prestigious-albatross-415": 1045384, + "prestigious-dodo-738": 1045384, + "gallant-ermine-270": 1045386, + "tacit-ox-835": 1045387, + "first-bandicoot-713": 1045389, + "glorious-malamute-110": 1045390, + "determined-crane-157": 1045395, + "doting-armadillo-619": 1045396, + "pastel-newt-274": 1045400, + "tangible-marten-132": 1045401, + "honorable-basilisk-191": 1045413, + "original-tapir-961": 1045414, + "usable-seahorse-1": 1045416, + "brilliant-mammoth-629": 1045417, + "chatty-boar-621": 1045419, + "shocking-elk-337": 1045420, + "brainy-orca-697": 1045420, + "zany-retriever-638": 1045423, + "honorable-turtle-989": 1045424, + "bold-kookabura-491": 1045433, + "valiant-schnauzer-646": 1045434, + "reliable-condor-92": 1045434, + "sensible-bat-206": 1045435, + "stoic-butterfly-195": 1045436, + "decisive-fly-314": 1045436, + "energized-flamingo-787": 1045441, + "fantastic-labrador-496": 1045442, + "energized-fennec-465": 1045442, + "coordinated-wildebeest-93": 1045447, + "brainy-roadrunner-405": 1045449, + "uncommon-woodpecker-99": 1045450, + "outstanding-tiger-549": 1045451, + "loyal-yak-982": 1045457, + "adorable-rhinoceros-144": 1045458, + "hardy-marlin-260": 1045459, + "grateful-swan-189": 1045460, + "jovial-cardinal-36": 1045460, + "colorless-gull-600": 1045464, + "deafening-horse-628": 1045465, + "majestic-spoonbill-479": 1045466, + "successful-squirrel-611": 1045467, + "healthy-bulldog-497": 1045470, + "scintillating-marlin-339": 1045471, + "ceaseless-mastiff-616": 1045472, + "abundant-penguin-488": 1045473, + "brave-monitor-911": 1045477, + "deafening-whale-758": 1045478, + "chatty-peccary-533": 1045478, + "veracious-boar-603": 1045492, + "necessary-woodpecker-672": 1045493, + "confident-hound-187": 1045501, + "determined-goat-138": 1045502, + "mellow-gnat-486": 1045507, + "brave-akita-712": 1045508, + "focused-llama-136": 1045515, + "wry-basilisk-960": 1045516, + "wooden-dolphin-585": 1045570, + "formal-peccary-314": 1045574, + "scintillating-caiman-19": 1045576, + "hushed-salmon-466": 1045577, + "famous-camel-277": 1045583, + "nautical-turtle-906": 1045584, + "sensible-gerbil-680": 1045590, + "wary-roadrunner-292": 1045591, + "small-deer-799": 1045598, + "zany-buzzard-664": 1045599, + "tidy-goat-983": 1045604, + "beaming-jaguar-486": 1045605, + "lovable-poodle-467": 1045619, + "upbeat-okapi-496": 1045621, + "giant-fennec-20": 1045632, + "wary-trout-989": 1045633, + "fine-cassowary-742": 1045636, + "gregarious-dragon-481": 1045638, + "exciting-warthog-788": 1045649, + "watchful-hawk-150": 1045650, + "fantastic-crow-710": 1045650, + "notable-frog-254": 1045666, + "robust-cobra-819": 1045667, + "kindred-swan-65": 1045669, + "notable-civet-863": 1045670, + "wandering-goshawk-244": 1045680, + "wooden-parakeet-573": 1045681, + "stoic-stoat-498": 1045681, + "benevolent-minnow-119": 1045682, + "earnest-whale-221": 1045683, + "lovely-meerkat-650": 1045696, + "kindly-newt-873": 1045697, + "merry-elephant-72": 1045702, + "wonderful-pheasant-968": 1045703, + "tame-bloodhound-19": 1045707, + "limitless-sturgeon-892": 1045709, + "lovely-grasshopper-924": 1045709, + "sensible-lemur-438": 1045717, + "festive-hare-583": 1045718, + "calculating-loris-287": 1045721, + "patient-pigeon-969": 1045722, + "expert-seahorse-713": 1045722, + "glorious-ptarmigan-902": 1045730, + "prestigious-chicken-849": 1045731, + "basic-lemur-200": 1045734, + "valiant-dogfish-227": 1045735, + "beaming-ox-501": 1045737, + "rugged-wren-670": 1045739, + "elegant-bee-525": 1045740, + "cool-malamute-706": 1045747, + "wry-anteater-235": 1045749, + "festive-roadrunner-261": 1045749, + "quirky-kiwi-609": 1045756, + "polished-falcon-793": 1045757, + "woozy-skunk-27": 1045757, + "hip-bird-146": 1045771, + "fearless-wildcat-711": 1045773, + "silent-ladybug-143": 1045775, + "glad-cat-639": 1045776, + "sincere-reindeer-343": 1045779, + "dependable-yak-538": 1045780, + "shocking-bear-564": 1045787, + "clear-terrier-406": 1045788, + "blissful-sockeye-785": 1045798, + "diligent-falcon-128": 1045799, + "bright-mink-525": 1045802, + "prestigious-chihuahua-659": 1045803, + "rightful-partridge-543": 1045803, + "useful-pelican-790": 1045815, + "joyous-crane-880": 1045817, + "efficient-chameleon-589": 1045818, + "enduring-dragon-870": 1045824, + "terrific-warthog-761": 1045825, + "avid-civet-811": 1045829, + "bright-viper-39": 1045830, + "perceptive-setter-336": 1045834, + "rare-puma-149": 1045835, + "canny-butterfly-846": 1045838, + "brainy-anteater-777": 1045839, + "insightful-sturgeon-680": 1045839, + "acoustic-ferret-738": 1045869, + "handsome-lobster-276": 1045870, + "adept-clam-260": 1045871, + "fine-partridge-602": 1045873, + "harmless-jaguar-689": 1045882, + "decisive-marlin-284": 1045883, + "polite-echidna-271": 1045887, + "aromatic-malamute-819": 1045889, + "clever-emu-609": 1045901, + "outstanding-labrador-287": 1045902, + "courteous-schnauzer-526": 1045904, + "useful-hedgehog-625": 1045907, + "wandering-crow-3": 1045908, + "shocking-finch-563": 1045909, + "wooden-echidna-110": 1045909, + "spotted-sockeye-152": 1045910, + "accurate-parakeet-861": 1045911, + "tangible-crane-281": 1045913, + "careful-spoonbill-999": 1045914, + "original-ptarmigan-189": 1045920, + "incredible-squirrel-558": 1045921, + "limitless-basilisk-431": 1045926, + "stoic-ox-942": 1045928, + "friendly-alligator-879": 1045929, + "knowing-squid-574": 1045930, + "ceaseless-chihuahua-414": 1045937, + "fortunate-badger-277": 1045938, + "zealous-spaniel-392": 1045939, + "frugal-warbler-939": 1045940, + "first-goose-232": 1045941, + "cheerful-guineapig-681": 1045942, + "clever-labrador-860": 1045943, + "youthful-beagle-200": 1045944, + "elegant-gazelle-756": 1045945, + "elated-warbler-489": 1045946, + "precious-mandrill-8": 1045947, + "bold-jay-888": 1045948, + "lovable-pigeon-831": 1045949, + "original-lynx-329": 1045950, + "accurate-salmon-946": 1045951, + "neat-aardvark-700": 1045952, + "honorable-manatee-589": 1045953, + "hearty-hamster-140": 1045954, + "glorious-mule-713": 1045955, + "modest-ermine-313": 1045956, + "lovable-wolf-340": 1045968, + "disciplined-swan-474": 1045969, + "laudable-possum-429": 1045979, + "nautical-pigeon-431": 1045980, + "different-porpoise-33": 1045982, + "incredible-rooster-91": 1045988, + "scrupulous-ram-525": 1045989, + "friendly-clam-896": 1045990, + "hip-mosquito-534": 1045991, + "energetic-chipmunk-905": 1045997, + "hardy-hippopotamus-93": 1045998, + "calm-dachshund-236": 1045999, + "dapper-kingfisher-174": 1046000, + "impressive-trout-109": 1046000, + "successful-setter-345": 1046004, + "brainy-caribou-586": 1046005, + "steady-wildebeest-454": 1046007, + "aromatic-turtle-848": 1046008, + "charming-skunk-363": 1046015, + "posh-dog-383": 1046016, + "ideal-leopard-35": 1046018, + "quixotic-jay-705": 1046019, + "lovely-crane-545": 1046023, + "formal-crow-425": 1046024, + "kindhearted-parrot-73": 1046044, + "amiable-warbler-920": 1046045, + "laudable-pig-491": 1046050, + "sensible-raccoon-156": 1046051, + "groovy-dove-356": 1046051, + "resolute-ocelot-962": 1046052, + "tame-goldfish-514": 1046053, + "rightful-ostrich-137": 1046090, + "whimsical-woodpecker-943": 1046091, + "fearless-bear-750": 1046104, + "zany-civet-876": 1046105, + "mellow-dotterel-686": 1046107, + "bold-porcupine-119": 1046108, + "lovely-chipmunk-384": 1046108, + "animated-mongoose-302": 1046145, + "savory-kookabura-279": 1046147, + "clean-mole-335": 1046147, + "quick-marten-200": 1046158, + "brazen-wildcat-112": 1046159, + "woozy-tortoise-451": 1046174, + "hearty-cat-931": 1046175, + "academic-otter-622": 1046195, + "uncommon-crane-593": 1046196, + "glorious-spoonbill-632": 1046197, + "acrobatic-raven-900": 1046198, + "diligent-greyhound-492": 1046219, + "brave-hawk-877": 1046220, + "dashing-ladybug-509": 1046221, + "aromatic-peacock-839": 1046222, + "decisive-lobster-713": 1046229, + "cheerful-avocet-324": 1046230, + "beloved-ox-232": 1046240, + "fine-boar-218": 1046241, + "brazen-gnu-705": 1046243, + "watchful-sockeye-628": 1046244, + "fleet-akita-948": 1046246, + "deafening-moose-644": 1046247, + "blessed-mongoose-911": 1046247, + "qualified-caribou-932": 1046249, + "adjoining-tortoise-595": 1046250, + "adorable-cardinal-693": 1046261, + "affable-egret-608": 1046262, + "handsome-ladybug-673": 1046267, + "dependable-swan-911": 1046268, + "perceptive-dachshund-654": 1046269, + "merry-seal-91": 1046275, + "lovely-hedgehog-470": 1046276, + "sensible-snake-288": 1046283, + "fastidious-malamute-41": 1046283, + "admired-kookabura-351": 1046297, + "kindhearted-buffalo-322": 1046298, + "friendly-herring-396": 1046298, + "reminiscent-raven-615": 1046301, + "youthful-bear-181": 1046302, + "dapper-curlew-699": 1046305, + "perfect-albatross-993": 1046306, + "cheery-terrier-95": 1046317, + "tremendous-raven-639": 1046318, + "zany-goldfinch-734": 1046319, + "outgoing-ant-224": 1046320, + "rare-quail-137": 1046321, + "posh-pig-350": 1046322, + "adorable-salamander-358": 1046323, + "valiant-porpoise-800": 1046324, + "capable-spaniel-451": 1046325, + "amicable-ibis-133": 1046326, + "usable-gnat-197": 1046327, + "beloved-orca-843": 1046328, + "determined-bass-989": 1046329, + "zealous-bat-689": 1046330, + "next-otter-814": 1046331, + "jovial-perch-773": 1046332, + "brilliant-leopard-446": 1046333, + "striped-yak-911": 1046334, + "industrious-cat-334": 1046336, + "accurate-yak-698": 1046337, + "confident-mole-752": 1046339, + "rapid-dove-512": 1046340, + "precise-monitor-463": 1046341, + "confident-pig-500": 1046344, + "woozy-wren-351": 1046347, + "zany-tortoise-706": 1046348, + "fiery-oriole-839": 1046367, + "quixotic-platypus-161": 1046368, + "fine-antelope-600": 1046397, + "colorful-canary-590": 1046398, + "first-quail-274": 1046399, + "fast-poodle-277": 1046400, + "stoic-coyote-438": 1046402, + "energized-gull-765": 1046403, + "peaceful-crab-455": 1046404, + "hallowed-quail-897": 1046405, + "steady-emu-375": 1046406, + "clean-bee-202": 1046407, + "cheerful-gull-714": 1046408, + "superb-rooster-104": 1046409, + "industrious-anaconda-523": 1046410, + "grand-eel-986": 1046411, + "nautical-possum-316": 1046412, + "colorful-gnu-835": 1046413, + "adept-jellyfish-284": 1046414, + "chatty-hummingbird-287": 1046415, + "shocking-sheep-846": 1046416, + "scrupulous-fox-647": 1046417, + "standing-fly-176": 1046418, + "watchful-pika-251": 1046420, + "insightful-snail-882": 1046421, + "agreeable-buzzard-27": 1046423, + "focused-hornet-620": 1046424, + "colorful-chipmunk-616": 1046428, + "knowing-dolphin-215": 1046429, + "expert-quail-947": 1046432, + "original-rook-271": 1046433, + "glorious-blackbird-521": 1046437, + "beaming-parakeet-365": 1046438, + "fantastic-marmot-88": 1046438, + "wonderful-donkey-915": 1046441, + "majestic-caiman-190": 1046442, + "jovial-partridge-78": 1046442, + "dusty-jay-320": 1046453, + "honorable-shark-239": 1046454, + "artful-parrot-686": 1046455, + "dapper-crab-681": 1046456, + "scrupulous-anteater-455": 1046458, + "warmhearted-rook-30": 1046459, + "acrobatic-panther-986": 1046460, + "precious-fennec-161": 1046461, + "festive-sturgeon-573": 1046462, + "usable-ferret-479": 1046463, + "charming-hare-39": 1046464, + "quixotic-salamander-926": 1046465, + "pleasant-giraffe-884": 1046466, + "decisive-parakeet-764": 1046467, + "optimistic-robin-976": 1046468, + "graceful-puffin-786": 1046469, + "judicious-porcupine-565": 1046470, + "coordinated-giraffe-43": 1046471, + "ardent-avocet-415": 1046472, + "academic-chipmunk-437": 1046473, + "useful-swan-264": 1046486, + "efficient-coyote-15": 1046487, + "prestigious-mockingbird-786": 1046500, + "quaint-meerkat-462": 1046501, + "careful-pony-96": 1046504, + "resolute-pika-48": 1046505, + "savory-snail-742": 1046507, + "grand-terrier-100": 1046508, + "superb-salmon-299": 1046543, + "curious-falcon-428": 1046544, + "rightful-ostrich-814": 1046546, + "upbeat-gopher-411": 1046547, + "elegant-wildcat-884": 1046547, + "exciting-owl-462": 1046556, + "beaming-mallard-623": 1046557, + "good-falcon-556": 1046559, + "industrious-sheep-687": 1046560, + "hushed-otter-938": 1046562, + "content-hamster-370": 1046563, + "groovy-partridge-269": 1046565, + "cautious-shepherd-200": 1046566, + "blissful-sardine-415": 1046575, + "insightful-buzzard-699": 1046576, + "optimistic-buffalo-662": 1046578, + "intent-crane-89": 1046579, + "standing-marmot-145": 1046581, + "deafening-buzzard-87": 1046583, + "successful-puma-990": 1046584, + "optimistic-cuttlefish-526": 1046585, + "jovial-dragon-607": 1046586, + "honorable-chickadee-801": 1046587, + "basic-pigeon-391": 1046588, + "colorless-goose-415": 1046589, + "energetic-grouse-799": 1046590, + "calm-echidna-885": 1046591, + "rugged-hornet-785": 1046592, + "trustworthy-koala-795": 1046593, + "tacit-octopus-235": 1046594, + "precious-pony-600": 1046595, + "lovable-anteater-687": 1046596, + "confident-armadillo-606": 1046597, + "brazen-fennec-948": 1046598, + "vibrant-scorpion-167": 1046599, + "ceaseless-orca-134": 1046600, + "wonderful-fly-652": 1046601, + "avid-ermine-534": 1046602, + "helpful-weasel-224": 1046603, + "festive-caiman-251": 1046608, + "limitless-clownfish-139": 1046609, + "fiery-scorpion-298": 1046613, + "hearty-iguana-198": 1046614, + "amicable-goshawk-425": 1046628, + "wooden-condor-462": 1046629, + "unique-cormorant-629": 1046636, + "energized-rabbit-211": 1046637, + "limitless-tapir-406": 1046637, + "animated-swordfish-151": 1046639, + "impressive-seahorse-984": 1046640, + "dutiful-mallard-319": 1046643, + "courteous-mammoth-331": 1046644, + "dependable-chipmunk-55": 1046646, + "acoustic-ram-966": 1046647, + "befitting-eel-480": 1046651, + "adorable-meadowlark-29": 1046652, + "focused-gopher-792": 1046658, + "cautious-chipmunk-87": 1046660, + "earnest-wolf-463": 1046666, + "successful-jellyfish-397": 1046667, + "famous-dolphin-437": 1046667, + "greedy-hornet-36": 1046668, + "posh-bird-532": 1046669, + "flexible-dove-889": 1046671, + "fabulous-cuttlefish-899": 1046672, + "striped-viper-470": 1046672, + "ideal-spoonbill-427": 1046673, + "astute-pheasant-264": 1046674, + "brilliant-lynx-532": 1046675, + "flexible-cod-260": 1046676, + "resilient-greyhound-644": 1046697, + "unique-pony-998": 1046698, + "adjoining-meadowlark-918": 1046705, + "standing-mammoth-146": 1046706, + "fearless-elephant-690": 1046715, + "tremendous-sardine-934": 1046716, + "useful-chicken-464": 1046725, + "aware-cricket-760": 1046726, + "combative-ant-480": 1046726, + "impartial-panther-562": 1046730, + "astute-canary-421": 1046731, + "quaint-terrier-568": 1046731, + "confident-oyster-685": 1046736, + "brilliant-walrus-626": 1046737, + "basic-jackal-12": 1046740, + "effervescent-chinchilla-964": 1046741, + "agreeable-swan-102": 1046745, + "polished-eagle-359": 1046746, + "resolute-roadrunner-214": 1046747, + "intent-corgi-672": 1046748, + "sincere-lynx-515": 1046764, + "moonlit-fish-284": 1046765, + "secret-swan-367": 1046777, + "quick-wombat-925": 1046778, + "greedy-mockingbird-12": 1046778, + "fast-caterpillar-298": 1046802, + "capable-tortoise-50": 1046803, + "sleek-stork-738": 1046813, + "energized-ladybug-959": 1046815, + "dazzling-ant-589": 1046817, + "standing-finch-474": 1046818, + "groovy-mink-723": 1046819, + "quaint-marlin-38": 1046820, + "trustworthy-eel-439": 1046824, + "keen-walrus-64": 1046825, + "original-kingfisher-609": 1046827, + "uncommon-reindeer-856": 1046851, + "energized-ant-652": 1046852, + "flippant-pelican-513": 1046852, + "limitless-pigeon-843": 1046858, + "hip-kudu-43": 1046860, + "beaming-bison-670": 1046870, + "brainy-chihuahua-412": 1046871, + "intent-wolverine-103": 1046873, + "glad-opossum-834": 1046874, + "reminiscent-seal-284": 1046883, + "academic-labrador-798": 1046885, + "resolute-antelope-373": 1046885, + "fabulous-magpie-689": 1046893, + "vivid-pelican-310": 1046893, + "acrobatic-starling-726": 1046894, + "fabulous-herring-952": 1046901, + "brainy-pheasant-652": 1046902, + "doting-firefly-35": 1046926, + "hardy-sturgeon-756": 1046927, + "steady-nightingale-84": 1046928, + "affable-manatee-323": 1046929, + "knowing-tapir-202": 1046930, + "agile-crane-140": 1046931, + "ceaseless-rat-764": 1046932, + "enduring-crane-233": 1046933, + "tangible-albatross-468": 1046934, + "agreeable-robin-646": 1046935, + "original-herring-62": 1046936, + "quiet-jellyfish-103": 1046937, + "notable-warbler-964": 1046938, + "kindhearted-bass-996": 1046939, + "helpful-herring-977": 1046940, + "colorless-narwhal-347": 1046941, + "polished-hawk-430": 1046942, + "content-cheetah-11": 1046943, + "pastel-ox-936": 1046944, + "flexible-greyhound-538": 1046945, + "impartial-sparrow-733": 1046946, + "aware-wolf-547": 1046952, + "prestigious-mastiff-737": 1046971, + "hallowed-shark-274": 1046972, + "accomplished-shrimp-648": 1046986, + "terrific-shepherd-175": 1046987, + "resolute-minnow-189": 1046987, + "adept-meerkat-34": 1046990, + "accurate-bat-900": 1046991, + "strong-viper-734": 1046992, + "adjoining-butterfly-198": 1046993, + "sensible-vulture-757": 1046994, + "amiable-butterfly-355": 1046995, + "scintillating-jellyfish-232": 1046996, + "blessed-puffin-427": 1046997, + "abundant-minnow-829": 1046998, + "cautious-hare-524": 1046999, + "decisive-ladybug-51": 1047000, + "watchful-tortoise-997": 1047001, + "brave-guineapig-976": 1047002, + "valuable-crane-852": 1047003, + "dusty-wren-794": 1047005, + "resilient-clownfish-923": 1047006, + "successful-dachshund-881": 1047007, + "hidden-cricket-772": 1047008, + "flexible-finch-296": 1047009, + "rare-eagle-70": 1047010, + "blessed-perch-609": 1047012, + "aromatic-camel-672": 1047013, + "trustworthy-hedgehog-478": 1047024, + "disciplined-wren-290": 1047025, + "coordinated-magpie-171": 1047033, + "utmost-anaconda-613": 1047034, + "necessary-sparrow-96": 1047038, + "kindred-cat-342": 1047039, + "helpful-quail-28": 1047041, + "laudable-bear-665": 1047042, + "incredible-viper-332": 1047048, + "dependable-puffin-774": 1047049, + "tangible-ox-168": 1047059, + "enchanted-horse-287": 1047060, + "agile-raccoon-986": 1047061, + "aware-manatee-531": 1047062, + "fortunate-spider-780": 1047065, + "giant-goldfinch-93": 1047066, + "clever-hummingbird-668": 1047069, + "cheerful-spider-965": 1047070, + "aware-oyster-867": 1047089, + "tame-roadrunner-750": 1047090, + "fiery-raccoon-215": 1047091, + "wooden-panther-173": 1047091, + "quiet-aardvark-644": 1047101, + "gallant-butterfly-167": 1047102, + "mellow-snake-835": 1047109, + "chatty-lapwing-585": 1047110, + "zealous-loris-734": 1047141, + "youthful-duck-389": 1047143, + "colorful-marlin-545": 1047157, + "insightful-trout-835": 1047158, + "beaming-anteater-840": 1047159, + "hardy-avocet-13": 1047160, + "nautical-puma-267": 1047160, + "blessed-elk-989": 1047177, + "proper-sturgeon-358": 1047178, + "tremendous-ocelot-143": 1047205, + "diligent-buzzard-627": 1047207, + "tangible-pelican-327": 1047212, + "gregarious-basilisk-937": 1047212, + "courteous-firefly-479": 1047213, + "compassionate-wildcat-89": 1047242, + "modest-hare-839": 1047243, + "colorful-ox-187": 1047245, + "accurate-cricket-573": 1047246, + "groovy-marten-70": 1047259, + "canny-malamute-178": 1047260, + "glorious-elk-940": 1047263, + "rugged-meadowlark-637": 1047264, + "brainy-lobster-661": 1047267, + "silent-dotterel-640": 1047268, + "sensible-lion-142": 1047272, + "beaming-buffalo-137": 1047273, + "mild-fox-959": 1047289, + "utmost-penguin-786": 1047291, + "third-ermine-946": 1047303, + "optimistic-otter-918": 1047305, + "frugal-gazelle-995": 1047305, + "giddy-terrier-863": 1047313, + "hardy-snail-878": 1047314, + "cautious-gopher-668": 1047314, + "fastidious-dogfish-355": 1047320, + "third-llama-130": 1047321, + "insightful-crane-665": 1047333, + "befitting-spider-213": 1047335, + "fearless-gnu-955": 1047338, + "accurate-trout-992": 1047341, + "fabulous-bullfrog-164": 1047348, + "rosy-hyena-896": 1047349, + "grand-starfish-818": 1047358, + "mild-manatee-437": 1047360, + "affable-tiger-718": 1047363, + "careful-jackal-89": 1047364, + "curious-gnu-631": 1047364, + "unique-bear-628": 1047368, + "resolute-grouse-815": 1047369, + "woozy-oyster-88": 1047381, + "festive-pig-388": 1047382, + "hearty-deer-770": 1047382, + "aware-loris-148": 1047407, + "tangible-llama-854": 1047408, + "valuable-cuttlefish-569": 1047411, + "moonlit-herring-248": 1047412, + "prestigious-cobra-221": 1047413, + "hearty-tortoise-561": 1047414, + "disciplined-greyhound-429": 1047416, + "uncommon-shepherd-787": 1047417, + "small-spoonbill-795": 1047418, + "hushed-mammoth-861": 1047419, + "rare-labrador-454": 1047420, + "rightful-tern-136": 1047421, + "upbeat-cat-238": 1047422, + "jovial-wildebeest-977": 1047423, + "marvelous-dalmatian-868": 1047424, + "sleek-albatross-363": 1047425, + "jovial-hyena-318": 1047426, + "stoic-warthog-607": 1047427, + "enchanted-hamster-271": 1047428, + "decisive-newt-646": 1047429, + "capable-cricket-672": 1047430, + "disciplined-grouse-363": 1047431, + "sleek-rooster-558": 1047442, + "patient-jackal-936": 1047443, + "calm-hawk-692": 1047444, + "hushed-goat-44": 1047445, + "compassionate-pelican-777": 1047450, + "benevolent-hyena-800": 1047451, + "avid-monitor-740": 1047459, + "incredible-cassowary-129": 1047460, + "elegant-starling-549": 1047472, + "utmost-fish-491": 1047473, + "exuberant-setter-799": 1047475, + "secret-jackal-778": 1047476, + "tremendous-spider-41": 1047476, + "reliable-dog-72": 1047484, + "resilient-lark-793": 1047486, + "adorable-bullfrog-665": 1047486, + "rapid-sockeye-362": 1047487, + "opulent-walrus-403": 1047489, + "graceful-starfish-173": 1047489, + "cautious-salamander-760": 1047496, + "standing-ox-72": 1047497, + "frugal-ostrich-607": 1047500, + "standing-emu-381": 1047501, + "fearless-squirrel-188": 1047525, + "agile-porcupine-589": 1047527, + "different-cobra-384": 1047528, + "spotted-alligator-35": 1047529, + "valiant-elephant-315": 1047530, + "decisive-wolverine-451": 1047531, + "helpful-bear-328": 1047532, + "sensible-peacock-843": 1047533, + "outstanding-goldfinch-178": 1047534, + "ideal-civet-694": 1047535, + "vibrant-cuttlefish-807": 1047536, + "ceaseless-impala-715": 1047537, + "terrific-dachshund-538": 1047538, + "diligent-clam-86": 1047539, + "joyous-blackbird-456": 1047540, + "helpful-marmot-248": 1047541, + "wary-bird-304": 1047542, + "peaceful-wolf-543": 1047543, + "different-wolverine-192": 1047544, + "fast-elk-722": 1047545, + "tame-meerkat-716": 1047547, + "wandering-bear-265": 1047548, + "grandiose-terrier-305": 1047555, + "pleasant-dalmatian-491": 1047556, + "earnest-reindeer-14": 1047562, + "warmhearted-gopher-503": 1047563, + "adjoining-gnat-141": 1047566, + "cheerful-whale-354": 1047567, + "wonderful-gull-812": 1047568, + "blissful-tapir-174": 1047569, + "rosy-sturgeon-431": 1047576, + "colorless-manatee-282": 1047577, + "affable-cormorant-893": 1047590, + "fiery-coyote-762": 1047591, + "affable-toucan-271": 1047597, + "strong-sparrow-456": 1047598, + "veracious-sturgeon-692": 1047604, + "ceaseless-reindeer-994": 1047606, + "fast-wolf-968": 1047609, + "energized-ram-680": 1047610, + "uncommon-tern-671": 1047610, + "earnest-marmot-150": 1047613, + "quixotic-dachshund-524": 1047614, + "abundant-kingfisher-654": 1047617, + "different-bee-661": 1047618, + "opulent-bullfrog-618": 1047622, + "charming-chameleon-488": 1047623, + "frugal-cuttlefish-428": 1047628, + "artful-alpaca-141": 1047629, + "perceptive-badger-548": 1047630, + "valuable-axolotl-445": 1047631, + "mellow-giraffe-931": 1047638, + "successful-chinchilla-493": 1047639, + "giddy-tortoise-912": 1047642, + "striped-ermine-765": 1047643, + "brazen-marlin-579": 1047646, + "zany-skunk-25": 1047647, + "agile-goshawk-739": 1047647, + "adjoining-cuttlefish-581": 1047648, + "ceaseless-terrier-746": 1047649, + "adjoining-moose-703": 1047656, + "intent-spoonbill-244": 1047657, + "trustworthy-porcupine-922": 1047659, + "hearty-herring-68": 1047660, + "laudable-wombat-390": 1047671, + "superb-aardvark-163": 1047672, + "fleet-newt-521": 1047678, + "jovial-rabbit-363": 1047679, + "perceptive-gull-457": 1047690, + "fabulous-dog-49": 1047691, + "first-retriever-581": 1047694, + "deafening-axolotl-114": 1047695, + "posh-emu-766": 1047702, + "handsome-perch-899": 1047703, + "dependable-weasel-547": 1047704, + "keen-rhinoceros-958": 1047705, + "grand-alligator-11": 1047710, + "lovable-caterpillar-68": 1047711, + "unique-okapi-311": 1047711, + "moonlit-ptarmigan-969": 1047712, + "calculating-rhinoceros-147": 1047713, + "knowing-nightingale-119": 1047715, + "tough-giraffe-107": 1047716, + "brazen-gull-453": 1047719, + "prestigious-reindeer-469": 1047720, + "compassionate-seal-170": 1047721, + "precious-echidna-226": 1047722, + "amicable-kudu-730": 1047724, + "wonderful-pig-605": 1047725, + "small-kookabura-999": 1047727, + "energized-pony-153": 1047728, + "colorful-eagle-704": 1047735, + "hardy-ibex-383": 1047736, + "canny-possum-695": 1047738, + "adept-egret-168": 1047739, + "hearty-meadowlark-0": 1047745, + "strong-anaconda-708": 1047746, + "decisive-crane-255": 1047747, + "precious-possum-72": 1047751, + "honorable-impala-702": 1047752, + "secret-boar-184": 1047768, + "agile-pheasant-491": 1047769, + "oceanic-orca-981": 1047771, + "mild-hippopotamus-173": 1047772, + "hushed-ox-587": 1047772, + "rosy-spider-145": 1047781, + "animated-hound-321": 1047783, + "cautious-caribou-41": 1047786, + "fleet-canary-919": 1047787, + "ideal-squid-799": 1047789, + "whimsical-kiwi-138": 1047790, + "impartial-chameleon-621": 1047793, + "wary-seal-677": 1047794, + "dusty-eel-603": 1047795, + "healthy-mastiff-824": 1047796, + "coordinated-goose-976": 1047797, + "flexible-wolverine-855": 1047798, + "grand-anteater-209": 1047799, + "accurate-sparrow-419": 1047800, + "kindred-greyhound-85": 1047801, + "colorful-goshawk-454": 1047802, + "savory-herring-489": 1047803, + "small-badger-764": 1047804, + "tough-shepherd-791": 1047805, + "enchanted-chickadee-876": 1047806, + "brave-porcupine-611": 1047807, + "handsome-panther-804": 1047808, + "agreeable-badger-41": 1047809, + "outstanding-barracuda-233": 1047810, + "wooden-toad-576": 1047811, + "upbeat-ibis-184": 1047812, + "optimistic-elephant-435": 1047832, + "energetic-elephant-0": 1047834, + "ideal-monitor-459": 1047841, + "amicable-shrimp-709": 1047842, + "enduring-goshawk-219": 1047843, + "artful-iguana-846": 1047845, + "honorable-dove-826": 1047846, + "valiant-eel-891": 1047847, + "steady-chipmunk-596": 1047848, + "doting-sardine-533": 1047849, + "savory-mastiff-19": 1047850, + "exuberant-salamander-513": 1047851, + "hearty-marlin-808": 1047852, + "elegant-rabbit-250": 1047853, + "terrific-ibex-66": 1047854, + "brave-possum-67": 1047855, + "academic-cat-251": 1047856, + "glad-oyster-549": 1047857, + "insightful-goldfish-9": 1047858, + "jovial-meerkat-634": 1047859, + "first-bass-572": 1047860, + "notable-possum-732": 1047861, + "diligent-bat-732": 1047864, + "accurate-peccary-722": 1047865, + "acrobatic-hound-815": 1047869, + "quirky-greyhound-704": 1047870, + "stoic-avocet-641": 1047889, + "shiny-eel-474": 1047890, + "brainy-lyrebird-23": 1047893, + "mild-retriever-119": 1047894, + "wandering-crab-300": 1047922, + "usable-starling-385": 1047923, + "shocking-beagle-231": 1047935, + "wonderful-stoat-758": 1047936, + "unique-goldfish-15": 1047938, + "youthful-clownfish-570": 1047939, + "handsome-koala-76": 1047939, + "uncommon-salmon-562": 1047945, + "fabulous-butterfly-34": 1047946, + "scrupulous-wildcat-55": 1047946, + "blissful-guanaco-894": 1047947, + "zany-emu-162": 1047948, + "upbeat-hippopotamus-565": 1047954, + "small-buffalo-785": 1047955, + "fortunate-basilisk-182": 1047960, + "greedy-scorpion-573": 1047961, + "perfect-platypus-97": 1047965, + "lovely-dinosaur-201": 1047967, + "brilliant-swan-365": 1047977, + "adorable-pigeon-919": 1047978, + "colorful-chihuahua-820": 1047981, + "perceptive-boar-808": 1047982, + "quaint-roadrunner-929": 1047982, + "incredible-marten-351": 1047984, + "coordinated-viper-376": 1047985, + "brazen-guanaco-175": 1047988, + "aware-beagle-24": 1047989, + "keen-stork-263": 1047992, + "merry-skunk-75": 1047993, + "merry-firefly-838": 1047998, + "trustworthy-panda-573": 1047999, + "lovable-bass-308": 1048005, + "zealous-walrus-937": 1048006, + "efficient-cormorant-460": 1048009, + "adjoining-canary-630": 1048010, + "pleasant-retriever-51": 1048020, + "insightful-guanaco-80": 1048021, + "quixotic-bobcat-876": 1048022, + "cautious-viper-800": 1048023, + "resilient-husky-798": 1048024, + "effervescent-egret-514": 1048025, + "famous-barracuda-649": 1048026, + "lovable-cormorant-161": 1048027, + "optimistic-swan-67": 1048029, + "rosy-spoonbill-643": 1048030, + "dynamic-starfish-194": 1048031, + "rapid-turtle-348": 1048032, + "zany-opossum-214": 1048034, + "graceful-hornet-982": 1048035, + "incredible-setter-525": 1048036, + "resolute-mockingbird-303": 1048037, + "zealous-sparrow-320": 1048038, + "oceanic-chinchilla-820": 1048039, + "impressive-ram-79": 1048040, + "focused-goose-674": 1048041, + "bold-mole-67": 1048090, + "posh-marlin-586": 1048091, + "patient-terrier-991": 1048094, + "dusty-woodpecker-530": 1048096, + "first-puma-238": 1048096, + "notable-bat-580": 1048102, + "accomplished-basilisk-907": 1048103, + "perfect-labrador-645": 1048104, + "admired-gnu-155": 1048105, + "wry-greyhound-359": 1048113, + "opulent-meerkat-276": 1048114, + "handsome-falcon-966": 1048116, + "moonlit-parrot-751": 1048118, + "aromatic-cheetah-899": 1048121, + "gregarious-guineapig-684": 1048122, + "focused-otter-134": 1048122, + "clear-gull-724": 1048127, + "capable-wildebeest-770": 1048129, + "gallant-fly-112": 1048130, + "brainy-finch-576": 1048131, + "impressive-ibis-458": 1048144, + "quick-bullfrog-159": 1048145, + "wandering-impala-2": 1048146, + "amiable-cricket-714": 1048148, + "perceptive-swordfish-511": 1048156, + "expert-caterpillar-227": 1048158, + "third-spider-59": 1048161, + "flexible-squid-751": 1048162, + "stoic-llama-176": 1048163, + "scrupulous-wren-351": 1048164, + "canny-possum-686": 1048169, + "third-oyster-957": 1048170, + "dashing-bandicoot-788": 1048186, + "merry-blackbird-263": 1048187, + "perceptive-antelope-320": 1048192, + "famous-spaniel-554": 1048193, + "fast-mallard-183": 1048208, + "necessary-pigeon-450": 1048209, + "original-swordfish-316": 1048216, + "limitless-zebra-762": 1048217, + "scrupulous-wildebeest-604": 1048220, + "curious-toad-675": 1048221, + "kindhearted-walrus-29": 1048229, + "affable-dolphin-245": 1048230, + "adept-trout-864": 1048232, + "youthful-cat-308": 1048233, + "clean-rook-224": 1048324, + "fastidious-hyena-900": 1048325, + "keen-hornet-646": 1048326, + "good-nightingale-503": 1048327, + "elated-warbler-441": 1048328, + "beaming-pheasant-727": 1048329, + "accomplished-wren-947": 1048330, + "mild-scorpion-972": 1048331, + "wary-stingray-294": 1048332, + "artful-dragon-700": 1048333, + "majestic-firefly-486": 1048334, + "necessary-gnat-300": 1048335, + "grandiose-mosquito-502": 1048336, + "lovable-mink-348": 1048337, + "coordinated-avocet-751": 1048338, + "abundant-robin-170": 1048339, + "peaceful-ant-424": 1048340, + "neat-shark-542": 1048341, + "glorious-pig-589": 1048342, + "energetic-cardinal-321": 1048343, + "acrobatic-parakeet-808": 1048358, + "compassionate-crane-757": 1048360, + "fortunate-ocelot-678": 1048363, + "dapper-pelican-75": 1048364, + "superb-gecko-184": 1048365, + "fine-hummingbird-829": 1048366, + "careful-goldfinch-743": 1048372, + "hip-malamute-239": 1048373, + "wandering-scorpion-455": 1048378, + "expert-opossum-464": 1048379, + "mild-badger-61": 1048392, + "precise-clownfish-186": 1048393, + "calculating-bat-678": 1048393, + "amicable-schnauzer-763": 1048405, + "dapper-mule-625": 1048406, + "avid-dolphin-622": 1048425, + "tangible-chinchilla-747": 1048426, + "polished-chihuahua-229": 1048446, + "brazen-corgi-853": 1048447, + "diligent-wombat-492": 1048454, + "dusty-zebra-607": 1048455, + "tame-falcon-124": 1048487, + "cheerful-mastiff-317": 1048488, + "agreeable-bison-57": 1048488, + "dazzling-labrador-535": 1048496, + "festive-chicken-444": 1048497, + "tacit-newt-412": 1048500, + "reminiscent-dolphin-163": 1048501, + "fearless-hornet-615": 1048505, + "neat-cricket-306": 1048506, + "trustworthy-gerbil-537": 1048533, + "tremendous-scorpion-185": 1048534, + "dutiful-bear-474": 1048534, + "helpful-buzzard-134": 1048542, + "canny-gnu-156": 1048543, + "optimistic-partridge-771": 1048544, + "rugged-tortoise-739": 1048562, + "intent-pig-140": 1048563, + "stoic-antelope-366": 1048570, + "efficient-ferret-163": 1048571, + "courteous-elephant-964": 1048576, + "vibrant-hippopotamus-83": 1048577, + "curious-crane-370": 1048592, + "necessary-fish-79": 1048593, + "cheerful-sheep-749": 1048594, + "outstanding-puma-698": 1048609, + "cheerful-salamander-27": 1048611, + "dependable-antelope-301": 1048612, + "precious-ladybug-538": 1048613, + "careful-ladybug-663": 1048619, + "pleasant-hummingbird-370": 1048620, + "enchanted-rhinoceros-354": 1048620, + "laudable-porpoise-335": 1048622, + "doting-avocet-640": 1048623, + "frugal-newt-123": 1048636, + "polished-ant-259": 1048637, + "clever-oriole-772": 1048674, + "cheerful-skunk-467": 1048675, + "polished-meerkat-103": 1048677, + "confident-goldfish-374": 1048678, + "vibrant-herring-846": 1048680, + "moonlit-akita-790": 1048681, + "festive-perch-70": 1048709, + "brainy-wren-459": 1048709, + "trustworthy-porpoise-961": 1048710, + "admired-trout-464": 1048715, + "watchful-lyrebird-775": 1048716, + "shiny-sparrow-247": 1048720, + "incredible-swan-763": 1048721, + "vibrant-stoat-75": 1048722, + "jovial-greyhound-220": 1048732, + "fiery-spoonbill-173": 1048733, + "resolute-viper-247": 1048737, + "knowing-elephant-881": 1048738, + "marvelous-vulture-619": 1048791, + "cheerful-ladybug-717": 1048804, + "hearty-meerkat-675": 1048805, + "clean-dachshund-168": 1048807, + "graceful-crab-581": 1048807, + "perceptive-crow-390": 1048808, + "graceful-pigeon-603": 1048814, + "original-horse-221": 1048815, + "dashing-oyster-509": 1048851, + "zany-nightingale-109": 1048852, + "hushed-warbler-314": 1048856, + "superb-robin-352": 1048858, + "beaming-cat-486": 1048862, + "basic-tiger-951": 1048884, + "valuable-goldfinch-114": 1048885, + "famous-dodo-953": 1048889, + "exuberant-grasshopper-191": 1048890, + "vivid-anteater-246": 1048890, + "quirky-ant-538": 1048892, + "opulent-emu-576": 1048893, + "expert-jellyfish-648": 1048893, + "artful-anaconda-318": 1048926, + "grandiose-salmon-881": 1048927, + "coordinated-cow-566": 1048955, + "artful-octopus-588": 1048956, + "curious-sturgeon-113": 1048963, + "lovely-manatee-802": 1048964, + "usable-hummingbird-765": 1048977, + "quaint-porpoise-217": 1048978, + "giant-peacock-996": 1048978, + "friendly-gnat-883": 1048987, + "tangible-tapir-66": 1048988, + "perceptive-cheetah-16": 1048990, + "lovely-mallard-858": 1048991, + "modest-jackal-956": 1048996, + "clever-albatross-182": 1048998, + "ideal-dotterel-508": 1049014, + "gallant-leopard-26": 1049015, + "rightful-hamster-819": 1049030, + "tough-goose-981": 1049032, + "acrobatic-impala-656": 1049032, + "qualified-porcupine-102": 1049037, + "veracious-boar-741": 1049038, + "mild-toucan-972": 1049055, + "enduring-goat-377": 1049056, + "precise-lapwing-43": 1049058, + "fearless-koala-733": 1049059, + "exciting-salamander-541": 1049072, + "neighborly-caribou-294": 1049073, + "rightful-magpie-333": 1049080, + "hushed-bandicoot-461": 1049081, + "healthy-pelican-8": 1049084, + "quixotic-aardvark-773": 1049085, + "flexible-dog-527": 1049089, + "brainy-raven-82": 1049091, + "clever-cat-991": 1049102, + "necessary-dragon-940": 1049103, + "shiny-goshawk-215": 1049111, + "industrious-seal-928": 1049113, + "gregarious-pig-659": 1049130, + "valiant-goshawk-662": 1049131, + "incredible-antelope-279": 1049132, + "deafening-skunk-723": 1049133, + "notable-peacock-38": 1049153, + "posh-seahorse-910": 1049154, + "glad-mosquito-853": 1049155, + "gallant-hamster-105": 1049156, + "acoustic-caribou-69": 1049159, + "dependable-chickadee-936": 1049160, + "glorious-falcon-250": 1049180, + "artful-dragon-955": 1049181, + "adjoining-bobcat-977": 1049182, + "glad-cricket-158": 1049183, + "famous-labrador-264": 1049207, + "precise-panda-602": 1049208, + "perceptive-tapir-717": 1049213, + "tremendous-seahorse-628": 1049214, + "moonlit-flamingo-747": 1049219, + "bright-armadillo-607": 1049220, + "adorable-oriole-291": 1049221, + "nautical-hare-221": 1049222, + "content-sheep-551": 1049225, + "superb-cuttlefish-897": 1049226, + "whimsical-crow-326": 1049231, + "amicable-viper-129": 1049232, + "valuable-lemur-557": 1049234, + "doting-seal-360": 1049235, + "wry-dachshund-977": 1049237, + "fearless-gull-701": 1049238, + "healthy-canary-130": 1049238, + "brave-otter-476": 1049251, + "tame-duck-426": 1049252, + "trustworthy-starfish-767": 1049253, + "outgoing-egret-337": 1049256, + "energized-tiger-751": 1049261, + "gregarious-seal-872": 1049262, + "diligent-husky-831": 1049274, + "warmhearted-boar-286": 1049275, + "standing-cuttlefish-970": 1049276, + "rightful-rhinoceros-881": 1049277, + "ceaseless-squid-619": 1049278, + "benevolent-bass-422": 1049279, + "enduring-leopard-223": 1049297, + "whimsical-puma-600": 1049298, + "dazzling-aardvark-170": 1049300, + "astute-cobra-476": 1049308, + "energetic-jaguar-214": 1049309, + "vivid-swordfish-586": 1049310, + "wandering-opossum-271": 1049311, + "accomplished-goldfinch-632": 1049312, + "compassionate-duck-216": 1049313, + "reminiscent-hare-913": 1049316, + "pastel-roadrunner-73": 1049322, + "clever-monitor-733": 1049323, + "steady-albatross-114": 1049325, + "astute-leopard-959": 1049326, + "dynamic-poodle-89": 1049328, + "blessed-giraffe-480": 1049329, + "grandiose-salamander-455": 1049330, + "wooden-terrier-56": 1049333, + "brave-cricket-994": 1049334, + "merry-horse-684": 1049335, + "tame-camel-344": 1049337, + "useful-clownfish-947": 1049338, + "zany-cuttlefish-237": 1049338, + "academic-hawk-406": 1049339, + "focused-mockingbird-478": 1049376, + "marvelous-ibex-789": 1049393, + "elegant-beagle-992": 1049394, + "standing-snail-222": 1049468, + "descriptive-sturgeon-799": 1049469, + "precise-puffin-850": 1049479, + "doting-sardine-241": 1049480, + "dapper-badger-76": 1049480, + "reliable-avocet-880": 1049484, + "rugged-hyena-683": 1049485, + "robust-boar-677": 1049486, + "limitless-mouse-775": 1049487, + "fast-wolverine-708": 1049489, + "honorable-spaniel-931": 1049490, + "rosy-canary-683": 1049499, + "befitting-parrot-463": 1049501, + "adorable-dachshund-775": 1049510, + "standing-llama-28": 1049511, + "accomplished-tiger-613": 1049513, + "stoic-curlew-990": 1049514, + "wary-rat-300": 1049517, + "quiet-clam-750": 1049518, + "knowing-clownfish-101": 1049521, + "steady-marlin-70": 1049522, + "hidden-falcon-551": 1049524, + "patient-bass-792": 1049525, + "lovable-fly-329": 1049529, + "insightful-sturgeon-512": 1049530, + "wary-rhinoceros-427": 1049533, + "knowing-goldfinch-932": 1049534, + "warmhearted-partridge-304": 1049535, + "useful-mallard-587": 1049537, + "vibrant-armadillo-565": 1049540, + "fastidious-cricket-587": 1049541, + "fleet-monitor-446": 1049541, + "terrific-falcon-590": 1049546, + "original-clam-269": 1049547, + "strong-jay-853": 1049548, + "wandering-manatee-397": 1049549, + "deafening-mink-365": 1049551, + "friendly-fennec-791": 1049552, + "kindred-caribou-74": 1049562, + "focused-raven-33": 1049563, + "chatty-panda-626": 1049564, + "adjoining-capybara-995": 1049565, + "adorable-ostrich-784": 1049567, + "glorious-dalmatian-540": 1049569, + "tremendous-shrimp-629": 1049576, + "little-spider-695": 1049577, + "confident-trout-997": 1049580, + "fine-newt-889": 1049581, + "secret-kudu-349": 1049584, + "patient-lion-615": 1049585, + "aromatic-ant-566": 1049587, + "descriptive-dogfish-798": 1049588, + "diligent-guanaco-666": 1049593, + "tame-antelope-251": 1049594, + "third-opossum-476": 1049595, + "descriptive-mandrill-280": 1049603, + "energetic-cow-958": 1049604, + "bright-eagle-834": 1049605, + "wary-gnat-170": 1049606, + "brave-bullfrog-456": 1049616, + "keen-skunk-310": 1049617, + "bold-bat-308": 1049619, + "keen-guanaco-542": 1049620, + "exciting-bass-210": 1049620, + "necessary-alpaca-966": 1049625, + "clear-toad-912": 1049626, + "scintillating-hornet-570": 1049629, + "notable-oriole-776": 1049630, + "wary-buffalo-97": 1049632, + "lovely-horse-272": 1049639, + "dependable-condor-168": 1049640, + "spotted-flamingo-851": 1049642, + "zany-toad-43": 1049643, + "resolute-malamute-603": 1049644, + "dazzling-platypus-379": 1049645, + "mild-ox-637": 1049654, + "animated-lynx-922": 1049656, + "wry-heron-946": 1049658, + "keen-clownfish-206": 1049663, + "content-wren-936": 1049664, + "rosy-aardvark-683": 1049671, + "gallant-bear-50": 1049673, + "hip-lynx-110": 1049674, + "glorious-dog-517": 1049676, + "agreeable-gecko-705": 1049677, + "handsome-emu-684": 1049678, + "decisive-trout-762": 1049679, + "superb-crane-224": 1049680, + "handsome-raccoon-267": 1049682, + "chatty-hamster-442": 1049691, + "admired-sparrow-659": 1049695, + "dependable-heron-660": 1049696, + "colorful-goshawk-863": 1049696, + "charming-cat-878": 1049702, + "grandiose-hare-269": 1049703, + "cheery-oyster-478": 1049710, + "valuable-echidna-879": 1049711, + "qualified-duck-917": 1049714, + "blessed-jackal-419": 1049715, + "precious-mouse-494": 1049716, + "healthy-oyster-627": 1049717, + "agile-bobcat-574": 1049718, + "watchful-dolphin-547": 1049719, + "trustworthy-hummingbird-35": 1049722, + "courteous-lark-37": 1049723, + "blissful-salmon-503": 1049725, + "courteous-loris-807": 1049726, + "harmless-perch-576": 1049727, + "opulent-shepherd-352": 1049728, + "gallant-egret-870": 1049728, + "steady-trout-332": 1049729, + "clean-dove-304": 1049730, + "standing-barracuda-652": 1049734, + "compassionate-chicken-939": 1049735, + "flexible-greyhound-346": 1049736, + "energized-crane-849": 1049737, + "accomplished-giraffe-923": 1049740, + "mellow-bee-87": 1049741, + "quick-eagle-532": 1049747, + "robust-hound-485": 1049753, + "flippant-cuttlefish-779": 1049754, + "incredible-albatross-164": 1049768, + "adorable-starling-66": 1049769, + "resolute-dachshund-3": 1049773, + "reliable-cat-521": 1049776, + "wry-bandicoot-273": 1049782, + "blessed-bobcat-993": 1049782, + "beaming-cheetah-444": 1049795, + "effervescent-tiger-776": 1049796, + "fantastic-rook-906": 1049809, + "dazzling-partridge-57": 1049810, + "whimsical-mole-729": 1049813, + "courteous-scorpion-451": 1049814, + "limitless-leopard-545": 1049816, + "acoustic-crocodile-198": 1049817, + "oceanic-flamingo-930": 1049824, + "wooden-minnow-851": 1049825, + "judicious-partridge-158": 1049825, + "vibrant-possum-717": 1049829, + "efficient-ox-93": 1049830, + "gregarious-heron-79": 1049833, + "utmost-mammoth-676": 1049834, + "outgoing-caterpillar-749": 1049839, + "usable-kingfisher-339": 1049840, + "optimistic-chicken-677": 1049849, + "careful-finch-880": 1049850, + "outgoing-raven-284": 1049852, + "quirky-dolphin-497": 1049853, + "hushed-firefly-807": 1049857, + "adjoining-platypus-334": 1049858, + "cool-lyrebird-126": 1049883, + "modest-lion-406": 1049884, + "animated-dodo-521": 1049904, + "modest-lemur-291": 1049905, + "quick-wildebeest-297": 1049911, + "handsome-herring-621": 1049912, + "exuberant-hippopotamus-199": 1049916, + "stoic-mammoth-934": 1049917, + "perceptive-gopher-572": 1049924, + "scrupulous-cheetah-310": 1049925, + "dazzling-retriever-978": 1049937, + "quixotic-salamander-960": 1049938, + "artful-butterfly-436": 1049939, + "adept-bison-761": 1049940, + "warmhearted-mongoose-166": 1049943, + "ideal-lobster-68": 1049944, + "notable-orca-10": 1049952, + "chatty-partridge-450": 1049954, + "prestigious-oyster-743": 1049954, + "adventurous-blackbird-142": 1049957, + "steady-chipmunk-987": 1049958, + "optimistic-alpaca-353": 1049958, + "exciting-sturgeon-307": 1049966, + "upbeat-minnow-287": 1049967, + "polite-rook-19": 1049971, + "outstanding-caribou-527": 1049972, + "terrific-sturgeon-230": 1049977, + "opulent-mole-208": 1049978, + "focused-reindeer-636": 1049986, + "quixotic-warbler-862": 1049987, + "third-lapwing-345": 1050003, + "content-llama-432": 1050004, + "spotted-echidna-483": 1050011, + "handsome-retriever-908": 1050012, + "acrobatic-spaniel-442": 1050036, + "acoustic-raven-963": 1050043, + "grateful-retriever-518": 1050044, + "energized-hornet-242": 1050048, + "rapid-ocelot-6": 1050049, + "optimistic-butterfly-109": 1050049, + "fantastic-peccary-284": 1050053, + "rugged-swordfish-872": 1050055, + "dutiful-armadillo-897": 1050055, + "fine-ostrich-286": 1050058, + "famous-caterpillar-329": 1050059, + "standing-anteater-661": 1050059, + "steady-dolphin-383": 1050060, + "accomplished-stork-535": 1050061, + "moonlit-wombat-221": 1050069, + "industrious-warthog-350": 1050070, + "enduring-kangaroo-13": 1050071, + "outgoing-retriever-826": 1050072, + "descriptive-orca-402": 1050076, + "unique-hound-599": 1050077, + "mild-minnow-88": 1050080, + "wry-husky-814": 1050089, + "modest-sandpiper-971": 1050090, + "effervescent-cassowary-183": 1050098, + "youthful-oyster-334": 1050099, + "gallant-pony-562": 1050099, + "moonlit-dinosaur-883": 1050100, + "adamant-terrier-872": 1050101, + "groovy-bison-617": 1050105, + "accurate-ocelot-138": 1050106, + "upbeat-jackal-871": 1050112, + "watchful-lynx-130": 1050113, + "healthy-fennec-857": 1050113, + "different-husky-108": 1050123, + "successful-parrot-180": 1050124, + "expert-pony-677": 1050134, + "industrious-marten-175": 1050135, + "cheery-ptarmigan-704": 1050148, + "neat-fly-2": 1050149, + "fabulous-frog-658": 1050152, + "amiable-cow-99": 1050153, + "quiet-cat-2": 1050158, + "outstanding-swan-390": 1050159, + "affable-egret-260": 1050160, + "wry-duck-800": 1050161, + "scintillating-hare-72": 1050161, + "grateful-whale-688": 1050168, + "hallowed-hyena-706": 1050169, + "expert-possum-387": 1050169, + "tangible-badger-410": 1050175, + "neat-barracuda-56": 1050176, + "strong-ptarmigan-113": 1050176, + "brazen-fox-249": 1050179, + "original-ferret-425": 1050180, + "hushed-sockeye-839": 1050180, + "expert-sturgeon-63": 1050195, + "clear-retriever-938": 1050198, + "good-robin-464": 1050203, + "dynamic-wolf-219": 1050204, + "avid-lark-610": 1050208, + "dapper-cod-20": 1050209, + "giddy-capybara-959": 1050215, + "keen-tortoise-938": 1050216, + "doting-ermine-711": 1050236, + "abundant-pelican-959": 1050237, + "chatty-aardvark-177": 1050239, + "animated-emu-506": 1050240, + "optimistic-albatross-650": 1050253, + "joyous-salamander-662": 1050254, + "outstanding-sparrow-671": 1050255, + "enduring-husky-851": 1050256, + "combative-hornet-183": 1050269, + "impressive-vole-760": 1050270, + "dependable-toad-475": 1050272, + "glorious-whale-532": 1050273, + "modest-loris-869": 1050275, + "watchful-narwhal-796": 1050277, + "veracious-dragon-673": 1050280, + "earnest-hippopotamus-579": 1050281, + "giant-ermine-404": 1050292, + "exuberant-lapwing-666": 1050293, + "vibrant-whale-112": 1050295, + "proper-akita-164": 1050297, + "prestigious-ladybug-410": 1050302, + "merry-dove-151": 1050303, + "brazen-bass-604": 1050309, + "judicious-bison-674": 1050310, + "blessed-mastiff-481": 1050317, + "benevolent-puma-433": 1050318, + "hearty-dogfish-231": 1050373, + "descriptive-grasshopper-723": 1050374, + "sensible-sparrow-22": 1050410, + "tacit-vulture-903": 1050411, + "capable-mallard-752": 1050411, + "basic-anaconda-118": 1050421, + "silent-blackbird-980": 1050422, + "pleasant-falcon-855": 1050457, + "oceanic-okapi-218": 1050458, + "hallowed-perch-493": 1050458, + "accomplished-toucan-912": 1050464, + "fearless-mastiff-715": 1050465, + "fiery-goldfish-705": 1050476, + "fortunate-wolverine-576": 1050477, + "fast-tern-822": 1050477, + "animated-cat-875": 1050479, + "grandiose-seahorse-615": 1050480, + "trustworthy-eagle-24": 1050503, + "colorful-hare-258": 1050504, + "efficient-horse-915": 1050510, + "cheerful-guineapig-107": 1050511, + "giddy-vulture-812": 1050513, + "first-bulldog-866": 1050514, + "brilliant-alligator-132": 1050514, + "grateful-starling-325": 1050517, + "ardent-sturgeon-874": 1050518, + "small-ermine-189": 1050525, + "fast-dotterel-663": 1050526, + "pleasant-grasshopper-673": 1050527, + "hearty-magpie-179": 1050528, + "dusty-cheetah-897": 1050530, + "valiant-tern-278": 1050532, + "trustworthy-stork-856": 1050545, + "fearless-bear-877": 1050546, + "wandering-hornet-739": 1050549, + "pleasant-husky-156": 1050551, + "harmless-gnu-595": 1050554, + "stoic-hawk-579": 1050555, + "accurate-shark-632": 1050558, + "fiery-gnat-417": 1050560, + "rightful-rat-720": 1050570, + "youthful-starfish-811": 1050571, + "modest-wombat-825": 1050580, + "cheerful-mongoose-839": 1050581, + "good-jaguar-132": 1050593, + "next-falcon-537": 1050594, + "honorable-bison-673": 1050594, + "wry-oriole-307": 1050602, + "beloved-bison-365": 1050603, + "combative-beagle-141": 1050603, + "content-ocelot-768": 1050611, + "dashing-echidna-591": 1050612, + "exciting-gerbil-3": 1050612, + "tame-vulture-606": 1050643, + "woozy-hawk-432": 1050644, + "kindhearted-jay-832": 1050646, + "clear-tortoise-768": 1050647, + "optimistic-crane-142": 1050648, + "incredible-ocelot-946": 1050649, + "good-ostrich-30": 1050650, + "precious-wildcat-145": 1050651, + "brilliant-canary-385": 1050652, + "decisive-stork-420": 1050653, + "bright-fennec-683": 1050654, + "quick-whale-2": 1050655, + "laudable-gerbil-944": 1050656, + "graceful-emu-684": 1050657, + "amicable-caribou-884": 1050658, + "capable-toad-617": 1050658, + "standing-bobcat-614": 1050659, + "vivid-lark-724": 1050660, + "honorable-reindeer-855": 1050661, + "dependable-guanaco-232": 1050662, + "aware-porcupine-831": 1050663, + "giddy-toad-474": 1050663, + "handsome-alligator-211": 1050664, + "beaming-mosquito-529": 1050665, + "reminiscent-fennec-960": 1050681, + "upbeat-curlew-757": 1050682, + "prestigious-curlew-127": 1050692, + "colorful-cassowary-615": 1050693, + "whimsical-pelican-554": 1050693, + "robust-giraffe-30": 1050695, + "standing-panda-606": 1050697, + "enduring-weasel-221": 1050697, + "reminiscent-pika-255": 1050703, + "tacit-echidna-600": 1050704, + "enchanted-clownfish-793": 1050704, + "beaming-starling-151": 1050710, + "veracious-trout-351": 1050711, + "canny-kangaroo-498": 1050712, + "precious-alpaca-366": 1050713, + "curious-lyrebird-218": 1050722, + "tame-dragon-406": 1050723, + "brainy-koala-612": 1050742, + "keen-cobra-366": 1050743, + "limitless-koala-967": 1050744, + "helpful-snake-380": 1050745, + "harmless-greyhound-525": 1050752, + "wooden-ram-282": 1050753, + "tacit-gnat-101": 1050753, + "warmhearted-bandicoot-355": 1050755, + "sleek-marmot-933": 1050756, + "quirky-goose-460": 1050760, + "rightful-starling-212": 1050761, + "handsome-snake-800": 1050770, + "aromatic-cassowary-734": 1050771, + "dependable-bandicoot-338": 1050795, + "unique-hedgehog-365": 1050796, + "bright-dragon-460": 1050796, + "glorious-roadrunner-451": 1050801, + "energetic-akita-151": 1050802, + "robust-ram-699": 1050817, + "tangible-ostrich-379": 1050818, + "sincere-dolphin-776": 1050822, + "basic-perch-261": 1050823, + "peaceful-coyote-997": 1050843, + "merry-owl-136": 1050844, + "insightful-goshawk-42": 1050851, + "rare-ostrich-215": 1050852, + "dependable-seal-254": 1050853, + "adept-shepherd-650": 1050854, + "energetic-basilisk-382": 1050854, + "tacit-firefly-120": 1050855, + "trustworthy-starling-720": 1050856, + "ardent-owl-16": 1050872, + "upbeat-lynx-996": 1050873, + "fast-chinchilla-377": 1050874, + "fastidious-basilisk-365": 1050875, + "scintillating-warbler-818": 1050878, + "sleek-crane-556": 1050879, + "accomplished-mallard-79": 1050884, + "shocking-bass-246": 1050885, + "superb-cuttlefish-321": 1050885, + "rugged-caterpillar-148": 1050886, + "artful-gopher-516": 1050887, + "beaming-weasel-955": 1050895, + "neighborly-anaconda-524": 1050896, + "rightful-basilisk-272": 1050896, + "resolute-viper-776": 1050912, + "optimistic-porpoise-196": 1050913, + "youthful-crocodile-1": 1050915, + "graceful-dogfish-685": 1050916, + "elated-hyena-684": 1050919, + "merry-marmot-847": 1050920, + "wary-goshawk-985": 1050939, + "wooden-finch-439": 1050940, + "beloved-squid-309": 1050944, + "lovely-goshawk-378": 1050945, + "acoustic-coyote-548": 1050946, + "gregarious-kingfisher-309": 1050947, + "fiery-pig-649": 1050952, + "kindred-dove-870": 1050953, + "basic-elephant-996": 1050957, + "adjoining-tapir-747": 1050958, + "mild-peacock-865": 1050965, + "clear-ox-684": 1050967, + "hearty-capybara-963": 1050980, + "dependable-mole-719": 1050980, + "descriptive-buzzard-171": 1050981, + "blessed-newt-458": 1050986, + "next-labrador-209": 1050987, + "confident-peacock-469": 1050987, + "sincere-walrus-897": 1050995, + "clever-warthog-533": 1050996, + "utmost-mastiff-720": 1050999, + "descriptive-toad-679": 1051000, + "tidy-dog-233": 1051004, + "original-dolphin-115": 1051005, + "lovely-yak-111": 1051006, + "trustworthy-hedgehog-388": 1051007, + "accurate-bison-431": 1051012, + "merry-seahorse-689": 1051013, + "flippant-gull-572": 1051018, + "diligent-bat-115": 1051019, + "whimsical-chameleon-42": 1051039, + "deafening-kingfisher-843": 1051041, + "acrobatic-eagle-555": 1051042, + "energized-lapwing-892": 1051063, + "efficient-spaniel-991": 1051072, + "brazen-spider-643": 1051073, + "groovy-rhinoceros-252": 1051075, + "helpful-swan-56": 1051075, + "grateful-giraffe-487": 1051077, + "affable-ladybug-443": 1051083, + "beaming-kingfisher-857": 1051084, + "veracious-giraffe-986": 1051094, + "upbeat-labrador-827": 1051095, + "adamant-clownfish-321": 1051095, + "original-meerkat-730": 1051097, + "accomplished-gnat-47": 1051098, + "incredible-lapwing-417": 1051099, + "laudable-avocet-907": 1051100, + "spotted-vulture-40": 1051141, + "dapper-bear-854": 1051142, + "compassionate-poodle-446": 1051143, + "amiable-capybara-371": 1051144, + "fiery-mouse-559": 1051166, + "upbeat-starling-115": 1051167, + "intent-dinosaur-750": 1051172, + "bright-herring-455": 1051173, + "rightful-crane-226": 1051181, + "kindhearted-cuttlefish-233": 1051182, + "oceanic-wildebeest-575": 1051201, + "agreeable-labrador-394": 1051202, + "descriptive-ladybug-408": 1051212, + "gallant-labrador-550": 1051213, + "terrific-duck-787": 1051216, + "knowing-kangaroo-234": 1051217, + "strong-gnat-918": 1051232, + "fleet-porpoise-343": 1051233, + "rapid-capybara-25": 1051247, + "elated-warbler-0": 1051248, + "jovial-starling-413": 1051255, + "accurate-pika-693": 1051256, + "oceanic-warthog-181": 1051270, + "artful-walrus-560": 1051271, + "mild-swan-909": 1051274, + "dashing-wombat-235": 1051275, + "trustworthy-dolphin-798": 1051277, + "fine-sparrow-490": 1051279, + "qualified-cassowary-396": 1051286, + "brazen-firefly-894": 1051287, + "dependable-hare-650": 1051292, + "neighborly-shepherd-489": 1051292, + "enchanted-ibex-480": 1051293, + "standing-lynx-565": 1051297, + "charming-dragon-253": 1051298, + "quick-viper-652": 1051310, + "beloved-lyrebird-923": 1051311, + "oceanic-albatross-34": 1051316, + "perfect-chipmunk-415": 1051317, + "focused-wolverine-612": 1051317, + "little-mammoth-227": 1051319, + "giant-porpoise-127": 1051320, + "graceful-kingfisher-703": 1051321, + "quirky-fennec-823": 1051321, + "graceful-dragon-298": 1051322, + "precious-okapi-261": 1051325, + "canny-gazelle-243": 1051326, + "warmhearted-tapir-398": 1051330, + "rightful-dachshund-762": 1051331, + "friendly-giraffe-495": 1051342, + "blissful-lion-384": 1051343, + "energetic-lemming-214": 1051343, + "charming-bulldog-764": 1051346, + "adventurous-chameleon-557": 1051349, + "canny-hawk-912": 1051350, + "decisive-malamute-933": 1051352, + "flexible-kingfisher-721": 1051353, + "cheery-elk-205": 1051354, + "admired-skunk-695": 1051356, + "calm-chipmunk-996": 1051358, + "perceptive-armadillo-495": 1051360, + "little-minnow-228": 1051361, + "formal-ant-409": 1051362, + "tremendous-tapir-460": 1051363, + "good-salamander-556": 1051365, + "limitless-mongoose-234": 1051366, + "fiery-mule-397": 1051370, + "combative-goldfinch-990": 1051371, + "beaming-finch-449": 1051378, + "kindhearted-owl-630": 1051379, + "rosy-butterfly-850": 1051387, + "beloved-skunk-706": 1051388, + "amicable-squid-272": 1051391, + "outstanding-peccary-202": 1051392, + "loyal-mule-650": 1051396, + "outstanding-hamster-661": 1051398, + "good-boar-464": 1051413, + "calm-wombat-375": 1051414, + "robust-cow-389": 1051422, + "dynamic-clam-851": 1051423, + "wonderful-pika-20": 1051433, + "reminiscent-wildebeest-624": 1051434, + "small-magpie-22": 1051437, + "silent-walrus-47": 1051438, + "ardent-kingfisher-322": 1051441, + "successful-goldfinch-232": 1051442, + "animated-donkey-508": 1051446, + "necessary-warthog-829": 1051447, + "qualified-cardinal-389": 1051461, + "grateful-gecko-467": 1051462, + "precise-squid-795": 1051468, + "efficient-manatee-39": 1051469, + "compassionate-shrimp-143": 1051471, + "decisive-squid-129": 1051472, + "merry-barracuda-97": 1051475, + "zany-kudu-647": 1051476, + "intent-peacock-881": 1051478, + "kindred-starfish-483": 1051479, + "fastidious-aardvark-108": 1051487, + "coordinated-dove-524": 1051488, + "third-iguana-84": 1051490, + "precious-kookabura-893": 1051491, + "woozy-avocet-880": 1051499, + "zealous-magpie-2": 1051501, + "tremendous-fish-931": 1051503, + "rugged-kiwi-997": 1051505, + "perceptive-cat-878": 1051508, + "determined-salamander-575": 1051510, + "steady-oriole-485": 1051514, + "festive-orca-890": 1051516, + "elated-seal-300": 1051518, + "dusty-raccoon-28": 1051519, + "fearless-dotterel-365": 1051526, + "charming-spoonbill-41": 1051528, + "sensible-terrier-853": 1051531, + "dynamic-ibis-430": 1051532, + "tangible-buzzard-161": 1052835, + "efficient-warbler-808": 1052851, + "dependable-pika-150": 1052859, + "curious-eagle-819": 1052934, + "trustworthy-schnauzer-833": 1053178, + "academic-cassowary-953": 1053179, + "perfect-owl-937": 1053180, + "keen-avocet-298": 1053181, + "expert-curlew-520": 1053182, + "rapid-gazelle-684": 1053183, + "aromatic-mole-247": 1053184, + "content-moose-697": 1053185, + "notable-mole-195": 1053186, + "diligent-sandpiper-589": 1053187, + "colorful-pheasant-62": 1053572, + "proper-grouse-567": 1053573, + "quick-kudu-748": 1053574, + "effervescent-magpie-153": 1053575, + "cool-hedgehog-249": 1053576, + "oceanic-sturgeon-974": 1053577, + "acrobatic-tern-746": 1053578, + "helpful-caterpillar-496": 1053579, + "impartial-jay-916": 1053580, + "silent-zebra-498": 1053581, + "admired-roadrunner-124": 1054036, + "veracious-snail-951": 1054037, + "hallowed-sheep-706": 1054038, + "veracious-hare-320": 1054039, + "artful-sheep-485": 1054040, + "notable-horse-367": 1054041, + "wandering-heron-249": 1054042, + "outgoing-chickadee-275": 1054043, + "merry-bee-563": 1054044, + "chatty-pelican-824": 1054045, + "curious-hare-328": 1054083, + "vibrant-minnow-332": 1054108, + "dusty-cassowary-347": 1054142, + "savory-retriever-31": 1054162, + "utmost-quail-8": 1054174, + "amiable-iguana-629": 1054177, + "patient-axolotl-135": 1054196, + "academic-axolotl-920": 1054202, + "sensible-sardine-751": 1054214, + "adventurous-capybara-395": 1054219, + "wonderful-walrus-829": 1054225, + "next-hyena-33": 1054236, +}; + +export const updateProdDeploymentName = internalMutation({ + args: { + projectId: v.id("project"), + prodDeploymentName: v.string(), + }, + handler: async (ctx, args) => { + const matchingRecord = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!matchingRecord) { + throw new Error( + "Convex instance not found for project " + args.projectId, + ); + } + + await ctx.db.patch(matchingRecord._id, { + prodDeploymentName: args.prodDeploymentName, + }); + }, +}); + +export const updateLogStreamIds = internalMutation({ + args: { + projectId: v.id("project"), + devLogStreamId: v.optional(v.string()), + prodLogStreamId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const matchingRecord = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!matchingRecord) { + throw new Error( + "Convex instance not found for project " + args.projectId, + ); + } + + const updateData: { + devLogStreamId?: string; + prodLogStreamId?: string; + } = {}; + + if (args.devLogStreamId !== undefined) { + updateData.devLogStreamId = args.devLogStreamId; + } + if (args.prodLogStreamId !== undefined) { + updateData.prodLogStreamId = args.prodLogStreamId; + } + + await ctx.db.patch(matchingRecord._id, updateData); + }, +}); diff --git a/freebuff/web/convex/convex_management.ts b/freebuff/web/convex/convex_management.ts new file mode 100644 index 0000000000..ab7c4f8717 --- /dev/null +++ b/freebuff/web/convex/convex_management.ts @@ -0,0 +1,788 @@ +"use node"; + +import axios, { isAxiosError } from "axios"; +import { randomUUID } from "crypto"; +import { exportJWK, exportPKCS8, generateKeyPair } from "jose"; + +const convexAxiosV1 = axios.create({ + baseURL: "https://api.convex.dev/v1", + headers: { + Authorization: `Bearer ${process.env.CONVEX_ADMIN_KEY}`, + }, +}); + +export const convexAxiosOld = axios.create({ + baseURL: "https://api.convex.dev/api", + headers: { + Authorization: `Bearer ${process.env.CONVEX_ADMIN_KEY}`, + }, +}); + +const USAGE_LOG_WEBHOOK_URL = "https://convex-usage.vly.ai/webhook"; + +function isLogStreamAlreadyExistsError(data: unknown): boolean { + if (!data) { + return false; + } + + if (typeof data === "string") { + return ( + data.includes("LogStreamAlreadyExists") || data.includes("already exists") + ); + } + + if (typeof data === "object") { + const errorData = data as { code?: unknown; message?: unknown }; + return ( + errorData.code === "LogStreamAlreadyExists" || + (typeof errorData.message === "string" && + errorData.message.includes("already exists")) + ); + } + + return false; +} + +/** + * Generate JWT key pair for Convex Auth + * Returns JWT_PRIVATE_KEY (PEM format) and JWKS (JSON Web Key Set) + */ +export async function generateJWTKeyPair(): Promise<{ + privateKey: string; + jwks: string; +}> { + try { + // Generate RS256 key pair + const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, + }); + + // Export private key in PKCS8 PEM format + const privateKeyPem = await exportPKCS8(privateKey); + const privateKeySingleLine = privateKeyPem.trimEnd().replace(/\n/g, " "); + + // Export public key as JWK + const publicJwk = await exportJWK(publicKey); + + // Add required fields for JWKS + publicJwk.use = "sig"; + + // Create JWKS (JSON Web Key Set) + const jwks = JSON.stringify({ + keys: [publicJwk], + }); + + return { + privateKey: privateKeySingleLine, + jwks, + }; + } catch (error) { + throw new Error( + `Failed to generate JWT key pair: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Get environment variables for a Convex deployment using the Deployment API + * Requires a deployment-specific deploy key (not the team admin key) + */ +export async function getConvexEnvironmentVariables( + deploymentName: string, + deployKey: string, + deploymentUrl?: string, +): Promise> { + try { + const url = deploymentUrl ?? `https://${deploymentName}.convex.cloud`; + + const response = await axios.get<{ + environmentVariables: Record; + }>(`${url}/api/v1/list_environment_variables`, { + headers: { + Authorization: `Convex ${deployKey}`, + }, + }); + + // The API returns { environmentVariables: { KEY1: "value1", KEY2: "value2", ... } } + const envVars = response.data?.environmentVariables; + + if (!envVars || typeof envVars !== "object") { + console.error( + `Unexpected response format from list_environment_variables:`, + JSON.stringify(response.data), + ); + return {}; + } + + + return envVars; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to get environment variables for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + throw new Error( + `Failed to get environment variables: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } +} + +/** + * Set environment variables for a Convex deployment using the Deployment API + * Requires a deployment-specific deploy key (not the team admin key) + */ +export async function setConvexEnvironmentVariables( + deploymentName: string, + deployKey: string, + variables: Record, + deploymentUrl?: string, +): Promise { + try { + const url = deploymentUrl ?? `https://${deploymentName}.convex.cloud`; + + const changes = Object.entries(variables).map(([name, value]) => ({ + name, + value, + })); + + const requestBody = { changes }; + await axios.post( + `${url}/api/v1/update_environment_variables`, + requestBody, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully set environment variables for ${deploymentName}:`, + Object.keys(variables).join(", "), + ); + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + `Failed to set environment variables: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } +} + +/** + * Delete an environment variable from a Convex deployment using the Deployment API + * Sends value: null in the changes array to remove the variable + */ +export async function deleteConvexEnvironmentVariable( + deploymentName: string, + deployKey: string, + variableName: string, + deploymentUrl?: string, +): Promise { + try { + const url = deploymentUrl ?? `https://${deploymentName}.convex.cloud`; + + const changes = [{ name: variableName, value: null }]; + + await axios.post( + `${url}/api/v1/update_environment_variables`, + { changes }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully deleted environment variable ${variableName} for ${deploymentName}`, + ); + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + `Failed to delete environment variable: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } +} + +export async function createDeployKey( + deploymentName: string, + keyName?: string, +) { + keyName ??= randomUUID(); + + try { + const response = await convexAxiosV1.post<{ deployKey: string }>( + `/deployments/${deploymentName}/create_deploy_key`, + { + name: keyName, + }, + ); + + return response.data.deployKey; + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + "Failed to create deploy key: " + JSON.stringify(error.response?.data), + ); + } + throw error; + } +} + +export async function createConvexProject() { + if (!process.env.CONVEX_TEAM_ID) { + throw new Error("CONVEX_TEAM_ID is not set"); + } + + const projectName = randomUUID(); + + try { + const response = await convexAxiosV1.post<{ + deploymentName: string; + deploymentUrl: string; + projectId: number; + }>(`/teams/${process.env.CONVEX_TEAM_ID}/create_project`, { + deploymentType: "dev", + projectName, + }); + + return response.data; + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + "Failed to create project: " + JSON.stringify(error.response?.data), + ); + } + throw error; + } +} + +export async function createConvexDeployment({ + deploymentType, + convexProjectId, +}: { + deploymentType: "dev" | "prod"; + convexProjectId: number; +}) { + const result = await convexAxiosV1.post<{ name: string }>( + `/projects/${convexProjectId}/create_deployment`, + { type: deploymentType }, + { + headers: { + "Content-Type": "application/json", + }, + }, + ); + + return result.data.name; +} + +export async function configureUsageLogging( + deploymentName: string, + deployKey: string, +): Promise { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + // Use new Deployment API endpoint for creating log streams + const response = await axios.post<{ id: string }>( + `${deploymentUrl}/api/v1/create_log_stream`, + { + logStreamType: "webhook", + url: USAGE_LOG_WEBHOOK_URL, + format: "json", + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + console.log( + `Successfully configured usage logging for deployment ${deploymentName}, log stream ID: ${response.data.id}`, + ); + return response.data.id; + } catch (error) { + if (isAxiosError(error)) { + if (isLogStreamAlreadyExistsError(error.response?.data)) { + console.warn( + `Usage logging webhook already exists for deployment ${deploymentName}; reusing existing log stream`, + ); + + const existingSinks = await listConfiguredSinks( + deploymentName, + deployKey, + ); + const existingWebhook = existingSinks.find( + (sink) => + sink.logStreamType === "webhook" && + sink.url === USAGE_LOG_WEBHOOK_URL, + ); + + if (existingWebhook) { + return existingWebhook.id; + } + + const anyWebhook = existingSinks.find( + (sink) => sink.logStreamType === "webhook", + ); + if (anyWebhook) { + return anyWebhook.id; + } + + // If Convex reports it already exists, treat as configured even when + // list_log_streams is stale or unavailable. + return "already-exists"; + } + + console.error( + "Failed to configure usage logging:", + JSON.stringify(error.response?.data), + ); + throw new Error( + "Failed to configure usage logging: " + + JSON.stringify(error.response?.data), + ); + } + throw error; + } +} + +/** + * List configured log streams for a deployment using new Deployment API + */ +export async function listConfiguredSinks( + deploymentName: string, + deployKey: string, +): Promise< + Array<{ + id: string; + logStreamType: string; + url: string; + format: "json" | "jsonl"; + hmacSecret: string; + status: { + type: "failed" | "pending" | "active" | "deleting"; + reason?: string; + }; + }> +> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + // Use new Deployment API endpoint for listing log streams + const response = await axios.get< + Array<{ + id: string; + logStreamType: string; + url: string; + format: "json" | "jsonl"; + hmacSecret: string; + status: { + type: "failed" | "pending" | "active" | "deleting"; + reason?: string; + }; + }> + >(`${deploymentUrl}/api/v1/list_log_streams`, { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }); + + return response.data || []; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to list configured sinks for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + } + // Don't throw - return empty array so we can attempt to configure + return []; + } +} + +/** + * Ensure webhook is configured for a deployment. + * Checks if webhook exists and is active, creates it if missing. + * Returns the log stream ID. + */ +export async function ensureWebhookConfigured( + deploymentName: string, + deployKey: string, +): Promise<{ + success: boolean; + action: "already_configured" | "configured" | "failed"; + message: string; + logStreamId?: string; +}> { + const targetUrl = USAGE_LOG_WEBHOOK_URL; + + try { + // Check existing webhooks using new API + const sinks = await listConfiguredSinks(deploymentName, deployKey); + + // Check if our webhook is already configured and active + const existingWebhook = sinks.find( + (sink) => sink.logStreamType === "webhook" && sink.url === targetUrl, + ); + + if (existingWebhook) { + if (existingWebhook.status.type === "active") { + console.log( + `Webhook already active for deployment ${deploymentName}, log stream ID: ${existingWebhook.id}`, + ); + return { + success: true, + action: "already_configured", + message: `Webhook already active for ${deploymentName}`, + logStreamId: existingWebhook.id, + }; + } else { + console.log( + `Webhook exists but status is ${existingWebhook.status.type} for deployment ${deploymentName}. Reason: ${existingWebhook.status.reason || "none"}`, + ); + // Webhook exists but is not active - we'll try to reconfigure + } + } + + // Configure the webhook using new API + const logStreamId = await configureUsageLogging(deploymentName, deployKey); + + return { + success: true, + action: "configured", + message: `Successfully configured webhook for ${deploymentName}`, + logStreamId, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to ensure webhook configured for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + action: "failed", + message: `Failed to configure webhook for ${deploymentName}: ${errorMessage}`, + }; + } +} + +/** + * Update an existing log stream configuration + */ +export async function updateLogStream( + deploymentName: string, + deployKey: string, + logStreamId: string, + newUrl: string, + format?: "json" | "jsonl", +): Promise<{ + success: boolean; + message: string; +}> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + await axios.post( + `${deploymentUrl}/api/v1/update_log_stream/${logStreamId}`, + { + logStreamType: "webhook", + url: newUrl, + format: format || null, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully updated log stream ${logStreamId} for deployment ${deploymentName}`, + ); + + return { + success: true, + message: `Successfully updated log stream ${logStreamId}`, + }; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to update log stream for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + return { + success: false, + message: `Failed to update log stream: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to update log stream for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + message: `Failed to update log stream: ${errorMessage}`, + }; + } +} + +/** + * Delete a log stream + */ +export async function deleteLogStream( + deploymentName: string, + deployKey: string, + logStreamId: string, +): Promise<{ + success: boolean; + message: string; +}> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + await axios.post( + `${deploymentUrl}/api/v1/delete_log_stream/${logStreamId}`, + {}, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully deleted log stream ${logStreamId} for deployment ${deploymentName}`, + ); + + return { + success: true, + message: `Successfully deleted log stream ${logStreamId}`, + }; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to delete log stream for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + return { + success: false, + message: `Failed to delete log stream: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to delete log stream for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + message: `Failed to delete log stream: ${errorMessage}`, + }; + } +} + +/** + * Rotate webhook secret for a log stream + */ +export async function rotateWebhookLogStreamSecret( + deploymentName: string, + deployKey: string, + logStreamId: string, +): Promise<{ + success: boolean; + message: string; + newSecret?: string; +}> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + const response = await axios.post<{ secret: string }>( + `${deploymentUrl}/api/v1/rotate_webhook_secret/${logStreamId}`, + {}, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully rotated webhook secret for log stream ${logStreamId} on deployment ${deploymentName}`, + ); + + return { + success: true, + message: `Successfully rotated webhook secret`, + newSecret: response.data.secret, + }; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to rotate webhook secret for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + return { + success: false, + message: `Failed to rotate webhook secret: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to rotate webhook secret for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + message: `Failed to rotate webhook secret: ${errorMessage}`, + }; + } +} + +/** + * Change deployment state (pause/unpause) using team access token + */ +export async function changeDeploymentStateWithTeamToken( + deploymentName: string, + newState: "paused" | "running", +): Promise<{ + success: boolean; + message: string; +}> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + await axios.post( + `${deploymentUrl}/api/change_deployment_state`, + { + newState, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${process.env.CONVEX_TEAM_ACCESS_TOKEN}`, + }, + }, + ); + + console.log( + `Successfully changed deployment ${deploymentName} state to ${newState}`, + ); + + return { + success: true, + message: `Successfully changed deployment ${deploymentName} state to ${newState}`, + }; + } catch (error) { + if (isAxiosError(error)) { + // Handle "already in state" as success since desired state is achieved + if (error.response?.data?.code === "DeploymentAlreadyInState") { + console.log(`Deployment ${deploymentName} is already ${newState}`); + return { + success: true, + message: `Deployment ${deploymentName} is already ${newState}`, + }; + } + + console.error( + `Failed to change deployment state for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + return { + success: false, + message: `Failed to change deployment state for ${deploymentName}: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to change deployment state for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + message: `Failed to change deployment state for ${deploymentName}: ${errorMessage}`, + }; + } +} + +/** + * Change deployment state (pause/unpause) using deployment admin key + */ +export async function changeDeploymentState( + deploymentName: string, + deployKey: string, + newState: "paused" | "running", +): Promise<{ + success: boolean; + message: string; +}> { + try { + const deploymentUrl = `https://${deploymentName}.convex.cloud`; + await axios.post( + `${deploymentUrl}/api/change_deployment_state`, + { + newState, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${deployKey}`, + }, + }, + ); + + console.log( + `Successfully changed deployment ${deploymentName} state to ${newState}`, + ); + + return { + success: true, + message: `Successfully changed deployment ${deploymentName} state to ${newState}`, + }; + } catch (error) { + if (isAxiosError(error)) { + // Handle "already in state" as success since desired state is achieved + if (error.response?.data?.code === "DeploymentAlreadyInState") { + console.log(`Deployment ${deploymentName} is already ${newState}`); + return { + success: true, + message: `Deployment ${deploymentName} is already ${newState}`, + }; + } + + console.error( + `Failed to change deployment state for ${deploymentName}:`, + JSON.stringify(error.response?.data), + ); + return { + success: false, + message: `Failed to change deployment state for ${deploymentName}: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `Failed to change deployment state for ${deploymentName}:`, + errorMessage, + ); + return { + success: false, + message: `Failed to change deployment state for ${deploymentName}: ${errorMessage}`, + }; + } +} diff --git a/freebuff/web/convex/convex_migration/cli_migrate.ts b/freebuff/web/convex/convex_migration/cli_migrate.ts new file mode 100644 index 0000000000..fb095b6659 --- /dev/null +++ b/freebuff/web/convex/convex_migration/cli_migrate.ts @@ -0,0 +1,480 @@ +"use node"; + +import { v } from "convex/values"; +import { internalAction } from "../_generated/server"; +import { internal, api } from "../_generated/api"; +import { initializeCodebase } from "../../codebase-utils/codebase/initializeCodebase"; +import { DaytonaCodebase } from "../../codebase-utils/codebase/DaytonaCodebase"; +import { + getConvexEnvironmentVariables, + setConvexEnvironmentVariables, +} from "../convex_management"; + +/** + * CLI-based migration approach for self-hosting + * Uses npx convex export/import commands similar to ExportDevToProdDataInternal + * + * This approach: + * 1. Exports data from VLY Convex using existing keys in VM + * 2. Replaces deploy keys with user's self-hosted keys + * 3. Imports data to user's Convex using the new keys + * 4. Updates environment variables + */ + +/** + * Start CLI-based migration from VLY Convex to user's self-hosted Convex + */ +export const startCLIMigration = internalAction({ + args: { + projectId: v.id("project"), + connectionId: v.id("convex_connections"), + }, + handler: async (ctx, args) => { + console.log("[CLI Migration] Starting migration:", { + projectId: args.projectId, + connectionId: args.connectionId, + }); + + let migrationId: any = null; + + try { + // Get project details + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + // Get connection info (encrypted) + const connection = await ctx.runQuery( + internal.convex_oauth.connections.getConnectionWithTokens, + { connectionId: args.connectionId }, + ); + + if (!connection) { + throw new Error("Connection not found"); + } + + // Decrypt the deploy keys + const decryptedDevDeployKey = connection.dev_deploy_key + ? await ctx.runAction(api.convex_oauth.crypto.decryptToken, { + encrypted: connection.dev_deploy_key, + }) + : null; + + const decryptedProdDeployKey = connection.prod_deploy_key + ? await ctx.runAction(api.convex_oauth.crypto.decryptToken, { + encrypted: connection.prod_deploy_key, + }) + : null; + + console.log("[CLI Migration] Deploy keys decrypted"); + + // Get VLY deployment info + const convexInstance = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!convexInstance) { + throw new Error("VLY Convex instance not found"); + } + + // Create migration record + migrationId = await ctx.runMutation( + internal.convex_migration.status.createMigration, + { + projectId: args.projectId, + user_id: connection.user_id, + source_dev_deployment: convexInstance.devDeploymentName, + source_prod_deployment: + convexInstance.prodDeploymentName || undefined, + target_convex_project_id: connection.convex_project_id, + target_team_slug: connection.team_slug, + target_dev_deployment: connection.dev_deployment_name, + target_prod_deployment: connection.prod_deployment_name, + }, + ); + + console.log("[CLI Migration] Migration record created:", migrationId); + + // Initialize sandbox codebase + console.log("[CLI Migration] Connecting to sandbox..."); + const codebase = (await initializeCodebase( + project.sandbox_id, + project.packageManager, + )) as DaytonaCodebase; + + // Step 1: Export dev data using VLY keys (already in place) + console.log("[CLI Migration] Step 1: Exporting dev data from VLY..."); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "exporting", + progress_percentage: 10, + }, + ); + + // Check if snapshot exists and clean up + if (await codebase.checkIfFileExists("./snapshot-dev.zip")) { + console.log("[CLI Migration] Deleting existing snapshot-dev.zip"); + await codebase.runCommandThrow("rm -rf ./snapshot-dev.zip", 30000); + } + + // Export dev database using VLY's dev deploy key + const devExportResult = await codebase.runCommandThrow( + `CONVEX_DEPLOY_KEY=$(cat $HOME/.vly-convex/dev.key) npx convex export --path ./snapshot-dev.zip`, + 120000, // 2 minutes timeout for export + ); + + if (devExportResult.exitCode !== 0) { + throw new Error(`Dev export failed: ${devExportResult.output}`); + } + + console.log("[CLI Migration] Dev export completed"); + + // Step 2: Export prod data if exists + let hasProdData = false; + if (convexInstance.prodDeploymentName) { + console.log("[CLI Migration] Step 2: Exporting prod data from VLY..."); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "exporting", + progress_percentage: 25, + }, + ); + + // Check for existing prod key + const prodKeyCheck = await codebase.runCommand( + `cat $HOME/.vly-convex/prod.key 2>/dev/null || echo ""`, + ); + + if (prodKeyCheck.output.trim()) { + if (await codebase.checkIfFileExists("./snapshot-prod.zip")) { + await codebase.runCommandThrow("rm -rf ./snapshot-prod.zip", 30000); + } + + const prodExportResult = await codebase.runCommandThrow( + `CONVEX_DEPLOY_KEY=$(cat $HOME/.vly-convex/prod.key) npx convex export --prod --path ./snapshot-prod.zip`, + 120000, + ); + + if (prodExportResult.exitCode === 0) { + hasProdData = true; + console.log("[CLI Migration] Prod export completed"); + } else { + console.warn( + "[CLI Migration] Prod export failed, continuing with dev only:", + prodExportResult.output, + ); + } + } else { + console.log( + "[CLI Migration] No prod key found, skipping prod export", + ); + } + } + + // Step 2b: Export environment variables from VLY Convex (before replacing keys!) + // This includes JWT keys, integration keys, and any custom env vars + console.log( + "[CLI Migration] Step 2b: Exporting VLY environment variables...", + ); + let vlyDevEnvVars: Record = {}; + let vlyProdEnvVars: Record = {}; + + try { + // Read VLY dev deploy key from VM + const vlyDevKeyResult = await codebase.runCommand( + `cat $HOME/.vly-convex/dev.key 2>/dev/null || echo ""`, + ); + const vlyDevDeployKey = vlyDevKeyResult.output.trim(); + + if (vlyDevDeployKey) { + // Export env vars from VLY dev deployment + vlyDevEnvVars = await getConvexEnvironmentVariables( + convexInstance.devDeploymentName, + vlyDevDeployKey, + ); + console.log( + "[CLI Migration] Exported VLY dev env vars:", + Object.keys(vlyDevEnvVars), + ); + } else { + console.warn( + "[CLI Migration] No VLY dev key found, skipping env var export", + ); + } + + // Export prod env vars if prod deployment exists + if (convexInstance.prodDeploymentName) { + const vlyProdKeyResult = await codebase.runCommand( + `cat $HOME/.vly-convex/prod.key 2>/dev/null || echo ""`, + ); + const vlyProdDeployKey = vlyProdKeyResult.output.trim(); + + if (vlyProdDeployKey) { + vlyProdEnvVars = await getConvexEnvironmentVariables( + convexInstance.prodDeploymentName, + vlyProdDeployKey, + ); + console.log( + "[CLI Migration] Exported VLY prod env vars:", + Object.keys(vlyProdEnvVars), + ); + } + } + } catch (envExportError) { + console.warn( + "[CLI Migration] Warning: Failed to export VLY env vars, continuing without them:", + envExportError, + ); + // Don't fail migration, continue without env vars + } + + // Step 3: Replace deploy keys with user's self-hosted keys + console.log("[CLI Migration] Step 3: Replacing deploy keys..."); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "updating_credentials", + progress_percentage: 40, + }, + ); + + // Create backup of VLY keys (optional, for rollback) + await codebase.runCommand( + `mkdir -p $HOME/.vly-convex/backup && \ + cp $HOME/.vly-convex/dev.key $HOME/.vly-convex/backup/dev.key.bak 2>/dev/null || true && \ + cp $HOME/.vly-convex/prod.key $HOME/.vly-convex/backup/prod.key.bak 2>/dev/null || true`, + ); + + // Write user's dev deploy key (decrypted) + if (!decryptedDevDeployKey) { + throw new Error("No dev deploy key available"); + } + await codebase.runCommandThrow( + `echo "${decryptedDevDeployKey}" > $HOME/.vly-convex/dev.key`, + 10000, + ); + + // Write user's prod deploy key if available (decrypted) + if (decryptedProdDeployKey) { + await codebase.runCommandThrow( + `echo "${decryptedProdDeployKey}" > $HOME/.vly-convex/prod.key`, + 10000, + ); + } + + console.log("[CLI Migration] Deploy keys replaced"); + + // Step 4: Import dev data to user's Convex + console.log( + "[CLI Migration] Step 4: Importing dev data to user Convex...", + ); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "importing", + progress_percentage: 50, + }, + ); + + const devImportResult = await codebase.runCommandThrow( + `CONVEX_DEPLOY_KEY=$(cat $HOME/.vly-convex/dev.key) npx convex import --replace ./snapshot-dev.zip -y`, + 180000, // 3 minutes timeout for import + ); + + if (devImportResult.exitCode !== 0) { + throw new Error(`Dev import failed: ${devImportResult.output}`); + } + + console.log("[CLI Migration] Dev import completed"); + + // Step 5: Import prod data if available + if (hasProdData && connection.prod_deploy_key) { + console.log( + "[CLI Migration] Step 5: Importing prod data to user Convex...", + ); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "importing", + progress_percentage: 70, + }, + ); + + const prodImportResult = await codebase.runCommandThrow( + `CONVEX_DEPLOY_KEY=$(cat $HOME/.vly-convex/prod.key) npx convex import --prod --replace ./snapshot-prod.zip -y`, + 180000, + ); + + if (prodImportResult.exitCode !== 0) { + console.warn( + "[CLI Migration] Prod import failed:", + prodImportResult.output, + ); + // Don't fail the whole migration for prod import failure + } else { + console.log("[CLI Migration] Prod import completed"); + } + } + + // Step 6: Update environment variables in the sandbox + console.log("[CLI Migration] Step 6: Updating environment variables..."); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "updating_credentials", + progress_percentage: 85, + }, + ); + + // Update CONVEX_DEPLOYMENT env var to point to user's deployment + await codebase.setEnvVars({ + frontend: { + VITE_CONVEX_URL: connection.dev_deployment_url, + CONVEX_DEPLOYMENT: `dev:${connection.dev_deployment_name}`, + }, + backend: {}, + }); + + console.log("[CLI Migration] Environment variables updated"); + + // Step 7: Update database records + console.log("[CLI Migration] Step 7: Updating database records..."); + + // Update project convex URL + await ctx.runMutation(internal.project.updateProjectConvexUrl, { + projectId: args.projectId, + convexUrl: connection.dev_deployment_url, + }); + + // Update project_convex_instance + await ctx.runMutation( + internal.convex_migration.status.updateConvexInstanceForMigration, + { + projectId: args.projectId, + devDeploymentName: connection.dev_deployment_name, + prodDeploymentName: connection.prod_deployment_name, + }, + ); + + // Step 8: Cleanup snapshot files + console.log("[CLI Migration] Step 8: Cleaning up..."); + await codebase.runCommand("rm -f ./snapshot-dev.zip ./snapshot-prod.zip"); + + // Step 9: Restart dev server to apply changes + console.log("[CLI Migration] Step 9: Restarting dev server..."); + try { + await codebase.restartDevServer(); + } catch (restartError: any) { + // Ignore "session already exists" error - dev server is already running + if (restartError?.message?.includes("session already exists")) { + console.log( + "[CLI Migration] Dev server session already exists, skipping restart", + ); + } else { + console.warn( + "[CLI Migration] Failed to restart dev server:", + restartError, + ); + // Don't fail migration for dev server restart issues + } + } + + // Step 10: Transfer env vars to self-hosted deployment + console.log( + "[CLI Migration] Step 10: Setting env vars on self-hosted deployment...", + ); + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "updating_credentials", + progress_percentage: 95, + }, + ); + + if (Object.keys(vlyDevEnvVars).length > 0 && decryptedDevDeployKey) { + await setConvexEnvironmentVariables( + connection.dev_deployment_name, + decryptedDevDeployKey, + vlyDevEnvVars, + connection.dev_deployment_url, + ); + console.log( + "[CLI Migration] Set", + Object.keys(vlyDevEnvVars).length, + "env vars on dev deployment", + ); + } + + if ( + Object.keys(vlyProdEnvVars).length > 0 && + decryptedProdDeployKey && + connection.prod_deployment_name + ) { + await setConvexEnvironmentVariables( + connection.prod_deployment_name, + decryptedProdDeployKey, + vlyProdEnvVars, + connection.prod_deployment_url, + ); + console.log( + "[CLI Migration] Set", + Object.keys(vlyProdEnvVars).length, + "env vars on prod deployment", + ); + } + + // Mark migration as completed + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "completed", + progress_percentage: 100, + }, + ); + + console.log("[CLI Migration] Migration completed successfully!"); + + return { success: true, migrationId }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Migration failed"; + console.error("[CLI Migration] Error:", errorMessage, error); + + // Update migration record with error + if (migrationId) { + try { + await ctx.runMutation( + internal.convex_migration.status.updateMigrationStatus, + { + migrationId, + status: "failed", + error_message: errorMessage, + error_details: error instanceof Error ? error.stack : undefined, + }, + ); + } catch (updateError) { + console.error( + "[CLI Migration] Failed to update migration status:", + updateError, + ); + } + } + + throw error; + } + }, +}); diff --git a/freebuff/web/convex/convex_migration/management_api.ts b/freebuff/web/convex/convex_migration/management_api.ts new file mode 100644 index 0000000000..6a8179522a --- /dev/null +++ b/freebuff/web/convex/convex_migration/management_api.ts @@ -0,0 +1,814 @@ +import { v } from "convex/values"; +import { internalAction } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { SelfHostedDeployment } from "../lib/self_hosted_deployment"; + +const getDeploymentList = (response: any): any[] => { + if (Array.isArray(response)) { + return response; + } + + if (Array.isArray(response?.deployments)) { + return response.deployments; + } + + return []; +}; + +const getDeploymentName = (deployment: any): string | null => { + return ( + deployment?.name ?? + deployment?.deploymentName ?? + deployment?.deployment_name ?? + null + ); +}; + +const isProdDeployment = (deployment: any): boolean => { + const type = + deployment?.deploymentType ?? + deployment?.deployment_type ?? + deployment?.type ?? + null; + + const reference = + deployment?.deploymentReference ?? + deployment?.deployment_reference ?? + deployment?.reference ?? + null; + + return ( + type === "prod" || + type === "production" || + reference === "default_prod" || + reference === "prod" + ); +}; + +/** + * Management API helpers for Convex migration + * Handles project creation, deployment, and configuration + */ + +/** + * Get token details from Convex Management API + * GET /v1/token_details - Returns team or project ID associated with the token + */ +export const getTokenDetails = internalAction({ + args: { + accessToken: v.string(), + }, + handler: async (ctx, args) => { + console.log("[Management API] Getting token details"); + + try { + const response = await fetch("https://api.convex.dev/v1/token_details", { + method: "GET", + headers: { + Authorization: `Bearer ${args.accessToken}`, + }, + }); + + const responseText = await response.text(); + console.log( + "[Management API] Token details response:", + response.status, + responseText, + ); + + if (!response.ok) { + throw new Error( + `Failed to get token details: ${response.status} ${responseText}`, + ); + } + + const details = JSON.parse(responseText); + console.log("[Management API] Token details:", JSON.stringify(details)); + + return details; + } catch (error) { + console.error("[Management API] Failed to get token details:", error); + throw error; + } + }, +}); + +/** + * List projects for a team + * GET /v1/teams/{team_id}/list_projects + */ +export const listProjects = internalAction({ + args: { + accessToken: v.string(), + teamId: v.number(), + }, + handler: async (ctx, args) => { + console.log("[Management API] Listing projects for team:", args.teamId); + + try { + const response = await fetch( + `https://api.convex.dev/v1/teams/${args.teamId}/list_projects`, + { + method: "GET", + headers: { + Authorization: `Bearer ${args.accessToken}`, + }, + }, + ); + + const responseText = await response.text(); + console.log( + "[Management API] List projects response:", + response.status, + responseText, + ); + + if (!response.ok) { + throw new Error( + `Failed to list projects: ${response.status} ${responseText}`, + ); + } + + const projects = JSON.parse(responseText); + return projects; + } catch (error) { + console.error("[Management API] Failed to list projects:", error); + throw error; + } + }, +}); + +/** + * Get project details by ID + * GET /v1/projects/{project_id} + * Returns project info including slug + */ +export const getProjectById = internalAction({ + args: { + accessToken: v.string(), + projectId: v.number(), + }, + handler: async (ctx, args) => { + console.log( + "[Management API] Getting project details for:", + args.projectId, + ); + + try { + const response = await fetch( + `https://api.convex.dev/v1/projects/${args.projectId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${args.accessToken}`, + }, + }, + ); + + const responseText = await response.text(); + console.log( + "[Management API] Get project response:", + response.status, + responseText, + ); + + if (!response.ok) { + throw new Error( + `Failed to get project: ${response.status} ${responseText}`, + ); + } + + const project = JSON.parse(responseText); + console.log("[Management API] Project details:", JSON.stringify(project)); + + return { + id: project.id || project.projectId || args.projectId, + slug: project.slug || project.projectSlug || null, + name: project.name || null, + teamId: project.teamId || project.team_id || null, + teamSlug: project.teamSlug || project.team_slug || null, + }; + } catch (error) { + console.error("[Management API] Failed to get project:", error); + throw error; + } + }, +}); + +/** + * List deployments for a project + * GET /v1/projects/{project_id}/list_deployments + */ +export const listDeployments = internalAction({ + args: { + accessToken: v.string(), + projectId: v.number(), + }, + handler: async (ctx, args) => { + console.log( + "[Management API] Listing deployments for project:", + args.projectId, + ); + + try { + const response = await fetch( + `https://api.convex.dev/v1/projects/${args.projectId}/list_deployments`, + { + method: "GET", + headers: { + Authorization: `Bearer ${args.accessToken}`, + }, + }, + ); + + const responseText = await response.text(); + console.log( + "[Management API] List deployments response:", + response.status, + responseText, + ); + + if (!response.ok) { + throw new Error( + `Failed to list deployments: ${response.status} ${responseText}`, + ); + } + + const deployments = JSON.parse(responseText); + return deployments; + } catch (error) { + console.error("[Management API] Failed to list deployments:", error); + throw error; + } + }, +}); + +/** + * Create deploy key for a deployment using Convex Management API + * POST https://api.convex.dev/deployments/:deployment_name/create_deploy_key + */ +export const createDeployKeyForDeployment = internalAction({ + args: { + accessToken: v.string(), + deploymentName: v.string(), + }, + handler: async (ctx, args) => { + console.log( + "[Management API] Creating deploy key for:", + args.deploymentName, + ); + + try { + const response = await fetch( + `https://api.convex.dev/v1/deployments/${args.deploymentName}/create_deploy_key`, + { + method: "POST", + headers: { + Authorization: `Bearer ${args.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "my-deploy-key", + }), + }, + ); + + const responseText = await response.text(); + console.log( + "[Management API] Create deploy key response:", + response.status, + responseText.substring(0, 500), + ); + + if (!response.ok) { + throw new Error( + `Failed to create deploy key: ${response.status} ${responseText}`, + ); + } + + const result = JSON.parse(responseText); + + // The response should contain deploy_key + const deployKey = result.deploy_key || result.deployKey || result.key; + + console.log("[Management API] Deploy key created successfully"); + + const deploymentUrl = await SelfHostedDeployment.resolveUrl( + args.deploymentName, + args.accessToken, + ); + + return { + deployKey, + deploymentName: args.deploymentName, + deploymentUrl, + }; + } catch (error) { + console.error("[Management API] Failed to create deploy key:", error); + throw error; + } + }, +}); + +/** + * Create dev deployment for the project + * POST /projects/:project_id/create_deployment + */ +export const createDevDeployment = internalAction({ + args: { + accessToken: v.string(), + projectId: v.number(), + }, + handler: async (ctx, args) => { + console.log( + "[Management API] Creating dev deployment for project:", + args.projectId, + ); + + const endpoint = `https://api.convex.dev/v1/projects/${args.projectId}/create_deployment`; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${args.accessToken}`, + }, + body: JSON.stringify({ + type: "dev", + }), + }); + + const responseText = await response.text(); + console.log( + "[Management API] Create dev deployment response:", + response.status, + responseText.substring(0, 500), + ); + + if (!response.ok) { + throw new Error( + `Failed to create dev deployment: ${response.status} ${responseText}`, + ); + } + + const deployment = JSON.parse(responseText); + const deploymentName = deployment.name || deployment.deploymentName; + + console.log("[Management API] Dev deployment created:", deploymentName); + + const deploymentUrl = + deployment.deploymentUrl ?? + (await SelfHostedDeployment.resolveUrl( + deploymentName, + args.accessToken, + )); + + return { + deploymentName, + deploymentUrl, + }; + } catch (error) { + console.error("[Management API] Failed to create dev deployment:", error); + throw error; + } + }, +}); + +/** + * Create prod deployment for the project + * POST /projects/:project_id/create_deployment + */ +export const createProdDeployment = internalAction({ + args: { + accessToken: v.string(), + projectId: v.number(), + }, + handler: async (ctx, args) => { + console.log( + "[Management API] Creating prod deployment for project:", + args.projectId, + ); + + const endpoint = `https://api.convex.dev/v1/projects/${args.projectId}/create_deployment`; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${args.accessToken}`, + }, + body: JSON.stringify({ + type: "prod", + }), + }); + + const responseText = await response.text(); + console.log( + "[Management API] Create prod deployment response:", + response.status, + responseText.substring(0, 500), + ); + + if (!response.ok) { + let responseJson: any = null; + try { + responseJson = JSON.parse(responseText); + } catch { + responseJson = null; + } + + const alreadyExists = + response.status === 400 && + responseJson?.code === "DeploymentAlreadyExists"; + + if (!alreadyExists) { + throw new Error( + `Failed to create prod deployment: ${response.status} ${responseText}`, + ); + } + + console.log( + "[Management API] Prod deployment already exists, fetching existing deployment...", + ); + + const deploymentsResponse: any = await ctx.runAction( + internal.convex_migration.management_api.listDeployments, + { + accessToken: args.accessToken, + projectId: args.projectId, + }, + ); + + const prodDeployment = getDeploymentList(deploymentsResponse).find( + isProdDeployment, + ); + const deploymentName = getDeploymentName(prodDeployment); + + if (!deploymentName) { + throw new Error( + "Prod deployment already exists but could not be resolved from list_deployments response", + ); + } + + const deploymentUrl = + prodDeployment?.deploymentUrl ?? + (await SelfHostedDeployment.resolveUrl( + deploymentName, + args.accessToken, + )); + + return { + deploymentName, + deploymentUrl, + }; + } + + const deployment = JSON.parse(responseText); + const deploymentName = getDeploymentName(deployment); + + if (!deploymentName) { + throw new Error( + `Failed to parse prod deployment name from response: ${responseText}`, + ); + } + + console.log("[Management API] Prod deployment created:", deploymentName); + + const deploymentUrl = + deployment.deploymentUrl ?? + (await SelfHostedDeployment.resolveUrl( + deploymentName, + args.accessToken, + )); + + return { + deploymentName, + deploymentUrl, + }; + } catch (error) { + console.error( + "[Management API] Failed to create prod deployment:", + error, + ); + throw error; + } + }, +}); + +/** + * Setup Convex project using Management API + * This is the main function called after OAuth to prepare user's Convex + * + * Flow: + * 1. GET /v1/token_details → get team_id or project_id + * 2. If team-scoped: GET /v1/teams/{team_id}/list_projects → get first project + * 3. GET /v1/projects/{project_id}/list_deployments → get deployments + * 4. ALWAYS ensure dev deployment exists (create if missing) + * 5. Check if VLY project has prod → if yes, ensure user has prod too + * 6. POST /v1/deployments/{name}/create_deploy_key → get deploy keys + * 7. Return deployment URLs and deploy keys + */ +export const setupUserConvexProject = internalAction({ + args: { + accessToken: v.string(), + projectName: v.string(), // Not used, kept for compatibility + vlyProjectId: v.id("project"), + }, + handler: async (ctx, args): Promise => { + console.log( + "[Management API] Setting up Convex project via Management API", + ); + + try { + // First, get VLY project's convex instance to know if we need prod + const vlyConvexInstance = await ctx.runQuery( + internal.convex_instance.get, + { projectId: args.vlyProjectId }, + ); + + const vlyHasProd = !!( + vlyConvexInstance && vlyConvexInstance.prodDeploymentName + ); + console.log("[Management API] VLY project has prod:", vlyHasProd); + + // Step 1: Get token details to know what we have access to + console.log("[Management API] Step 1: Getting token details..."); + const tokenDetails: any = await ctx.runAction( + internal.convex_migration.management_api.getTokenDetails, + { accessToken: args.accessToken }, + ); + + console.log( + "[Management API] Token details:", + JSON.stringify(tokenDetails), + ); + + let projectId: number | null = + tokenDetails.projectId || tokenDetails.project_id || null; + const teamId: number | null = + tokenDetails.teamId || tokenDetails.team_id || null; + let projectSlug: string | null = + tokenDetails.projectSlug || tokenDetails.project_slug || null; + console.log("[Management API] Token details: ", tokenDetails); + + // Step 2: If we only have team_id, list projects to get project_id + if (!projectId && teamId) { + console.log( + "[Management API] Step 2: Listing projects for team:", + teamId, + ); + const projects: any = await ctx.runAction( + internal.convex_migration.management_api.listProjects, + { accessToken: args.accessToken, teamId }, + ); + + console.log("[Management API] Projects:", JSON.stringify(projects)); + + // Get the first project (or find the one matching projectName if specified) + const projectList = Array.isArray(projects) + ? projects + : projects.projects || []; + if (projectList.length > 0) { + projectId = projectList[0].id || projectList[0].projectId; + projectSlug = + projectList[0].slug || projectList[0].projectSlug || null; + console.log("[Management API] Using project:", projectId); + } + } + + if (!projectId) { + console.log("[Management API] No project found"); + return { + convex_project_id: null, + project_slug: null, + team_slug: null, + dev_deployment_name: null, + dev_deployment_url: null, + dev_deploy_key: null, + dev_deploy_key_name: null, + prod_deployment_name: null, + prod_deployment_url: null, + prod_deploy_key: null, + prod_deploy_key_name: null, + needs_manual_setup: true, + }; + } + + // Step 2.5: If we have projectId but no projectSlug, get project details + if (projectId && !projectSlug) { + console.log( + "[Management API] Step 2.5: Getting project details to find slug...", + ); + try { + const projectDetails: any = await ctx.runAction( + internal.convex_migration.management_api.getProjectById, + { accessToken: args.accessToken, projectId }, + ); + projectSlug = projectDetails.slug; + console.log("[Management API] Got project slug:", projectSlug); + } catch (projectError) { + console.warn( + "[Management API] Could not get project details:", + projectError, + ); + // Continue without slug - we'll try with projectId + } + } + + // Step 3: List deployments for this project + console.log( + "[Management API] Step 3: Listing deployments for project:", + projectId, + ); + const deploymentsResponse: any = await ctx.runAction( + internal.convex_migration.management_api.listDeployments, + { accessToken: args.accessToken, projectId }, + ); + + console.log( + "[Management API] Deployments:", + JSON.stringify(deploymentsResponse), + ); + + // Parse deployments - identify dev and prod separately + const deploymentList = getDeploymentList(deploymentsResponse); + + let devDeployment: any = null; + let prodDeployment: any = null; + + for (const d of deploymentList) { + const type = + d.deploymentType || d.deployment_type || d.type || "development"; + const name = getDeploymentName(d); + + if (isProdDeployment(d) && name) { + prodDeployment = { name, type: "prod" }; + console.log("[Management API] Found existing prod deployment:", name); + } else if ((type === "dev" || type === "development") && name) { + devDeployment = { name, type: "dev" }; + console.log("[Management API] Found existing dev deployment:", name); + } + } + + // Step 4: ALWAYS ensure dev deployment exists + if (!devDeployment) { + console.log( + "[Management API] Step 4: No dev deployment found, creating one...", + ); + + try { + const devDeployResult: any = await ctx.runAction( + internal.convex_migration.management_api.createDevDeployment, + { + accessToken: args.accessToken, + projectId: projectId!, + }, + ); + devDeployment = { + name: devDeployResult.deploymentName, + type: "dev", + }; + console.log( + "[Management API] Dev deployment created:", + devDeployment.name, + ); + } catch (createDevError) { + console.error( + "[Management API] Failed to create dev deployment:", + createDevError, + ); + return { + convex_project_id: projectId, + project_slug: projectSlug, + team_slug: null, + dev_deployment_name: null, + dev_deployment_url: null, + dev_deploy_key: null, + dev_deploy_key_name: null, + prod_deployment_name: null, + prod_deployment_url: null, + prod_deploy_key: null, + prod_deploy_key_name: null, + needs_manual_setup: true, + }; + } + } + + // Step 5: If VLY has prod and user doesn't have prod, create prod + if (vlyHasProd && !prodDeployment) { + console.log( + "[Management API] Step 5: VLY has prod but user doesn't, creating prod deployment...", + ); + + try { + const prodDeployResult: any = await ctx.runAction( + internal.convex_migration.management_api.createProdDeployment, + { + accessToken: args.accessToken, + projectId: projectId!, + }, + ); + prodDeployment = { + name: prodDeployResult.deploymentName, + type: "prod", + }; + console.log( + "[Management API] Prod deployment created:", + prodDeployment.name, + ); + } catch (createProdError) { + console.warn( + "[Management API] Could not create prod deployment:", + createProdError, + ); + // Continue without prod - not a fatal error + } + } + + // Step 6: Create deploy key for dev deployment + console.log( + "[Management API] Step 6: Creating deploy key for dev:", + devDeployment.name, + ); + const devKeyResult: any = await ctx.runAction( + internal.convex_migration.management_api.createDeployKeyForDeployment, + { + accessToken: args.accessToken, + deploymentName: devDeployment.name, + }, + ); + + console.log("[Management API] Dev deploy key created"); + + // Step 7: Create deploy key for prod deployment (if exists) + let prodKeyResult: any = null; + if (prodDeployment) { + console.log( + "[Management API] Step 7: Creating deploy key for prod:", + prodDeployment.name, + ); + try { + prodKeyResult = await ctx.runAction( + internal.convex_migration.management_api + .createDeployKeyForDeployment, + { + accessToken: args.accessToken, + deploymentName: prodDeployment.name, + }, + ); + console.log("[Management API] Prod deploy key created"); + } catch (prodError) { + console.warn( + "[Management API] Could not create prod deploy key:", + prodError, + ); + } + } + + // Return all the info + console.log("[Management API] Setup complete!"); + console.log("[Management API] Summary:", { + dev_deployment: devDeployment.name, + prod_deployment: prodDeployment?.name || "none", + vly_has_prod: vlyHasProd, + }); + + return { + convex_project_id: projectId, + project_slug: projectSlug, + team_slug: null, + dev_deployment_name: devDeployment.name, + dev_deployment_url: devKeyResult.deploymentUrl, + dev_deploy_key: devKeyResult.deployKey, + dev_deploy_key_name: `vly-${args.vlyProjectId}-dev`, + prod_deployment_name: prodDeployment?.name || null, + prod_deployment_url: prodKeyResult?.deploymentUrl || null, + prod_deploy_key: prodKeyResult?.deployKey || null, + prod_deploy_key_name: prodKeyResult + ? `vly-${args.vlyProjectId}-prod` + : null, + needs_manual_setup: false, + }; + } catch (error) { + console.error("[Management API] Failed to setup project:", error); + + // Return needs_manual_setup on error + return { + convex_project_id: null, + project_slug: null, + team_slug: null, + dev_deployment_name: null, + dev_deployment_url: null, + dev_deploy_key: null, + dev_deploy_key_name: null, + prod_deployment_name: null, + prod_deployment_url: null, + prod_deploy_key: null, + prod_deploy_key_name: null, + needs_manual_setup: true, + }; + } + }, +}); diff --git a/freebuff/web/convex/convex_migration/status.ts b/freebuff/web/convex/convex_migration/status.ts new file mode 100644 index 0000000000..26847fd244 --- /dev/null +++ b/freebuff/web/convex/convex_migration/status.ts @@ -0,0 +1,201 @@ +import { v } from "convex/values"; +import { internalMutation, query, mutation } from "../_generated/server"; +import { getAuthUser } from "!/users"; + +/** + * Migration status type + */ +export const migrationStatusValidator = v.union( + v.literal("initiated"), + v.literal("exporting"), + v.literal("importing"), + v.literal("updating_credentials"), + v.literal("completed"), + v.literal("failed"), +); + +/** + * Create a new migration record (internal only) + */ +export const createMigration = internalMutation({ + args: { + projectId: v.id("project"), + user_id: v.id("users"), + source_dev_deployment: v.string(), + source_prod_deployment: v.optional(v.string()), + target_convex_project_id: v.optional(v.number()), + target_team_slug: v.optional(v.string()), + target_dev_deployment: v.string(), + target_prod_deployment: v.optional(v.string()), + }, + returns: v.id("convex_migrations"), + handler: async (ctx, args) => { + const migrationId = await ctx.db.insert("convex_migrations", { + projectId: args.projectId, + user_id: args.user_id, + status: "initiated", + progress_percentage: 0, + source_dev_deployment: args.source_dev_deployment, + source_prod_deployment: args.source_prod_deployment, + target_convex_project_id: args.target_convex_project_id, + target_team_slug: args.target_team_slug, + target_dev_deployment: args.target_dev_deployment, + target_prod_deployment: args.target_prod_deployment, + started_at: Date.now(), + }); + + console.log("[Migration] Created:", { + migrationId, + projectId: args.projectId, + }); + + return migrationId; + }, +}); + +/** + * Update migration status and progress (internal only) + */ +export const updateMigrationStatus = internalMutation({ + args: { + migrationId: v.id("convex_migrations"), + status: migrationStatusValidator, + progress_percentage: v.optional(v.number()), + tables_exported: v.optional(v.array(v.string())), + total_documents_exported: v.optional(v.number()), + total_documents_imported: v.optional(v.number()), + error_message: v.optional(v.string()), + error_details: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const { migrationId, ...updates } = args; + + // If status is completed or failed, set completed_at timestamp + const additionalUpdates: any = {}; + if (updates.status === "completed" || updates.status === "failed") { + additionalUpdates.completed_at = Date.now(); + } + + await ctx.db.patch(migrationId, { + ...updates, + ...additionalUpdates, + }); + + console.log("[Migration] Updated:", { + migrationId, + status: updates.status, + progress: updates.progress_percentage, + }); + }, +}); + +/** + * Get migration status for a project (public - user can view their own) + */ +export const getMigrationStatus = query({ + args: { + projectId: v.id("project"), + }, + returns: v.union( + v.object({ + _id: v.id("convex_migrations"), + projectId: v.id("project"), + status: migrationStatusValidator, + progress_percentage: v.number(), + tables_exported: v.optional(v.array(v.string())), + total_documents_exported: v.optional(v.number()), + total_documents_imported: v.optional(v.number()), + error_message: v.optional(v.string()), + started_at: v.number(), + completed_at: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + // Get the most recent migration for this project + const migration = await ctx.db + .query("convex_migrations") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .order("desc") // Most recent first + .first(); + + if (!migration || migration.user_id !== user._id) { + return null; + } + + return { + _id: migration._id, + projectId: migration.projectId, + status: migration.status, + progress_percentage: migration.progress_percentage, + tables_exported: migration.tables_exported, + total_documents_exported: migration.total_documents_exported, + total_documents_imported: migration.total_documents_imported, + error_message: migration.error_message, + started_at: migration.started_at, + completed_at: migration.completed_at, + }; + }, +}); + +/** + * Cancel an in-progress migration (public) + */ +export const cancelMigration = mutation({ + args: { + migrationId: v.id("convex_migrations"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Unauthorized"); + + const migration = await ctx.db.get(args.migrationId); + if (!migration || migration.user_id !== user._id) { + throw new Error("Migration not found or unauthorized"); + } + + // Can only cancel if not already completed or failed + if (migration.status === "completed" || migration.status === "failed") { + throw new Error("Cannot cancel a completed or failed migration"); + } + + await ctx.db.patch(args.migrationId, { + status: "failed", + error_message: "Cancelled by user", + completed_at: Date.now(), + }); + + console.log("[Migration] Cancelled:", args.migrationId); + }, +}); + +/** + * Update project_convex_instance after migration (for paid users) + */ +export const updateConvexInstanceForMigration = internalMutation({ + args: { + projectId: v.id("project"), + devDeploymentName: v.string(), + prodDeploymentName: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const instance = await ctx.db + .query("project_convex_instance") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .first(); + + if (!instance) { + throw new Error("Convex instance not found"); + } + + await ctx.db.patch(instance._id, { + devDeploymentName: args.devDeploymentName, + prodDeploymentName: args.prodDeploymentName || null, + }); + }, +}); diff --git a/freebuff/web/convex/convex_oauth/connections.ts b/freebuff/web/convex/convex_oauth/connections.ts new file mode 100644 index 0000000000..dced53c769 --- /dev/null +++ b/freebuff/web/convex/convex_oauth/connections.ts @@ -0,0 +1,161 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery, query } from "../_generated/server"; +import { getAuthUser } from "../users"; + +/** + * Store a new Convex OAuth connection (internal only) + */ +export const storeConnection = internalMutation({ + args: { + user_id: v.id("users"), + projectId: v.optional(v.id("project")), + + // OAuth tokens (ENCRYPTED) + access_token: v.string(), + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + + // Convex project info (optional for pending_setup state) + convex_project_id: v.optional(v.number()), + project_slug: v.optional(v.string()), + team_slug: v.optional(v.string()), + + // Deployment info (optional for pending_setup state) + dev_deployment_name: v.optional(v.string()), + dev_deployment_url: v.optional(v.string()), + prod_deployment_name: v.optional(v.string()), + prod_deployment_url: v.optional(v.string()), + + // Deploy keys (ENCRYPTED) - optional for pending_setup state + dev_deploy_key: v.optional(v.string()), + dev_deploy_key_name: v.optional(v.string()), + prod_deploy_key: v.optional(v.string()), + prod_deploy_key_name: v.optional(v.string()), + + // Metadata + connection_type: v.union( + v.literal("migrated"), + v.literal("self_hosted"), + v.literal("oauth_pending_setup"), + ), + is_active: v.boolean(), + }, + handler: async (ctx, args) => { + const now = Date.now(); + + const connectionId = await ctx.db.insert("convex_connections", { + user_id: args.user_id, + projectId: args.projectId, + + access_token: args.access_token, + refresh_token: args.refresh_token, + token_expires_at: args.token_expires_at, + + convex_project_id: args.convex_project_id, + project_slug: args.project_slug, + team_slug: args.team_slug, + + dev_deployment_name: args.dev_deployment_name, + dev_deployment_url: args.dev_deployment_url, + prod_deployment_name: args.prod_deployment_name, + prod_deployment_url: args.prod_deployment_url, + + dev_deploy_key: args.dev_deploy_key, + dev_deploy_key_name: args.dev_deploy_key_name, + prod_deploy_key: args.prod_deploy_key, + prod_deploy_key_name: args.prod_deploy_key_name, + + connection_type: args.connection_type, + is_active: args.is_active, + created_at: now, + updated_at: now, + }); + + console.log("[Convex Connection] Stored:", { + connectionId, + user_id: args.user_id, + projectId: args.projectId, + dev_deployment: args.dev_deployment_name, + }); + + return connectionId; + }, +}); + +export const updateConnectionProdDeployment = internalMutation({ + args: { + connectionId: v.id("convex_connections"), + prod_deployment_name: v.string(), + prod_deployment_url: v.string(), + prod_deploy_key: v.string(), + prod_deploy_key_name: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + prod_deployment_name: args.prod_deployment_name, + prod_deployment_url: args.prod_deployment_url, + prod_deploy_key: args.prod_deploy_key, + prod_deploy_key_name: args.prod_deploy_key_name, + updated_at: Date.now(), + }); + }, +}); + +/** + * Get connection by ID with decrypted tokens (internal only - for migration/API use) + */ +export const getConnectionWithTokens = internalQuery({ + args: { + connectionId: v.id("convex_connections"), + }, + handler: async (ctx, args) => { + const connection = await ctx.db.get(args.connectionId); + if (!connection) { + return null; + } + + // Decrypt tokens using the crypto action + // NOTE: We can't call actions from queries, so the decryption will need to be done + // in an action that calls this query first, then decrypts in the action layer + // For now, return encrypted values - decryption happens in the action layer + + return connection as any; + }, +}); + +/** + * Get connection by project ID (internal only) + * Used to check if a project is self-hosted before deployment + */ +export const getConnectionByProjectId = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const connection = await ctx.db + .query("convex_connections") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .filter((q) => q.eq(q.field("is_active"), true)) + .first(); + + return connection; + }, +}); + +export const isProjectSelfHosted = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return false; + + const connection = await ctx.db + .query("convex_connections") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .filter((q) => q.eq(q.field("is_active"), true)) + .first(); + + return !!connection; + }, +}); diff --git a/freebuff/web/convex/convex_oauth/crypto.ts b/freebuff/web/convex/convex_oauth/crypto.ts new file mode 100644 index 0000000000..e37cd475ee --- /dev/null +++ b/freebuff/web/convex/convex_oauth/crypto.ts @@ -0,0 +1,144 @@ +"use node"; + +import crypto from "crypto"; +import { action } from "../_generated/server"; +import { v } from "convex/values"; + +/** + * Encryption configuration for OAuth tokens and deploy keys + * Uses AES-256-GCM for authenticated encryption + */ +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; // 128 bits +const AUTH_TAG_LENGTH = 16; // 128 bits + +/** + * Get encryption key from environment variable + * Key must be 32 bytes (256 bits) encoded as base64 + */ +function getEncryptionKey(): Buffer { + // const keyBase64 = process.env.CONVEX_TOKEN_ENCRYPTION_KEY || "tfHWY+GkAue4r3VT9SrVFoJMZ9P3Eu6N/bdQaC+5BC4="; + const keyBase64 = "tfHWY+GkAue4r3VT9SrVFoJMZ9P3Eu6N/bdQaC+5BC4="; + if (!keyBase64) { + throw new Error( + "CONVEX_TOKEN_ENCRYPTION_KEY environment variable is not set", + ); + } + + try { + const key = Buffer.from(keyBase64, "base64"); + if (key.length !== 32) { + throw new Error( + `Encryption key must be 32 bytes (256 bits), got ${key.length} bytes`, + ); + } + return key; + } catch (error) { + throw new Error( + `Invalid CONVEX_TOKEN_ENCRYPTION_KEY: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +/** + * Encrypt a token or secret string using AES-256-GCM + * + * @param plaintext The token or secret to encrypt + * @returns Encrypted string in format: "{iv}:{authTag}:{encrypted}" + * + * @example + * const encrypted = await encryptToken("my-secret-token"); + * // Returns: "a1b2c3d4...:{authTag}:{encryptedData}" + */ +export const encryptToken = action({ + args: { + plaintext: v.string(), + }, + returns: v.string(), + handler: async (_, args) => { + if (!args.plaintext || args.plaintext.length === 0) { + throw new Error("Cannot encrypt empty string"); + } + + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(args.plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + // Format: {iv}:{authTag}:{encrypted} + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; + }, +}); + +/** + * Decrypt a token or secret string that was encrypted with encryptToken + * + * @param encrypted The encrypted string in format: "{iv}:{authTag}:{encrypted}" + * @returns The decrypted plaintext + * + * @example + * const decrypted = await decryptToken("a1b2c3d4...:{authTag}:{encryptedData}"); + * // Returns: "my-secret-token" + */ +export const decryptToken = action({ + args: { + encrypted: v.string(), + }, + returns: v.string(), + handler: async (_, args) => { + if (!args.encrypted || args.encrypted.length === 0) { + throw new Error("Cannot decrypt empty string"); + } + + // Parse the encrypted format + const parts = args.encrypted.split(":"); + if (parts.length !== 3) { + throw new Error( + `Invalid encrypted format. Expected "{iv}:{authTag}:{encrypted}", got ${parts.length} parts`, + ); + } + + const [ivHex, authTagHex, encryptedHex] = parts; + + try { + const key = getEncryptionKey(); + const iv = Buffer.from(ivHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + decipher.setAuthTag(authTag); + + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + // Don't leak details about decryption failures (security best practice) + throw new Error("Failed to decrypt token. Invalid encryption or key."); + } + }, +}); + +/** + * Generate a cryptographically secure random string for OAuth state tokens + * + * @param bytes Number of random bytes to generate (default: 32) + * @returns Hex-encoded random string + */ +export const generateSecureState = action({ + args: { + bytes: v.optional(v.number()), + }, + returns: v.string(), + handler: async (_, args) => { + const numBytes = args.bytes || 32; + if (numBytes < 16 || numBytes > 128) { + throw new Error("Bytes must be between 16 and 128"); + } + return crypto.randomBytes(numBytes).toString("hex"); + }, +}); diff --git a/freebuff/web/convex/convex_oauth/oauth.ts b/freebuff/web/convex/convex_oauth/oauth.ts new file mode 100644 index 0000000000..220e06c496 --- /dev/null +++ b/freebuff/web/convex/convex_oauth/oauth.ts @@ -0,0 +1,487 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { action, internalMutation, internalQuery } from "../_generated/server"; +import { api, internal } from "../_generated/api"; + +/** + * Internal mutation to store OAuth state for migration + */ +export const _storeOAuthState = internalMutation({ + args: { + user_id: v.id("users"), + state: v.string(), + projectId: v.id("project"), + return_url: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Clean up any existing OAuth states for this user (older than 15 minutes) + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + const existingStates = await ctx.db + .query("oauth_states") + .withIndex("by_user", (q) => q.eq("user_id", args.user_id)) + .filter((q) => q.lt(q.field("created_at"), fifteenMinutesAgo)) + .collect(); + + for (const stateRecord of existingStates) { + await ctx.db.delete(stateRecord._id); + } + + // Store state for verification with projectId in return_url (as JSON) + await ctx.db.insert("oauth_states", { + user_id: args.user_id, + state: args.state, + // Store projectId in return_url as JSON for later retrieval + return_url: JSON.stringify({ + projectId: args.projectId, + url: args.return_url, + }), + created_at: Date.now(), + }); + }, +}); + +/** + * Verify OAuth state and get user + project info + */ +export const verifyOAuthStateInternal = internalQuery({ + args: { + state: v.string(), + userId: v.id("users"), + }, + returns: v.union( + v.object({ + user_id: v.id("users"), + state_id: v.id("oauth_states"), + projectId: v.id("project"), + return_url: v.optional(v.string()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const stateRecord = await ctx.db + .query("oauth_states") + .withIndex("by_state", (q) => q.eq("state", args.state)) + .first(); + + if (!stateRecord) { + console.warn( + `[Convex OAuth] State not found: ${args.state.substring(0, 8)}`, + ); + return null; + } + + if (stateRecord.user_id !== args.userId) { + console.warn( + `[Convex OAuth] State user mismatch: expected ${args.userId}, got ${stateRecord.user_id}`, + ); + return null; + } + + // Check if state is expired (15 minutes - extended for slow OAuth flows) + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + if (stateRecord.created_at < fifteenMinutesAgo) { + console.warn( + `[Convex OAuth] State expired: ${args.state.substring(0, 8)}, created_at: ${stateRecord.created_at}, now: ${Date.now()}`, + ); + return null; + } + + // Check if state was already used + if (stateRecord.used) { + console.warn( + `[Convex OAuth] State already used: ${args.state.substring(0, 8)}`, + ); + return null; + } + + // Parse return_url JSON to get projectId + let projectId: string; + let returnUrl: string | undefined; + try { + const parsed = JSON.parse(stateRecord.return_url || "{}"); + projectId = parsed.projectId; + returnUrl = parsed.url; + } catch (e) { + console.error("[Convex OAuth] Failed to parse return_url:", e); + return null; + } + + if (!projectId) { + console.error("[Convex OAuth] No projectId in state"); + return null; + } + + return { + user_id: stateRecord.user_id, + state_id: stateRecord._id, + projectId: projectId as any, + return_url: returnUrl, + }; + }, +}); + +/** + * Mark OAuth state as used to prevent replay attacks + */ +export const markOAuthStateAsUsed = internalMutation({ + args: { + stateId: v.id("oauth_states"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.stateId, { + used: true, + }); + }, +}); + +/** + * Initiate Convex OAuth flow for migration + */ +export const initiateMigrationAuth = action({ + args: { + projectId: v.id("project"), + }, + returns: v.string(), // authorization URL + handler: async (ctx, args): Promise => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Unauthorized"); + + // Verify user has access to the project + const project = await ctx.runQuery( + internal.project.getVerifiedAccessProjectInternal, + { + projectId: args.projectId, + userId: user._id, + }, + ); + + if (!project) { + throw new Error("You don't have access to this project"); + } + + // Generate secure state + const state: string = await ctx.runAction( + api.convex_oauth.crypto.generateSecureState, + {}, + ); + + const clientId = process.env.CONVEX_OAUTH_CLIENT_ID; + if (!clientId) { + throw new Error("Convex OAuth Client ID not configured"); + } + const redirectUri = + process.env.CONVEX_OAUTH_REDIRECT_URI || "http://localhost:3000/callback"; + + // Convex OAuth authorization URL + // Requesting project scope to manage user's Convex project + const authUrl: string = `https://dashboard.convex.dev/oauth/authorize/project?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&state=${state}`; + + // Store OAuth state with projectId + await ctx.runMutation(internal.convex_oauth.oauth._storeOAuthState, { + user_id: user._id, + state, + projectId: args.projectId, + return_url: `/project/${args.projectId}`, + }); + + console.log("=== SELF-HOSTING INITIATED ==="); + console.log("[Self-Host] User initiated self-hosting migration:", { + user_id: user._id, + user_email: user.email, + user_tier: user.tier || "free", + projectId: args.projectId, + project_name: project.semantic_identifier, + timestamp: new Date().toISOString(), + }); + + return authUrl; + }, +}); + +/** + * Handle Convex OAuth callback and initiate migration + */ +export const handleMigrationCallback = action({ + args: { + code: v.string(), + state: v.string(), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + semantic_identifier: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + semantic_identifier?: string; + }> => { + const user = await getAuthUser(ctx); + if (!user) { + console.error("[Convex OAuth] Unauthorized callback"); + throw new Error("Unauthorized"); + } + + // Verify state + const stateInfo: any = await ctx.runQuery( + internal.convex_oauth.oauth.verifyOAuthStateInternal, + { + state: args.state, + userId: user._id, + }, + ); + + if (!stateInfo) { + console.error( + "[Convex OAuth] Invalid state - state verification failed for state:", + args.state.substring(0, 16) + "...", + "user:", + user._id, + ); + throw new Error( + "Invalid OAuth state. Please try again by clicking 'Connect Convex' button.", + ); + } + + // Fetch project to get semantic_identifier + const project = await ctx.runQuery( + internal.project.getVerifiedAccessProjectInternal, + { + projectId: stateInfo.projectId, + userId: user._id, + }, + ); + + if (!project) { + throw new Error("Project not found or you don't have access"); + } + + console.log("=== SELF-HOSTING CALLBACK ==="); + console.log("[Self-Host] OAuth callback received:", { + user_id: user._id, + user_email: user.email, + user_tier: user.tier || "free", + projectId: stateInfo.projectId, + project_name: project.semantic_identifier, + timestamp: new Date().toISOString(), + }); + + try { + // Exchange authorization code for access token + const clientId = process.env.CONVEX_OAUTH_CLIENT_ID; + const clientSecret = process.env.CONVEX_OAUTH_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error("Convex OAuth credentials not configured"); + } + + const redirectUri = + process.env.CONVEX_OAUTH_REDIRECT_URI || + "http://localhost:3000/callback"; + + // OAuth2 token endpoint expects application/x-www-form-urlencoded + const tokenParams = new URLSearchParams({ + grant_type: "authorization_code", + code: args.code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + }); + + const tokenResponse = await fetch("https://api.convex.dev/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: tokenParams.toString(), + }); + + const tokenData = await tokenResponse.json(); + + if (tokenData.error || !tokenResponse.ok) { + const errorDesc = + tokenData.error_description || tokenData.error || "Unknown error"; + console.error("[Convex OAuth] Token exchange failed:", errorDesc); + throw new Error(`OAuth error: ${errorDesc}`); + } + + const accessToken = tokenData.access_token; + const refreshToken = tokenData.refresh_token; + const expiresIn = tokenData.expires_in; + + console.log("[Convex OAuth] Access token obtained"); + + // Get existing Convex project info and create deploy keys + console.log("[Convex OAuth] Fetching existing Convex project info"); + const convexProject = await ctx.runAction( + internal.convex_migration.management_api.setupUserConvexProject, + { + accessToken: accessToken, + projectName: `vly-project-${stateInfo.projectId}`, // Not used for creation + vlyProjectId: stateInfo.projectId, + }, + ); + + console.log("[Convex OAuth] Convex project info retrieved:", { + projectId: convexProject.convex_project_id, + slug: convexProject.project_slug, + dev_deployment: convexProject.dev_deployment_name, + prod_deployment: convexProject.prod_deployment_name, + needs_manual_setup: convexProject.needs_manual_setup, + }); + + // Encrypt tokens before storing + const encryptedAccessToken = await ctx.runAction( + api.convex_oauth.crypto.encryptToken, + { plaintext: accessToken }, + ); + const encryptedRefreshToken = refreshToken + ? await ctx.runAction(api.convex_oauth.crypto.encryptToken, { + plaintext: refreshToken, + }) + : undefined; + + // Handle case where we need manual setup (couldn't get deployment info) + if (convexProject.needs_manual_setup) { + console.log( + "[Convex OAuth] Manual setup required - storing OAuth token only", + ); + + // Store connection with just the OAuth token + // Note: Convert null values to undefined since v.optional() doesn't accept null + const connectionId = await ctx.runMutation( + internal.convex_oauth.connections.storeConnection, + { + user_id: user._id, + projectId: stateInfo.projectId, + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + token_expires_at: expiresIn + ? Date.now() + expiresIn * 1000 + : undefined, + convex_project_id: convexProject.convex_project_id ?? undefined, + project_slug: convexProject.project_slug ?? undefined, + team_slug: convexProject.team_slug ?? undefined, + dev_deployment_name: undefined, + dev_deployment_url: undefined, + dev_deploy_key: undefined, + dev_deploy_key_name: undefined, + prod_deployment_name: undefined, + prod_deployment_url: undefined, + prod_deploy_key: undefined, + prod_deploy_key_name: undefined, + connection_type: "oauth_pending_setup", + is_active: true, + }, + ); + + // Mark OAuth state as used + await ctx.runMutation( + internal.convex_oauth.oauth.markOAuthStateAsUsed, + { + stateId: stateInfo.state_id, + }, + ); + + return { + success: true, + message: + "Convex connected! Please enter your deploy key to complete setup.", + semantic_identifier: project.semantic_identifier, + }; + } + + // Encrypt dev deploy key + const encryptedDevDeployKey = await ctx.runAction( + api.convex_oauth.crypto.encryptToken, + { plaintext: convexProject.dev_deploy_key }, + ); + + // Encrypt prod deploy key if available + const encryptedProdDeployKey = convexProject.prod_deploy_key + ? await ctx.runAction(api.convex_oauth.crypto.encryptToken, { + plaintext: convexProject.prod_deploy_key, + }) + : undefined; + + // Store connection with real values from existing project + const connectionId = await ctx.runMutation( + internal.convex_oauth.connections.storeConnection, + { + user_id: user._id, + projectId: stateInfo.projectId, + access_token: encryptedAccessToken, + refresh_token: encryptedRefreshToken, + token_expires_at: expiresIn + ? Date.now() + expiresIn * 1000 + : undefined, + // Real values from existing project (convert null to undefined) + convex_project_id: convexProject.convex_project_id ?? undefined, + project_slug: convexProject.project_slug ?? undefined, + team_slug: convexProject.team_slug ?? undefined, + // Dev deployment + dev_deployment_name: convexProject.dev_deployment_name ?? undefined, + dev_deployment_url: convexProject.dev_deployment_url ?? undefined, + dev_deploy_key: encryptedDevDeployKey, + dev_deploy_key_name: convexProject.dev_deploy_key_name ?? undefined, + // Prod deployment (if exists) - convert null to undefined + prod_deployment_name: convexProject.prod_deployment_name ?? undefined, + prod_deployment_url: convexProject.prod_deployment_url ?? undefined, + prod_deploy_key: encryptedProdDeployKey, + prod_deploy_key_name: convexProject.prod_deploy_key_name ?? undefined, + connection_type: "migrated", + is_active: true, + }, + ); + + console.log("[Convex OAuth] Connection stored:", connectionId); + + // Mark OAuth state as used + await ctx.runMutation(internal.convex_oauth.oauth.markOAuthStateAsUsed, { + stateId: stateInfo.state_id, + }); + + // Schedule CLI-based migration to start immediately + await ctx.scheduler.runAfter( + 0, + internal.convex_migration.cli_migrate.startCLIMigration, + { + projectId: stateInfo.projectId, + connectionId, + }, + ); + + console.log("=== SELF-HOSTING MIGRATION STARTED ==="); + console.log("[Self-Host] Migration scheduled successfully:", { + user_id: user._id, + user_email: user.email, + user_tier: user.tier || "free", + projectId: stateInfo.projectId, + project_name: project.semantic_identifier, + target_convex_project: convexProject.project_slug, + target_dev_deployment: convexProject.dev_deployment_name, + target_prod_deployment: convexProject.prod_deployment_name || "none", + timestamp: new Date().toISOString(), + }); + + return { + success: true, + message: "Convex connected! Migration started.", + semantic_identifier: project.semantic_identifier, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "OAuth callback failed"; + console.error("[Convex OAuth] Callback error:", errorMessage, error); + return { + success: false, + message: errorMessage, + }; + } + }, +}); diff --git a/freebuff/web/convex/crons.ts b/freebuff/web/convex/crons.ts new file mode 100644 index 0000000000..350bef5086 --- /dev/null +++ b/freebuff/web/convex/crons.ts @@ -0,0 +1,85 @@ +import { internal } from '!/_generated/api' +import { cronJobs } from 'convex/server' + +const crons = cronJobs() + +crons.interval( + 'replenish pool if empty', + { minutes: 10 }, + internal.pool_management.replenishPoolIfEmpty, +) + +// GitHub token rotation - run every 60 minutes to keep tokens fresh +crons.interval( + 'rotate expiring github tokens', + { minutes: 60 }, + internal.github.tokens.rotation.scheduleTokenRotation, +) + +// Clean up expired OAuth states - run every 15 minutes +crons.interval( + 'cleanup expired oauth states', + { minutes: 15 }, + internal.github.auth.oauth.cleanupExpiredStates, +) + +// Send pending ticket notification emails - run every 5 minutes +crons.interval( + 'send pending ticket notification emails', + { minutes: 5 }, + internal.tickets_email.processPendingEmails, +) + +crons.interval( + 'sweep timed out freebuff agent runs', + { minutes: 1 }, + internal.coding_agent.freebuff_bridge_mutations.sweepTimedOutFreebuffRuns, +) + +// Bug-fixer bot queue pump: settles finished Codex runs and dispatches the +// next approved issue report into the configured cloud project. No-op unless +// the bot is enabled in /web/admin/bug-fixer. +crons.interval( + 'process bug fixer queue', + { minutes: 1 }, + internal.bug_fixer.queue.processQueue, +) + +// Mirror of the Freebuff sweep above, for Codex / Claude Code threads (which +// don't have a dedicated run ledger). See cli_agent_timeout.ts for details. +crons.interval( + 'sweep timed out codex and claude agent runs', + { minutes: 1 }, + internal.coding_agent.cli_agent.cli_agent_timeout.sweepTimedOutCliAgentRuns, +) + +// Hard absolute-deadline watchdog for ALL agent runs (Freebuff, Codex, Claude). +// Last-resort guarantee that a stuck run is force-finished (Paused, with a +// continue affordance) once its turn budget passes — cloud=20min, web=10min — +// independent of the in-action timer, idle detection, or the chaining loop. +crons.interval( + 'enforce agent processing deadlines', + { minutes: 1 }, + internal.coding_agent.cli_agent.cli_agent_timeout.enforceProcessingDeadlines, +) + +// Keep the Freebuff agent Node bundle warm. Cold-loading it (@codebuff/sdk + +// all bundled agent definitions) costs ~5-9s, which used to land on the first +// user message after an idle period. +crons.interval( + 'warm freebuff agent runtime', + { minutes: 2 }, + internal.coding_agent.cli_agent.executeFreebuff.warmFreebuffRuntime, + {}, +) + +// Persist yesterday's engagement metrics (DAU, signups, projects, totals) +// shortly after UTC midnight so history survives aggregate rebuilds. +crons.daily( + 'snapshot daily stats', + { hourUTC: 0, minuteUTC: 5 }, + internal.activity.snapshotDailyStats, + {}, +) + +export default crons diff --git a/freebuff/web/convex/database/convex.ts b/freebuff/web/convex/database/convex.ts new file mode 100644 index 0000000000..45288bac67 --- /dev/null +++ b/freebuff/web/convex/database/convex.ts @@ -0,0 +1,204 @@ +"use node"; + +import { api, internal } from "!/_generated/api"; +import { action } from "!/_generated/server"; +import { createConvexDeployment, createDeployKey } from "!/convex_management"; +import { v } from "convex/values"; +import { initializeCodebase } from "../../codebase-utils/codebase/initializeCodebase"; + +export const getConvexDeploymentName = action({ + args: { + projectId: v.id("project"), + type: v.union(v.literal("dev"), v.literal("prod")), + }, + handler: async (ctx, args): Promise => { + const convexInstance = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!convexInstance) { + throw new Error( + "Convex instance not found for project " + args.projectId, + ); + } + + let deploymentName; + + if (args.type === "dev") { + deploymentName = convexInstance.devDeploymentName; + } else { + // prod + if (convexInstance.prodDeploymentName) { + deploymentName = convexInstance.prodDeploymentName; + } else { + const prodDeploymentName = await createConvexDeployment({ + deploymentType: "prod", + convexProjectId: convexInstance.convexProjectId, + }); + + await ctx.runMutation( + internal.convex_instance.updateProdDeploymentName, + { + projectId: args.projectId, + prodDeploymentName: prodDeploymentName, + }, + ); + + deploymentName = prodDeploymentName; + } + } + + if (!deploymentName) { + throw new Error( + "Deployment name not found for project " + args.projectId, + ); + } + + return deploymentName; + }, +}); + +export const getConvexDeploymentNameAndAdminKey = action({ + args: { + projectId: v.id("project"), + type: v.union(v.literal("dev"), v.literal("prod")), + }, + handler: async ( + ctx, + args, + ): Promise<{ + deploymentName: string; + adminKey: string; + deploymentUrl: string; + }> => { + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + // Check if this is a self-hosted project first + const selfHostedConnection = await ctx.runQuery( + internal.convex_oauth.connections.getConnectionByProjectId, + { projectId: args.projectId }, + ); + + if (selfHostedConnection) { + const encryptedKey = + args.type === "dev" + ? selfHostedConnection.dev_deploy_key + : selfHostedConnection.prod_deploy_key; + + if (!encryptedKey) { + throw new Error( + `No ${args.type} deploy key found for self-hosted project`, + ); + } + + const adminKey = await ctx.runAction( + api.convex_oauth.crypto.decryptToken, + { encrypted: encryptedKey }, + ); + + const deploymentName = ( + args.type === "dev" + ? selfHostedConnection.dev_deployment_name + : selfHostedConnection.prod_deployment_name + ) as string; + const deploymentUrl = + args.type === "dev" + ? selfHostedConnection.dev_deployment_url + : selfHostedConnection.prod_deployment_url; + + return { + deploymentName, + adminKey, + deploymentUrl: + deploymentUrl ?? `https://${deploymentName}.convex.cloud`, + }; + } + + // VLY-managed project path + const convexInstance = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!convexInstance) { + throw new Error( + "Convex instance not found for project " + args.projectId, + ); + } + + let deploymentName; + + if (args.type === "dev") { + deploymentName = convexInstance.devDeploymentName; + } else { + if (convexInstance.prodDeploymentName) { + deploymentName = convexInstance.prodDeploymentName; + } else { + const prodDeploymentName = await createConvexDeployment({ + deploymentType: "prod", + convexProjectId: convexInstance.convexProjectId, + }); + + await ctx.runMutation( + internal.convex_instance.updateProdDeploymentName, + { + projectId: args.projectId, + prodDeploymentName: prodDeploymentName, + }, + ); + + deploymentName = prodDeploymentName; + } + } + + if (!deploymentName) { + throw new Error( + "Deployment name not found for project " + args.projectId, + ); + } + + // WebContainer projects have no server-side sandbox to cache the admin + // key in — mint a fresh deploy key directly (same as the provision flow). + if (project.sandbox_id?.startsWith("webcontainer:")) { + const adminKey = await createDeployKey(deploymentName); + return { + deploymentName, + adminKey, + deploymentUrl: `https://${deploymentName}.convex.cloud`, + }; + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + const keyFileName = args.type === "dev" ? "dev.key" : "prod.key"; + const keyResult = await codebase.runCommand( + `cat $HOME/.vly-convex/${keyFileName} 2>/dev/null || echo ""`, + ); + + const existingKey = keyResult.output.trim(); + + let adminKey: string; + if (existingKey && keyResult.exitCode === 0) { + adminKey = existingKey; + } else { + adminKey = await createDeployKey(deploymentName); + + await codebase.runCommand( + `mkdir -p $HOME/.vly-convex && echo "${adminKey}" > $HOME/.vly-convex/${keyFileName}`, + ); + } + + return { + deploymentName, + adminKey, + deploymentUrl: `https://${deploymentName}.convex.cloud`, + }; + }, +}); diff --git a/freebuff/web/convex/daytona_migration/backfill.ts b/freebuff/web/convex/daytona_migration/backfill.ts new file mode 100644 index 0000000000..1b8197f940 --- /dev/null +++ b/freebuff/web/convex/daytona_migration/backfill.ts @@ -0,0 +1,262 @@ +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; +import { internal } from "../_generated/api"; +import { + internalAction, + internalMutation, + internalQuery, +} from "../_generated/server"; + +const self: any = (internal as any).daytona_migration.backfill; + +export type LegacyBackfillPage = { + projectIds: Array["_id"]>; + scanned: number; + cursor: string | null; + isDone: boolean; +}; + +export const readLegacyProjectsPage = internalQuery({ + args: { + cursor: v.optional(v.string()), + pageSize: v.optional(v.number()), + }, + handler: async (ctx, args): Promise => { + const pageSize = Math.min(Math.max(args.pageSize ?? 200, 1), 500); + const result = await ctx.db + .query("daytona_migration") + .withIndex("by_daytona_server", (q) => q.eq("daytona_server", "legacy")) + .paginate({ + numItems: pageSize, + cursor: args.cursor ?? null, + }); + + const projectIds = result.page.map((migration) => migration.project_id); + + return { + projectIds, + scanned: result.page.length, + cursor: result.continueCursor ?? null, + isDone: result.isDone, + }; + }, +}); + +export const markProjectsAsNewServer = internalMutation({ + args: { + projectIds: v.array(v.id("project")), + }, + handler: async (ctx, args): Promise<{ updated: number }> => { + let updated = 0; + for (const projectId of args.projectIds) { + const migration = await ctx.db + .query("daytona_migration") + .withIndex("by_project_id", (q) => q.eq("project_id", projectId)) + .unique(); + if (!migration || migration.daytona_server !== "legacy") { + continue; + } + + await ctx.db.patch(migration._id, { + daytona_server: "new", + updated_at: Date.now(), + }); + updated += 1; + } + + return { updated }; + }, +}); + +type ProjectPage = { + projectIds: Array["_id"]>; + scanned: number; + cursor: string | null; + isDone: boolean; +}; + +export const readProjectsPage = internalQuery({ + args: { + cursor: v.optional(v.string()), + pageSize: v.optional(v.number()), + }, + handler: async (ctx, args): Promise => { + const pageSize = Math.min(Math.max(args.pageSize ?? 200, 1), 500); + const result = await ctx.db + .query("project") + .order("desc") + .paginate({ + numItems: pageSize, + cursor: args.cursor ?? null, + }); + + return { + projectIds: result.page.map((project) => project._id), + scanned: result.page.length, + cursor: result.continueCursor ?? null, + isDone: result.isDone, + }; + }, +}); + +export const upsertProjectsAsLegacyServer = internalMutation({ + args: { + projectIds: v.array(v.id("project")), + }, + handler: async ( + ctx, + args, + ): Promise<{ inserted: number; updated: number; skippedNew: number }> => { + let inserted = 0; + let updated = 0; + let skippedNew = 0; + + for (const projectId of args.projectIds) { + const existing = await ctx.db + .query("daytona_migration") + .withIndex("by_project_id", (q) => q.eq("project_id", projectId)) + .unique(); + + if (existing?.daytona_server === "new") { + skippedNew += 1; + continue; + } + + if (!existing) { + await ctx.db.insert("daytona_migration", { + project_id: projectId, + daytona_server: "legacy", + migration_status: "idle", + updated_at: Date.now(), + }); + inserted += 1; + continue; + } + + if (existing.daytona_server !== "legacy") { + await ctx.db.patch(existing._id, { + daytona_server: "legacy", + updated_at: Date.now(), + }); + updated += 1; + } + } + + return { inserted, updated, skippedNew }; + }, +}); + +export const runSeedAllProjectsAsLegacy = internalAction({ + args: { + cursor: v.optional(v.string()), + scanned: v.optional(v.number()), + inserted: v.optional(v.number()), + updated: v.optional(v.number()), + skippedNew: v.optional(v.number()), + pageSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + handler: async ( + ctx, + args, + ): Promise< + | { + status: "scheduled_next_batch"; + scanned: number; + inserted: number; + updated: number; + skippedNew: number; + } + | { + status: "completed"; + scanned: number; + inserted: number; + updated: number; + skippedNew: number; + dryRun: boolean; + } + > => { + const totalScanned = args.scanned ?? 0; + const totalInserted = args.inserted ?? 0; + const totalUpdated = args.updated ?? 0; + const totalSkippedNew = args.skippedNew ?? 0; + + const page: ProjectPage = await ctx.runQuery(self.readProjectsPage, { + cursor: args.cursor, + pageSize: args.pageSize, + }); + + let batchInserted = 0; + let batchUpdated = 0; + let batchSkippedNew = 0; + + if (!args.dryRun && page.projectIds.length > 0) { + const result = await ctx.runMutation(self.upsertProjectsAsLegacyServer, { + projectIds: page.projectIds, + }); + batchInserted = result.inserted; + batchUpdated = result.updated; + batchSkippedNew = result.skippedNew; + } + + const nextScanned = totalScanned + page.scanned; + const nextInserted = totalInserted + batchInserted; + const nextUpdated = totalUpdated + batchUpdated; + const nextSkippedNew = totalSkippedNew + batchSkippedNew; + + console.log( + `[DaytonaMigration][SeedLegacy] scanned=${nextScanned} inserted=${nextInserted} updated=${nextUpdated} skippedNew=${nextSkippedNew} done=${page.isDone} dryRun=${args.dryRun === true}`, + ); + + if (!page.isDone) { + await ctx.scheduler.runAfter(0, self.runSeedAllProjectsAsLegacy, { + cursor: page.cursor ?? undefined, + scanned: nextScanned, + inserted: nextInserted, + updated: nextUpdated, + skippedNew: nextSkippedNew, + pageSize: args.pageSize, + dryRun: args.dryRun, + }); + + return { + status: "scheduled_next_batch", + scanned: nextScanned, + inserted: nextInserted, + updated: nextUpdated, + skippedNew: nextSkippedNew, + }; + } + + return { + status: "completed", + scanned: nextScanned, + inserted: nextInserted, + updated: nextUpdated, + skippedNew: nextSkippedNew, + dryRun: args.dryRun === true, + }; + }, +}); + +export const startSeedAllProjectsAsLegacy = internalAction({ + args: { + pageSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + handler: async ( + ctx, + args, + ): Promise<{ status: "started"; pageSize: number; dryRun: boolean }> => { + await ctx.scheduler.runAfter(0, self.runSeedAllProjectsAsLegacy, { + pageSize: args.pageSize, + dryRun: args.dryRun, + }); + + return { + status: "started", + pageSize: args.pageSize ?? 200, + dryRun: args.dryRun === true, + }; + }, +}); diff --git a/freebuff/web/convex/daytona_migration/constants.ts b/freebuff/web/convex/daytona_migration/constants.ts new file mode 100644 index 0000000000..ba70d68911 --- /dev/null +++ b/freebuff/web/convex/daytona_migration/constants.ts @@ -0,0 +1,11 @@ +export const MIGRATION_ARCHIVE_PATH = "/tmp/migration-codebase.tar.gz"; +export const DAYTONA_CODEBASE_PATH = "/home/daytona/codebase"; + +export type MigrationStatus = + | "idle" + | "queued" + | "copying" + | "validating" + | "cutting_over" + | "done" + | "failed"; diff --git a/freebuff/web/convex/daytona_migration/management.ts b/freebuff/web/convex/daytona_migration/management.ts new file mode 100644 index 0000000000..42035fd858 --- /dev/null +++ b/freebuff/web/convex/daytona_migration/management.ts @@ -0,0 +1,634 @@ +"use node"; + +import { Daytona } from "@daytonaio/sdk"; +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; +import type { LegacyBackfillPage } from "./backfill"; +import { hasDevServer } from "../../codebase-utils/codebase/Codebase"; +import { DaytonaSdkManager } from "../../codebase-utils/codebase/DaytonaSdkManager"; +import type { DaytonaServer } from "../../codebase-utils/codebase/DaytonaSdkManager"; +import { initializeCodebase } from "../../codebase-utils/codebase/initializeCodebase"; +import { configProxy } from "../../codebase-utils/instanceManager"; +import { internal } from "../_generated/api"; +import { + action, + internalAction, +} from "../_generated/server"; +import { DAYTONA_CODEBASE_PATH, MIGRATION_ARCHIVE_PATH } from "./constants"; + +type ProjectPackageManager = "bun" | "pnpm"; +type MigrationResult = + | { status: "already_migrated"; sandboxId: string } + | { + status: "success"; + sourceSandboxId: string; + targetSandboxId: string; + server: "new"; + }; + +const self = (internal as any).daytona_migration.management; +const DAYTONA_MIGRATION_ENABLED_SETTING_KEY = "daytona_migration_enabled"; + +async function openSandboxWithRetries(sdk: Daytona, sandboxId: string) { + const maxAttempts = 8; + let attempt = 0; + let lastError: unknown; + + while (attempt < maxAttempts) { + attempt += 1; + try { + console.log( + `[DaytonaMigration] openSandboxWithRetries attempt=${attempt}/${maxAttempts} sandboxId=${sandboxId}`, + ); + let sandbox = await sdk.get(sandboxId); + if (sandbox.state !== "started") { + console.log( + `[DaytonaMigration] sandbox=${sandboxId} state=${sandbox.state}, starting...`, + ); + await sdk.start(sandbox, 60); + } + sandbox = await sdk.get(sandboxId); + if (sandbox.state !== "started") { + throw new Error(`Sandbox ${sandboxId} not started: ${sandbox.state}`); + } + console.log( + `[DaytonaMigration] sandbox=${sandboxId} confirmed started`, + ); + return sandbox; + } catch (error) { + lastError = error; + console.log( + `[DaytonaMigration] openSandboxWithRetries failed attempt=${attempt} sandboxId=${sandboxId} error=${error instanceof Error ? error.message : String(error)}`, + ); + if (attempt >= maxAttempts) { + break; + } + await new Promise((resolve) => setTimeout(resolve, attempt * 1500)); + } + } + + throw lastError instanceof Error + ? lastError + : new Error("Failed to open sandbox"); +} + +function getTarCommand(packageManager: ProjectPackageManager): string { + const lockfileExcludes = + packageManager === "pnpm" + ? "--exclude='codebase/pnpm-lock.yaml' --exclude='codebase/pnpm-lock.yml'" + : "--exclude='codebase/bun.lock'"; + const commonExcludes = [ + "--exclude='codebase/node_modules'", + "--exclude='codebase/.git'", + "--exclude='codebase/.next'", + "--exclude='codebase/dist'", + "--exclude='codebase/build'", + "--exclude='codebase/coverage'", + "--exclude='codebase/.turbo'", + "--exclude='codebase/.cache'", + "--exclude='codebase/.pnpm-store'", + "--exclude='codebase/isolate'", + "--exclude='codebase/.isolate'", + "--exclude='*.md'", + "--exclude='*.mp3'", + "--exclude='*.mp4'", + "--exclude='*.jpg'", + "--exclude='*.jpeg'", + "--exclude='*.png'", + "--exclude='*.gif'", + "--exclude='*.svg'", + "--exclude='*.ico'", + "--exclude='*.webp'", + ].join(" "); + + return [ + `rm -f ${MIGRATION_ARCHIVE_PATH}`, + `tar -czf ${MIGRATION_ARCHIVE_PATH} ${commonExcludes} ${lockfileExcludes} -C /home/daytona codebase`, + ].join(" && "); +} + +function getInstallCommand(packageManager: ProjectPackageManager): string { + void packageManager; + return "bun install"; +} + +function getRemoveCrudDependencyCommand(): string { + const sanitizeScript = + "const fs=require('fs');const path='package.json';const pkg=JSON.parse(fs.readFileSync(path,'utf8'));const sections=['dependencies','devDependencies','peerDependencies','optionalDependencies'];const removed=[];for(const section of sections){const deps=pkg[section];if(deps&&Object.prototype.hasOwnProperty.call(deps,'crud')){delete deps.crud;removed.push(section);}}if(removed.length){fs.writeFileSync(path, JSON.stringify(pkg,null,2)+'\\n');console.log('removed crud from '+removed.join(','));}else{console.log('crud not found in package.json');}"; + + return [ + "if command -v bun >/dev/null 2>&1; then", + ` bun -e ${JSON.stringify(sanitizeScript)}`, + "elif command -v node >/dev/null 2>&1; then", + ` node -e ${JSON.stringify(sanitizeScript)}`, + "else", + " echo \"Neither bun nor node is available to sanitize package.json\"", + " exit 1", + "fi", + ].join("\n"); +} + +async function detectDaytonaServerForSandboxId( + sandboxId: string, +): Promise { + const legacySdk = DaytonaSdkManager.getDaytonaSDK("legacy"); + const newSdk = DaytonaSdkManager.getDaytonaSDK("new"); + + const [legacyResult, newResult] = await Promise.allSettled([ + legacySdk.get(sandboxId), + newSdk.get(sandboxId), + ]); + + const foundOnLegacy = legacyResult.status === "fulfilled"; + const foundOnNew = newResult.status === "fulfilled"; + + console.log( + `[DaytonaMigration] detectDaytonaServer sandboxId=${sandboxId} foundOnLegacy=${foundOnLegacy} foundOnNew=${foundOnNew}`, + ); + + if (foundOnLegacy && !foundOnNew) { + return "legacy"; + } + if (foundOnNew && !foundOnLegacy) { + return "new"; + } + + if (!foundOnLegacy && !foundOnNew) { + throw new Error( + `Sandbox ${sandboxId} not found on legacy or new Daytona servers`, + ); + } + + throw new Error( + `Sandbox ${sandboxId} appears on both Daytona servers (ambiguous)`, + ); +} + +function isMissingGitRepositoryError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("not a git repository"); +} + +export const resolveProjectDaytonaServer = action({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<{ daytonaServer: DaytonaServer | null }> => { + const project: Doc<"project"> | null = await ctx.runQuery( + internal.project.getProject, + { + projectId: args.projectId, + }, + ); + + if (!project) { + throw new Error("Project not found"); + } + + if (!project.sandbox_id.startsWith("daytona:")) { + return { daytonaServer: null }; + } + + const migration = await ctx.runQuery( + internal.project.getProjectDaytonaMigration, + { + projectId: project._id, + }, + ); + + if (migration?.daytona_server) { + return { daytonaServer: migration.daytona_server }; + } + + const sandboxId = project.sandbox_id.slice("daytona:".length); + if (!sandboxId) { + throw new Error("Invalid Daytona sandbox id"); + } + + const detectedServer = await detectDaytonaServerForSandboxId(sandboxId); + await ctx.runMutation(internal.project.setProjectDaytonaServer, { + projectId: project._id, + daytonaServer: detectedServer, + }); + + console.log( + `[DaytonaMigration] resolved daytona_server projectId=${project._id} server=${detectedServer}`, + ); + + return { daytonaServer: detectedServer }; + }, +}); + +export const migrateAllLegacyProjectsToNewServer = internalAction({ + args: { + cursor: v.optional(v.string()), + scanned: v.optional(v.number()), + updated: v.optional(v.number()), + pageSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const isMigrationEnabled = await ctx.runQuery(internal.settings.getInternal, { + key: DAYTONA_MIGRATION_ENABLED_SETTING_KEY, + defaultValue: true, + }); + + if (!isMigrationEnabled) { + console.log( + "[DaytonaMigration][Backfill] skipped because migration is disabled", + ); + return { + status: "disabled", + scanned: args.scanned ?? 0, + updated: args.updated ?? 0, + }; + } + + const totalScanned = args.scanned ?? 0; + const totalUpdated = args.updated ?? 0; + const page: LegacyBackfillPage = await ctx.runQuery( + internal.daytona_migration.backfill.readLegacyProjectsPage, + { + cursor: args.cursor, + pageSize: args.pageSize, + }, + ); + + let batchUpdated = 0; + if (!args.dryRun && page.projectIds.length > 0) { + const result = await ctx.runMutation( + internal.daytona_migration.backfill.markProjectsAsNewServer, + { + projectIds: page.projectIds, + }, + ); + batchUpdated = result.updated; + } + + const nextScanned = totalScanned + page.scanned; + const nextUpdated = totalUpdated + batchUpdated; + + console.log( + `[DaytonaMigration][Backfill] scanned=${nextScanned} updated=${nextUpdated} batchUpdated=${batchUpdated} legacyInBatch=${page.projectIds.length} done=${page.isDone} dryRun=${args.dryRun === true}`, + ); + + if (!page.isDone) { + await ctx.scheduler.runAfter(0, self.migrateAllLegacyProjectsToNewServer, { + cursor: page.cursor ?? undefined, + scanned: nextScanned, + updated: nextUpdated, + pageSize: args.pageSize, + dryRun: args.dryRun, + }); + return { + status: "scheduled_next_batch", + scanned: nextScanned, + updated: nextUpdated, + }; + } + + console.log( + `[DaytonaMigration][Backfill] complete scanned=${nextScanned} updated=${nextUpdated} dryRun=${args.dryRun === true}`, + ); + + return { + status: "completed", + scanned: nextScanned, + updated: nextUpdated, + dryRun: args.dryRun === true, + }; + }, +}); + +export const startMigrateAllLegacyProjectsToNewServer = internalAction({ + args: { + pageSize: v.optional(v.number()), + dryRun: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const isMigrationEnabled = await ctx.runQuery(internal.settings.getInternal, { + key: DAYTONA_MIGRATION_ENABLED_SETTING_KEY, + defaultValue: true, + }); + + if (!isMigrationEnabled) { + console.log( + "[DaytonaMigration][Backfill] not started because migration is disabled", + ); + return { + status: "disabled", + pageSize: args.pageSize ?? 200, + dryRun: args.dryRun === true, + }; + } + + await ctx.scheduler.runAfter(0, self.migrateAllLegacyProjectsToNewServer, { + pageSize: args.pageSize, + dryRun: args.dryRun, + }); + + console.log( + `[DaytonaMigration][Backfill] started pageSize=${args.pageSize ?? 200} dryRun=${args.dryRun === true}`, + ); + + return { + status: "started", + pageSize: args.pageSize ?? 200, + dryRun: args.dryRun === true, + }; + }, +}); + +export const migrateLegacyProjectToNewDaytona = action({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise => { + console.log( + `[DaytonaMigration] start projectId=${args.projectId} action=migrateLegacyProjectToNewDaytona`, + ); + + const isMigrationEnabled = await ctx.runQuery(internal.settings.getInternal, { + key: DAYTONA_MIGRATION_ENABLED_SETTING_KEY, + defaultValue: true, + }); + if (!isMigrationEnabled) { + throw new Error("Daytona migration is currently disabled by admin"); + } + + const project: Doc<"project"> | null = await ctx.runQuery( + internal.project.getProject, + { + projectId: args.projectId, + }, + ); + + if (!project) { + console.log(`[DaytonaMigration] project not found projectId=${args.projectId}`); + throw new Error("Project not found"); + } + + const migration = await ctx.runQuery( + internal.project.getProjectDaytonaMigration, + { + projectId: project._id, + }, + ); + + console.log( + `[DaytonaMigration] loaded project semantic=${project.semantic_identifier} sandbox_id=${project.sandbox_id} daytona_server=${migration?.daytona_server ?? "undefined"} packageManager=${project.packageManager ?? "undefined"}`, + ); + + if (!project.sandbox_id.startsWith("daytona:")) { + console.log( + `[DaytonaMigration] project sandbox is not Daytona sandbox_id=${project.sandbox_id}`, + ); + throw new Error("Project is not a Daytona sandbox"); + } + + const sourceSandboxId = project.sandbox_id.slice("daytona:".length); + if (!sourceSandboxId) { + console.log(`[DaytonaMigration] invalid source sandbox id parsed empty`); + throw new Error("Invalid source sandbox id"); + } + + let currentServer: DaytonaServer = migration?.daytona_server ?? "legacy"; + if (!migration?.daytona_server) { + currentServer = await detectDaytonaServerForSandboxId(sourceSandboxId); + await ctx.runMutation(internal.project.setProjectDaytonaServer, { + projectId: project._id, + daytonaServer: currentServer, + }); + console.log( + `[DaytonaMigration] inferred missing daytona_server projectId=${project._id} inferred=${currentServer}`, + ); + } + if (currentServer === "new") { + console.log( + `[DaytonaMigration] project already on new server sandbox_id=${project.sandbox_id}`, + ); + return { status: "already_migrated", sandboxId: project.sandbox_id }; + } + + const packageManager: ProjectPackageManager = project.packageManager ?? "bun"; + + console.log( + `[DaytonaMigration] sourceSandboxId=${sourceSandboxId} sourceServer=${currentServer} targetServer=new packageManager=${packageManager}`, + ); + + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "queued", + startedAt: Date.now(), + migrationError: undefined, + }); + + const legacySdk = DaytonaSdkManager.getDaytonaSDK("legacy"); + const newSdk = DaytonaSdkManager.getDaytonaSDK("new"); + console.log("[DaytonaMigration] resolved Daytona SDK clients for legacy and new"); + + let targetSandboxId: string | undefined; + + try { + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "copying", + }); + console.log( + `[DaytonaMigration] migration_status=copying projectId=${args.projectId}`, + ); + + const sourceSandbox = await openSandboxWithRetries(legacySdk, sourceSandboxId); + console.log( + `[DaytonaMigration] source sandbox opened sourceSandboxId=${sourceSandboxId}`, + ); + + const targetPackageManager: ProjectPackageManager = "bun"; + + const targetSandbox = await newSdk.create({ + snapshot: process.env.DAYTONA_SNAPSHOT_ID, + public: true, + autoStopInterval: 10, + autoArchiveInterval: 60, + }); + targetSandboxId = targetSandbox.id; + console.log( + `[DaytonaMigration] created target sandbox targetSandboxId=${targetSandboxId} snapshot=${process.env.DAYTONA_SNAPSHOT_ID ?? "undefined"}`, + ); + + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "copying", + targetSandboxId, + }); + + if (packageManager === "pnpm") { + console.log( + "[DaytonaMigration] pnpm source detected, removing legacy 'crud' dependency before archiving", + ); + const sanitizeResult = await sourceSandbox.process.executeCommand( + getRemoveCrudDependencyCommand(), + DAYTONA_CODEBASE_PATH, + ); + if (sanitizeResult.exitCode !== 0) { + console.log( + `[DaytonaMigration] failed to sanitize package.json exitCode=${sanitizeResult.exitCode} output=${sanitizeResult.result}`, + ); + throw new Error( + `Failed to sanitize pnpm package.json before migration: ${sanitizeResult.result}`, + ); + } + console.log( + `[DaytonaMigration] package.json sanitized output=${sanitizeResult.result}`, + ); + } + + const sourceArchiveCommand = getTarCommand(packageManager); + console.log( + `[DaytonaMigration] creating source archive command=${sourceArchiveCommand}`, + ); + const tarResult = await sourceSandbox.process.executeCommand( + sourceArchiveCommand, + "/home/daytona", + ); + + if (tarResult.exitCode !== 0) { + console.log( + `[DaytonaMigration] source archive failed exitCode=${tarResult.exitCode} output=${tarResult.result}`, + ); + throw new Error(`Failed to archive source codebase: ${tarResult.result}`); + } + console.log("[DaytonaMigration] source archive created successfully"); + + const archiveBuffer = await sourceSandbox.fs.downloadFile(MIGRATION_ARCHIVE_PATH); + console.log( + `[DaytonaMigration] archive downloaded bytes=${archiveBuffer.length}`, + ); + + const targetSandboxStarted = await openSandboxWithRetries(newSdk, targetSandboxId); + console.log( + `[DaytonaMigration] target sandbox opened targetSandboxId=${targetSandboxId}`, + ); + await targetSandboxStarted.fs.uploadFile( + archiveBuffer, + MIGRATION_ARCHIVE_PATH, + 10, + ); + console.log("[DaytonaMigration] archive uploaded to target sandbox"); + + const extractResult = await targetSandboxStarted.process.executeCommand( + [ + `rm -rf ${DAYTONA_CODEBASE_PATH}`, + "mkdir -p /home/daytona", + `tar -xzf ${MIGRATION_ARCHIVE_PATH} -C /home/daytona`, + `rm -f ${MIGRATION_ARCHIVE_PATH}`, + ].join(" && "), + "/home/daytona", + ); + + if (extractResult.exitCode !== 0) { + console.log( + `[DaytonaMigration] extract failed exitCode=${extractResult.exitCode} output=${extractResult.result}`, + ); + throw new Error(`Failed to extract archive in target sandbox: ${extractResult.result}`); + } + console.log("[DaytonaMigration] archive extracted in target sandbox"); + + const installResult = await targetSandboxStarted.process.executeCommand( + getInstallCommand(targetPackageManager), + DAYTONA_CODEBASE_PATH, + ); + if (installResult.exitCode !== 0) { + console.log( + `[DaytonaMigration] dependency install failed exitCode=${installResult.exitCode} output=${installResult.result}`, + ); + throw new Error(`Dependency install failed: ${installResult.result}`); + } + console.log( + `[DaytonaMigration] dependency install successful packageManager=${targetPackageManager}`, + ); + + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "validating", + targetSandboxId, + }); + + try { + const newCodebase = await initializeCodebase( + `daytona:${targetSandboxId}`, + targetPackageManager, + "new", + ); + console.log("[DaytonaMigration] initialized target codebase abstraction"); + + if (!hasDevServer(newCodebase)) { + throw new Error("Target codebase does not support dev server management"); + } + await newCodebase.restartDevServer(); + console.log("[DaytonaMigration] target dev server restarted successfully"); + } catch (error) { + if (!isMissingGitRepositoryError(error)) { + throw error; + } + + console.log( + "[DaytonaMigration] skipping dev server restart because .git is missing in migrated codebase", + ); + } + + const previewDomain = `5173-${targetSandboxId}.proxy.daytona.works`; + const previewUrl = `https://${previewDomain}`; + + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "cutting_over", + targetSandboxId, + }); + console.log( + `[DaytonaMigration] migration_status=cutting_over projectId=${args.projectId}`, + ); + + await ctx.runMutation(internal.project.finalizeDaytonaServerMigration, { + projectId: args.projectId, + newSandboxId: targetSandboxId, + previewUrl, + templateId: process.env.DAYTONA_SNAPSHOT_ID, + newServer: "new", + packageManager: targetPackageManager, + }); + console.log( + `[DaytonaMigration] finalizeDaytonaServerMigration complete newSandboxId=${targetSandboxId}`, + ); + + const proxyTarget = previewUrl.replace(/^https?:\/\//, ""); + + await configProxy({ + slug: project.semantic_identifier, + target: proxyTarget, + }); + console.log( + `[DaytonaMigration] proxy configured slug=${project.semantic_identifier} target=${proxyTarget}`, + ); + + return { + status: "success", + sourceSandboxId, + targetSandboxId, + server: "new", + }; + } catch (error) { + console.log( + `[DaytonaMigration] failed projectId=${args.projectId} targetSandboxId=${targetSandboxId ?? "none"} error=${error instanceof Error ? error.message : String(error)}`, + ); + await ctx.runMutation(internal.project.updateDaytonaMigrationState, { + projectId: args.projectId, + migrationStatus: "failed", + targetSandboxId, + migrationError: error instanceof Error ? error.message : String(error), + completedAt: Date.now(), + }); + throw error; + } + }, +}); diff --git a/freebuff/web/convex/daytona_migration/resolve.ts b/freebuff/web/convex/daytona_migration/resolve.ts new file mode 100644 index 0000000000..f96e50dfd6 --- /dev/null +++ b/freebuff/web/convex/daytona_migration/resolve.ts @@ -0,0 +1,107 @@ +"use node"; + +import { v } from "convex/values"; +import type { Doc } from "../_generated/dataModel"; +import { DaytonaSdkManager } from "../../codebase-utils/codebase/DaytonaSdkManager"; +import type { DaytonaServer } from "../../codebase-utils/codebase/DaytonaSdkManager"; +import { internal } from "../_generated/api"; +import { action } from "../_generated/server"; + +async function detectDaytonaServerForSandboxId( + sandboxId: string, +): Promise { + // Single-server setups (local/dev with only DAYTONA_API_KEY, or any + // deployment that hasn't split legacy/new) point both SDKs at the same + // backend, so the dual-get below would always find the sandbox on "both" and + // throw "ambiguous". Resolve to the fallback server instead. + if (!DaytonaSdkManager.areServersDistinct()) { + const server = DaytonaSdkManager.getFallbackServer(); + console.log( + `[DaytonaResolve] single-server config; resolving sandboxId=${sandboxId} to ${server}`, + ); + return server; + } + + const legacySdk = DaytonaSdkManager.getDaytonaSDK("legacy"); + const newSdk = DaytonaSdkManager.getDaytonaSDK("new"); + + const [legacyResult, newResult] = await Promise.allSettled([ + legacySdk.get(sandboxId), + newSdk.get(sandboxId), + ]); + + const foundOnLegacy = legacyResult.status === "fulfilled"; + const foundOnNew = newResult.status === "fulfilled"; + + console.log( + `[DaytonaResolve] detect sandboxId=${sandboxId} foundOnLegacy=${foundOnLegacy} foundOnNew=${foundOnNew}`, + ); + + if (foundOnLegacy && !foundOnNew) { + return "legacy"; + } + if (foundOnNew && !foundOnLegacy) { + return "new"; + } + + if (!foundOnLegacy && !foundOnNew) { + throw new Error( + `Sandbox ${sandboxId} not found on legacy or new Daytona servers`, + ); + } + + throw new Error( + `Sandbox ${sandboxId} appears on both Daytona servers (ambiguous)`, + ); +} + +export const resolveProjectDaytonaServer = action({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<{ daytonaServer: DaytonaServer | null }> => { + console.log(`[DaytonaResolve] start projectId=${args.projectId}`); + const project: Doc<"project"> | null = await ctx.runQuery( + internal.project.getProject, + { + projectId: args.projectId, + }, + ); + + if (!project) { + throw new Error("Project not found"); + } + + if (!project.sandbox_id.startsWith("daytona:")) { + return { daytonaServer: null }; + } + + const migration = await ctx.runQuery(internal.project.getProjectDaytonaMigration, { + projectId: project._id, + }); + + if (migration?.daytona_server) { + console.log( + `[DaytonaResolve] already set projectId=${project._id} daytona_server=${migration.daytona_server}`, + ); + return { daytonaServer: migration.daytona_server }; + } + + const sandboxId = project.sandbox_id.slice("daytona:".length); + if (!sandboxId) { + throw new Error("Invalid Daytona sandbox id"); + } + + const detectedServer = await detectDaytonaServerForSandboxId(sandboxId); + await ctx.runMutation(internal.project.setProjectDaytonaServer, { + projectId: project._id, + daytonaServer: detectedServer, + }); + + console.log( + `[DaytonaResolve] resolved projectId=${project._id} detected=${detectedServer}`, + ); + + return { daytonaServer: detectedServer }; + }, +}); diff --git a/freebuff/web/convex/deployment.ts b/freebuff/web/convex/deployment.ts new file mode 100644 index 0000000000..3714ceb2d0 --- /dev/null +++ b/freebuff/web/convex/deployment.ts @@ -0,0 +1,748 @@ +import { api, internal } from "!/_generated/api"; +import schema from "!/schema"; +import { typedV } from "convex-helpers/validators"; +import { ConvexError, v } from "convex/values"; +import { + internalMutation, + internalQuery, + mutation, + query, + action, +} from "./_generated/server"; +import { getAuthUser } from "!/users"; +import { Doc } from "./_generated/dataModel"; + +// Internal cacheable version - accepts projectId directly +export const getProjectDeploymentsInternal = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise[]> => { + const deployments = await ctx.db + .query("deployments") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .order("desc") + .collect(); + + return deployments; + }, +}); + +export const getProjectDeployments = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise[]> => { + // Delegate to internal cached version + return await ctx.runQuery( + internal.deployment.getProjectDeploymentsInternal, + { + projectId: args.projectId, + }, + ); + }, +}); + +export const checkIfSlugAvailable = query({ + args: { + slug: v.string(), + }, + handler: async (ctx, args) => { + const slug = args.slug; + + const matchingProject = await ctx.db + .query("project") + .withIndex("by_prod_deployment_slug", (q) => + q.eq("prod_deployment_slug", slug), + ) + .first(); + + return !matchingProject; + }, +}); + +export const createDeployment = mutation({ + args: { + projectId: v.id("project"), + slug: v.string(), + // convexDeploymentName: v.optional(v.string()), + // freestyleDeploymentId: v.optional(v.string()), + // state: typedV(schema).doc("deployments").fields.state, + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new ConvexError("Project not found"); + } + + const slug = args.slug.toLowerCase(); + + const deploymentId = await ctx.db.insert("deployments", { + project: args.projectId, + deploymentDomain: `${slug}.freebuff.app`, + state: "deploying", + }); + + await ctx.db.patch(args.projectId, { + prod_deployment_slug: slug, + }); + + // WebContainer-backed projects can't be built server-side — the codebase + // only exists inside the user's open browser tab. The client drives the + // build (convex deploy + vite build in the container), uploads dist/, and + // calls codesandbox.webcontainerPublish.finalizeWebContainerDeployment. + // Daytona/cloud projects keep the existing server-side pipeline. + if (project.sandbox_id?.startsWith("webcontainer:")) { + await ctx.db.patch(deploymentId, { + deploy_status_text: "Building in your browser...", + }); + return { deploymentId, mode: "webcontainer" as const }; + } + + await ctx.scheduler.runAfter( + 0, + internal.codesandbox.export.deployOnFreestyle, + { + projectId: args.projectId, + slug: slug, + deploymentId, + }, + ); + + return { deploymentId, mode: "server" as const }; + }, +}); + +/** + * Progress/failure reporting for client-driven WebContainer deployments. + * The browser (which runs the build) has no access to the internal + * setDeployStatusText/update mutations, so this authenticated wrapper lets + * the project owner's tab report build progress and mark the deployment + * failed if the in-container build dies. + */ +export const reportWebContainerDeployProgress = mutation({ + args: { + deploymentId: v.id("deployments"), + statusText: v.optional(v.string()), + failed: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new ConvexError("Unauthorized"); + } + + const deployment = await ctx.db.get(args.deploymentId); + if (!deployment) { + throw new ConvexError("Deployment not found"); + } + + const project = await ctx.db.get(deployment.project); + if (!project || !project.sandbox_id?.startsWith("webcontainer:")) { + throw new ConvexError("Not a WebContainer deployment"); + } + + const membership = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", project._id).eq("user", user._id), + ) + .first(); + if (!membership) { + throw new ConvexError("Unauthorized"); + } + + if (args.failed) { + await ctx.db.patch(args.deploymentId, { + state: "error", + deploy_status_text: args.statusText ?? "Deployment failed", + }); + return; + } + + if (args.statusText !== undefined) { + await ctx.db.patch(args.deploymentId, { + deploy_status_text: args.statusText, + }); + } + }, +}); + +export const update = internalMutation({ + args: { + deploymentId: v.id("deployments"), + freestyleDeploymentId: v.optional(v.string()), + convexDeploymentName: v.optional(v.string()), + deploymentDomain: v.optional(v.string()), + state: typedV(schema).doc("deployments").fields.state, + github_deployment_id: v.optional(v.number()), + github_deployment_url: v.optional(v.string()), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.deploymentId, { + freestyleDeploymentId: args.freestyleDeploymentId, + convexDeploymentName: args.convexDeploymentName, + deploymentDomain: args.deploymentDomain, + state: args.state, + github_deployment_id: args.github_deployment_id, + github_deployment_url: args.github_deployment_url, + }); + }, +}); + +export const markAllActiveDeploymentsAsObsolete = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const deployments = await ctx.db + .query("deployments") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .filter((q) => q.eq(q.field("state"), "active")) + .collect(); + + for (const deployment of deployments) { + await ctx.db.patch(deployment._id, { + state: "obsolete", + }); + } + }, +}); + +export const getLatestActiveDeployment = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const deployment = await ctx.db + .query("deployments") + .withIndex("by_project_and_state", (q) => + q.eq("project", args.projectId).eq("state", "active"), + ) + .order("desc") + .first(); + + return deployment; + }, +}); + +export const setDeployStatusText = internalMutation({ + args: { + deploymentId: v.id("deployments"), + deployStatusText: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.deploymentId, { + deploy_status_text: args.deployStatusText, + }); + }, +}); + +export const get = internalQuery({ + args: { + deploymentId: v.id("deployments"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.deploymentId); + }, +}); + +export const cancelDeployment = mutation({ + args: { + deploymentId: v.id("deployments"), + }, + handler: async (ctx, args) => { + const deployment = await ctx.db.get(args.deploymentId); + + if (!deployment) { + throw new ConvexError("Deployment not found"); + } + + if (deployment.state !== "deploying") { + throw new ConvexError("Can only cancel deployments that are in progress"); + } + + // Mark the deployment as cancelling + await ctx.db.patch(args.deploymentId, { + state: "cancelling", + deploy_status_text: "Cancelling deployment...", + }); + + return args.deploymentId; + }, +}); + +export const getDeploymentById = internalQuery({ + args: { + deploymentId: v.id("deployments"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.deploymentId); + }, +}); + +export const cancelStalePendingDeployments = internalMutation({ + args: { + minutesOld: v.number(), + }, + handler: async (ctx, args) => { + const cutoffTime = Date.now() - args.minutesOld * 60 * 1000; + + const staleDeployments = await ctx.db + .query("deployments") + .filter((q) => + q.and( + q.or( + q.eq(q.field("state"), "deploying"), + q.eq(q.field("state"), "cancelling"), + ), + q.lt(q.field("_creationTime"), cutoffTime), + ), + ) + .collect(); + + const cancelledIds = []; + for (const deployment of staleDeployments) { + await ctx.db.patch(deployment._id, { + state: "cancelled", + deploy_status_text: "Deployment timed out", + }); + cancelledIds.push(deployment._id); + } + + return { + cancelledCount: cancelledIds.length, + cancelledIds, + }; + }, +}); + +function isValidSlug(slug: string): boolean { + if (slug.length > 100) return false; + if (slug !== slug.toLowerCase()) return false; + // Only allow lowercase letters and numbers — no dots, underscores, or hyphens + // to ensure valid single-level subdomains (slug.freebuff.app) + return /^[a-z][a-z0-9]*$/.test(slug); +} + +async function removeDomainFromVercel( + vercelProjectId: string, + domain: string, +): Promise<{ status: number; text: string }> { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains/${encodeURIComponent(domain.toLowerCase())}?teamId=${teamId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${vercelToken}`, + }, + }, + ); + + const text = await response.text(); + return { status: response.status, text }; +} + +async function addDomainToVercel( + vercelProjectId: string, + domain: string, +): Promise<{ status: number; text: string }> { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains?teamId=${teamId}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${vercelToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: domain }), + }, + ); + + const text = await response.text(); + return { status: response.status, text }; +} + +// DELETE DEPLOYMENT +export const deleteDeployment = action({ + args: { + deploymentId: v.id("deployments"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new ConvexError("User not authenticated"); + } + + // Get deployment via internal query + const deployment: any = await ctx.runQuery( + internal.deployment.getDeploymentById, + { + deploymentId: args.deploymentId, + }, + ); + if (!deployment) { + throw new ConvexError("Deployment not found"); + } + const project = await ctx.runQuery(internal.project.getProject, { + projectId: deployment.project, + }); + if (!project) { + throw new ConvexError("Project not found"); + } + const projectDeploymentSlug = project.prod_deployment_slug; + if (!projectDeploymentSlug) { + throw new ConvexError("Project deployment slug not found"); + } + + // Check if deployment can be deleted + const activeDeployments: any[] = await ctx.runQuery( + internal.deployment._getActiveDeploymentsByProject, + { projectId: deployment.project }, + ); + + if (deployment.state === "deploying" || deployment.state === "cancelling") { + throw new ConvexError( + `Cannot delete deployment while it is ${deployment.state}`, + ); + } + const deploymentDomain = + deployment.deploymentDomain || `${projectDeploymentSlug}.freebuff.app`; + console.log("deploymentDomain", deploymentDomain); + // Remove domain from Vercel project + if (deploymentDomain) { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + let vercelProjectId = deployment.freestyleDeploymentId || null; + + // Verify stored ID is valid, fall back to slug lookup + if (vercelProjectId) { + const checkResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(vercelProjectId)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (!checkResponse.ok) { + vercelProjectId = null; + } + } + + if (!vercelProjectId) { + const slugFromDomain = deploymentDomain.replace(".freebuff.app", ""); + const lookupResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(slugFromDomain)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (lookupResponse.ok) { + const projectData = await lookupResponse.json(); + vercelProjectId = projectData.id; + } + } + + if (vercelProjectId) { + console.log(`[DEBUG] Removing domain ${deploymentDomain} from Vercel`); + const unmapResult = await removeDomainFromVercel( + vercelProjectId, + deploymentDomain, + ); + console.log( + `[DEBUG] Vercel domain removal status: ${unmapResult.status}`, + ); + + if (![200, 204, 404].includes(unmapResult.status)) { + console.error( + `[DEBUG] Failed to remove domain. Status: ${unmapResult.status}, Response: ${unmapResult.text}`, + ); + throw new ConvexError( + `Failed to remove domain from Vercel: ${unmapResult.text || "Unknown error"}`, + ); + } + } + } + + // Delete deployment from database via internal mutation + await ctx.runMutation(internal.deployment._deleteDeploymentInDb, { + deploymentId: args.deploymentId, + projectId: deployment.project, + isActiveDeployment: deployment.state === "active", + }); + + return { + success: true, + message: `Deployment ${deployment.deploymentDomain} deleted successfully`, + }; + }, +}); + +// UPDATE DEPLOYMENT SLUG +export const updateDeploymentSlug = action({ + args: { + deploymentId: v.id("deployments"), + newSlug: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new ConvexError("User not authenticated"); + } + + // Validate new slug format + if (!isValidSlug(args.newSlug)) { + throw new ConvexError( + "Invalid slug format. Only lowercase letters and numbers allowed", + ); + } + + // Get deployment via internal query + const deployment: any = await ctx.runQuery( + internal.deployment.getDeploymentById, + { + deploymentId: args.deploymentId, + }, + ); + if (!deployment) { + throw new ConvexError("Deployment not found"); + } + const project = await ctx.runQuery(internal.project.getProject, { + projectId: deployment.project, + }); + if (!project) { + throw new ConvexError("Project not found"); + } + const projectDeploymentSlug = project.prod_deployment_slug; + if (!projectDeploymentSlug) { + throw new ConvexError("Project deployment slug not found"); + } + // Only allow updating active deployments + if (deployment.state !== "active") { + throw new ConvexError( + `Can only update active deployments. This deployment is ${deployment.state}`, + ); + } + + // Check if new slug is already taken via public query + const existingSlug: boolean = await ctx.runQuery( + api.deployment.checkIfSlugAvailable, + { + slug: args.newSlug, + }, + ); + + if (!existingSlug) { + throw new ConvexError(`Slug "${args.newSlug}" is already taken`); + } + let oldSlug: string; + if (deployment.deploymentDomain) { + oldSlug = deployment.deploymentDomain; + } else { + oldSlug = `${projectDeploymentSlug}.freebuff.app`; + } + console.log("oldSlug", oldSlug); + const newDeploymentDomain = `${args.newSlug}.freebuff.app`; + + { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + // Resolve Vercel project ID: try stored ID, fall back to slug lookup + let vercelProjectId = deployment.freestyleDeploymentId || null; + + if (vercelProjectId) { + const checkResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(vercelProjectId)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (!checkResponse.ok) { + console.log( + `[DEBUG] Stored ID ${vercelProjectId} not found on Vercel, looking up by slug`, + ); + vercelProjectId = null; + } + } + + if (!vercelProjectId && oldSlug) { + const slugFromDomain = oldSlug.replace(".freebuff.app", ""); + const lookupResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(slugFromDomain)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (lookupResponse.ok) { + const projectData = await lookupResponse.json(); + vercelProjectId = projectData.id; + console.log( + `[DEBUG] Found Vercel project by slug ${slugFromDomain}: ${vercelProjectId}`, + ); + } + } + + if (vercelProjectId && oldSlug) { + // STEP 1: Remove the old domain from Vercel + console.log(`[DEBUG] Removing old domain ${oldSlug} from Vercel`); + const unmapResult = await removeDomainFromVercel( + vercelProjectId, + oldSlug, + ); + console.log( + `[DEBUG] Vercel domain removal status: ${unmapResult.status}`, + ); + + if (![200, 204, 404].includes(unmapResult.status)) { + console.error( + `[DEBUG] Failed to remove old domain. Status: ${unmapResult.status}, Response: ${unmapResult.text}`, + ); + throw new ConvexError( + `Failed to remove old domain from Vercel: ${unmapResult.text || "Unknown error"}`, + ); + } + + // STEP 2: Add the new domain to Vercel + console.log( + `[DEBUG] Adding domain ${newDeploymentDomain} to Vercel project ${vercelProjectId}`, + ); + const remapResult = await addDomainToVercel( + vercelProjectId, + newDeploymentDomain, + ); + console.log(`[DEBUG] Vercel domain add status: ${remapResult.status}`); + + if ( + !remapResult.status.toString().startsWith("2") && + remapResult.status !== 409 + ) { + console.error( + `[DEBUG] Failed to add domain. Status: ${remapResult.status}, Response: ${remapResult.text}`, + ); + throw new ConvexError( + `Failed to add domain on Vercel: ${remapResult.text || "Unknown error"}`, + ); + } + + // Update stored ID so future operations don't need the lookup + await ctx.runMutation(internal.deployment.update, { + deploymentId: args.deploymentId, + state: deployment.state, + freestyleDeploymentId: vercelProjectId, + }); + } else { + console.log(`[DEBUG] No Vercel project found, skipping domain mapping`); + } + } + + // Update deployment domain in database + await ctx.runMutation(internal.deployment._updateDeploymentSlugInDb, { + deploymentId: args.deploymentId, + projectId: deployment.project, + newSlug: args.newSlug, + newDeploymentDomain, + }); + + return { + success: true, + message: `Deployment slug updated from ${oldSlug} to ${newDeploymentDomain}`, + newDeploymentDomain, + }; + }, +}); + +// Internal mutation to update deployment slug in database +export const _updateDeploymentSlugInDb = internalMutation({ + args: { + deploymentId: v.id("deployments"), + projectId: v.id("project"), + newSlug: v.string(), + newDeploymentDomain: v.string(), + }, + handler: async (ctx, args) => { + // Update deployment with new domain + await ctx.db.patch(args.deploymentId, { + deploymentDomain: args.newDeploymentDomain, + }); + + // Update project's prod_deployment_slug + await ctx.db.patch(args.projectId, { + prod_deployment_slug: args.newSlug, + }); + + // Update community post's previewUrl if one exists for this project + const communityPost = await ctx.db + .query("community_posts") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .unique(); + + if (communityPost) { + const newPreviewUrl = `https://${args.newDeploymentDomain}`; + await ctx.db.patch(communityPost._id, { + previewUrl: newPreviewUrl, + updatedAt: Date.now(), + }); + } + }, +}); + +// Internal query to get active deployments by project +export const _getActiveDeploymentsByProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("deployments") + .withIndex("by_project_and_state", (q) => + q.eq("project", args.projectId).eq("state", "active"), + ) + .collect(); + }, +}); + +// Internal mutation to delete deployment and clean up +export const _deleteDeploymentInDb = internalMutation({ + args: { + deploymentId: v.id("deployments"), + projectId: v.id("project"), + isActiveDeployment: v.boolean(), + }, + handler: async (ctx, args) => { + // Delete deployment from database + await ctx.db.delete(args.deploymentId); + + // Clear prod_deployment_slug from project if this was the active deployment + if (args.isActiveDeployment) { + const project = await ctx.db.get(args.projectId); + if (project && project.prod_deployment_slug) { + await ctx.db.patch(args.projectId, { + prod_deployment_slug: undefined, + }); + } + + // If no active deployments remain, clean up all old deployments + const remainingActiveDeployments = await ctx.db + .query("deployments") + .withIndex("by_project_and_state", (q) => + q.eq("project", args.projectId).eq("state", "active"), + ) + .collect(); + + if (remainingActiveDeployments.length === 0) { + // Delete all old obsolete/cancelled deployments for clean slate + const allOldDeployments = await ctx.db + .query("deployments") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + for (const oldDeployment of allOldDeployments) { + await ctx.db.delete(oldDeployment._id); + } + + console.log( + `[DEBUG] Cleaned up all old deployments for project ${args.projectId}`, + ); + } + } + }, +}); diff --git a/freebuff/web/convex/deployment_helpers.ts b/freebuff/web/convex/deployment_helpers.ts new file mode 100644 index 0000000000..5883f4bc90 --- /dev/null +++ b/freebuff/web/convex/deployment_helpers.ts @@ -0,0 +1,356 @@ +"use node"; + +import { v } from "convex/values"; +import { internalAction } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { PAUSE_REASON_VALIDATOR } from "./constants"; +import { changeDeploymentStateWithTeamToken } from "./convex_management"; + +/** + * Check if a user is paused and attempt to unpause them if they have sufficient credits. + * This is used to gate critical user actions like creating projects or sending messages. + * + * @returns Object with canProceed (boolean) and optional message (string) + */ +export const checkAndUnpauseUser = internalAction({ + args: { + userId: v.id("users"), + }, + handler: async ( + ctx, + args, + ): Promise<{ + canProceed: boolean; + unpaused?: boolean; + message?: string; + }> => { + // Check if user is currently paused + const pauseStatus = await ctx.runQuery( + internal.deployment_queries.getUserPauseStatusInternal, + { + userId: args.userId, + }, + ); + + // User is not paused - can proceed + if (!pauseStatus) { + return { + canProceed: true, + }; + } + + // User is paused - attempt to unpause + console.log( + `User ${args.userId} is paused (reason: ${pauseStatus.pauseReason}), attempting to unpause...`, + ); + + const unpauseResult: any = await ctx.runAction( + internal.deployment_management.unpauseUserDeployments, + { + userId: args.userId, + }, + ); + + if (unpauseResult.success && unpauseResult.unpaused) { + console.log( + `Successfully unpaused user ${args.userId}, they can now proceed`, + ); + return { + canProceed: true, + unpaused: true, + message: `Deployments unpaused! Restarting ${unpauseResult.successCount} deployment${unpauseResult.successCount !== 1 ? "s" : ""}...`, + }; + } + + // Could not unpause - block the action + const errorMessage = + unpauseResult.message || + "Your Convex deployments are paused. Please add more Convex credits to continue."; + + console.log(`Cannot unpause user ${args.userId}: ${errorMessage}`); + + return { + canProceed: false, + message: errorMessage, + }; + }, +}); + +/** + * Pause a single project's Convex deployments + */ +export const pauseProjectDeployments = internalAction({ + args: { + projectId: v.id("project"), + pauseReason: PAUSE_REASON_VALIDATOR, + pausedBy: v.id("users"), + }, + handler: async (ctx, args): Promise => { + console.log( + `Pausing deployments for project ${args.projectId} due to ${args.pauseReason}`, + ); + + // Get project and convex instance directly + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const convexInstance = await ctx.runQuery( + internal.project.getConvexInstanceInternal, + { + projectId: args.projectId, + }, + ); + + if (!convexInstance) { + return { + success: false, + message: "Project has no Convex deployments", + totalDeployments: 0, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Build operations for dev and prod deployments + const operations: Array<() => Promise> = []; + const results: any[] = []; + + // Dev deployment + if (convexInstance.devDeploymentName) { + operations.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + convexInstance.devDeploymentName, + "paused", + ); + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: convexInstance.devDeploymentName, + type: "dev", + success: result.success, + message: result.message, + }; + } catch (error) { + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: convexInstance.devDeploymentName, + type: "dev", + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }); + } + + // Prod deployment (if different from dev) + if ( + convexInstance.prodDeploymentName && + convexInstance.prodDeploymentName !== convexInstance.devDeploymentName + ) { + const prodDeploymentName = convexInstance.prodDeploymentName; // Capture for closure + operations.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + prodDeploymentName, + "paused", + ); + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: prodDeploymentName, + type: "prod", + success: result.success, + message: result.message, + }; + } catch (error) { + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: prodDeploymentName, + type: "prod", + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }); + } + + // Execute operations in parallel + const operationResults = await Promise.all(operations.map((op) => op())); + results.push(...operationResults); + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + console.log( + `Paused ${successCount} deployment(s) for project ${args.projectId}`, + ); + + // If successful, create a pause record in the database + if (successCount > 0) { + await ctx.runMutation( + internal.deployment_queries.createProjectPauseRecord, + { + projectId: args.projectId, + pauseReason: args.pauseReason, + pausedBy: args.pausedBy, + }, + ); + } + + return { + success: successCount > 0, + message: + successCount > 0 + ? `Paused ${successCount} deployment(s)` + : "Failed to pause any deployments", + totalDeployments: results.length, + successCount, + failureCount, + results, + }; + }, +}); + +/** + * Unpause a single project's Convex deployments + */ +export const unpauseProjectDeployments = internalAction({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise => { + console.log(`Unpausing deployments for project ${args.projectId}`); + + // Get project and convex instance directly + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const convexInstance = await ctx.runQuery( + internal.project.getConvexInstanceInternal, + { + projectId: args.projectId, + }, + ); + + if (!convexInstance) { + return { + success: false, + message: "Project has no Convex deployments", + totalDeployments: 0, + successCount: 0, + failureCount: 0, + results: [], + }; + } + + // Build operations for dev and prod deployments + const operations: Array<() => Promise> = []; + const results: any[] = []; + + // Dev deployment + if (convexInstance.devDeploymentName) { + operations.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + convexInstance.devDeploymentName, + "running", + ); + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: convexInstance.devDeploymentName, + type: "dev", + success: result.success, + message: result.message, + }; + } catch (error) { + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: convexInstance.devDeploymentName, + type: "dev", + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }); + } + + // Prod deployment (if different from dev) + if ( + convexInstance.prodDeploymentName && + convexInstance.prodDeploymentName !== convexInstance.devDeploymentName + ) { + const prodDeploymentName = convexInstance.prodDeploymentName; // Capture for closure + operations.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + prodDeploymentName, + "running", + ); + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: prodDeploymentName, + type: "prod", + success: result.success, + message: result.message, + }; + } catch (error) { + return { + projectId: args.projectId, + projectName: project.name || project.semantic_identifier, + deploymentName: prodDeploymentName, + type: "prod", + success: false, + message: `Error: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }); + } + + // Execute operations in parallel + const operationResults = await Promise.all(operations.map((op) => op())); + results.push(...operationResults); + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + console.log( + `Unpaused ${successCount} deployment(s) for project ${args.projectId}`, + ); + + // If successful, mark the pause record as unpaused + if (successCount > 0) { + await ctx.runMutation(internal.deployment_queries.unpauseProjectRecord, { + projectId: args.projectId, + }); + } + + return { + success: successCount > 0, + message: + successCount > 0 + ? `Unpaused ${successCount} deployment(s)` + : "Failed to unpause any deployments", + totalDeployments: results.length, + successCount, + failureCount, + results, + }; + }, +}); diff --git a/freebuff/web/convex/deployment_management.ts b/freebuff/web/convex/deployment_management.ts new file mode 100644 index 0000000000..f1a4fc5945 --- /dev/null +++ b/freebuff/web/convex/deployment_management.ts @@ -0,0 +1,703 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { Id } from "./_generated/dataModel"; +import { action, internalAction } from "./_generated/server"; +import { changeDeploymentStateWithTeamToken } from "./convex_management"; +import { getAuthUser } from "./users"; +import { PAUSE_REASON_VALIDATOR, PauseReason } from "./constants"; + +/** + * Map pause reason to Autumn feature ID + */ +function mapPauseReasonToFeatureId(reason: string): string { + const mapping: Record = { + db_bandwidth_depleted: "convex_database_bw", + compute_depleted: "convex_compute", + db_storage_depleted: "convex_database_bw", // No separate storage feature, using db_bw + file_bandwidth_depleted: "convex_file_bw", + function_calls_depleted: "convex_function_calls", + }; + return mapping[reason] || "convex_database_bw"; +} + +/** + * Map Autumn feature ID to pause reason (reverse mapping) + */ +export function mapFeatureIdToPauseReason( + featureId: string, +): PauseReason | null { + const mapping: Record = { + convex_database_bw: "db_bandwidth_depleted", + convex_compute: "compute_depleted", + convex_file_bw: "file_bandwidth_depleted", + convex_function_calls: "function_calls_depleted", + }; + return mapping[featureId] || null; +} + +type DeploymentResult = { + projectId: Id<"project">; + projectName: string; + deploymentName: string; + type: "dev" | "prod"; + success: boolean; + message: string; +}; + +/** + * Shared helper to change state of all user deployments + * Used by both pause and unpause operations + */ +async function changeUserDeploymentsState( + ctx: any, + userId: Id<"users">, + targetState: "paused" | "running", + actionVerb: string, +): Promise<{ + totalDeployments: number; + successCount: number; + failureCount: number; + results: DeploymentResult[]; +}> { + console.log(`${actionVerb} all deployments for user ${userId}`); + + // Get all user deployments + const deployments = await ctx.runQuery( + internal.deployment_queries.getUserDeployments, + { + userId, + }, + ); + + console.log( + `Found ${deployments.length} projects with Convex deployments for user ${userId}`, + ); + console.log( + "Projects:", + deployments.map((d: any) => ({ + name: d.projectName, + dev: d.devDeploymentName, + prod: d.prodDeploymentName, + })), + ); + + // Build array of operations for parallel processing + const operations = deployments.flatMap((deployment: any) => { + const ops: Array<() => Promise> = []; + + // Dev deployment operation + if (deployment.devDeploymentName) { + ops.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + deployment.devDeploymentName, + targetState, + ); + + return { + projectId: deployment.projectId, + projectName: deployment.projectName, + deploymentName: deployment.devDeploymentName, + type: "dev" as const, + success: result.success, + message: result.message, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + projectId: deployment.projectId, + projectName: deployment.projectName, + deploymentName: deployment.devDeploymentName, + type: "dev" as const, + success: false, + message: `Error: ${errorMessage}`, + }; + } + }); + } + + // Prod deployment operation (if different from dev) + if ( + deployment.prodDeploymentName && + deployment.prodDeploymentName !== deployment.devDeploymentName + ) { + const prodDeploymentName = deployment.prodDeploymentName; // Capture for closure + ops.push(async () => { + try { + const result = await changeDeploymentStateWithTeamToken( + prodDeploymentName, + targetState, + ); + + return { + projectId: deployment.projectId, + projectName: deployment.projectName, + deploymentName: prodDeploymentName, + type: "prod" as const, + success: result.success, + message: result.message, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + projectId: deployment.projectId, + projectName: deployment.projectName, + deploymentName: prodDeploymentName, + type: "prod" as const, + success: false, + message: `Error: ${errorMessage}`, + }; + } + }); + } + + return ops; + }); + + // Process in parallel batches of 10 to avoid overwhelming external APIs + const BATCH_SIZE = 10; + const results: DeploymentResult[] = []; + + for (let i = 0; i < operations.length; i += BATCH_SIZE) { + const batch = operations.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map((op: () => Promise) => op()), + ); + results.push(...batchResults); + } + + const successCount = results.filter((r) => r.success).length; + const failureCount = results.filter((r) => !r.success).length; + + console.log( + `${actionVerb} complete: ${successCount} succeeded, ${failureCount} failed`, + ); + + return { + totalDeployments: results.length, + successCount, + failureCount, + results, + }; +} + +/** + * Pause all Convex deployments for a user (admin only) + */ +export const pauseAllUserDeployments = internalAction({ + args: { + userId: v.id("users"), + pauseReason: PAUSE_REASON_VALIDATOR, + autoUnpauseEnabled: v.optional(v.boolean()), + pausedBy: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + console.log( + `Pausing all deployments for user ${args.userId} due to ${args.pauseReason}`, + ); + + const autoUnpause = args.autoUnpauseEnabled ?? true; + + // Record the pause in paused_users table + await ctx.runAction(internal.deployment_management.recordUserPause, { + userId: args.userId, + pauseReason: args.pauseReason, + autoUnpauseEnabled: autoUnpause, + pausedBy: args.pausedBy, + }); + + // Use shared function to pause all deployments + return await changeUserDeploymentsState( + ctx, + args.userId, + "paused", + "Pausing", + ); + }, +}); + +/** + * Unpause all Convex deployments for a user (admin only) + */ +export const unpauseAllUserDeployments = internalAction({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + // Use shared function to unpause all deployments + return await changeUserDeploymentsState( + ctx, + args.userId, + "running", + "Unpausing", + ); + }, +}); + +/** + * Check all Convex resources for a user to see if they're available + * Returns which resources are depleted (if any) + */ +export const checkAllConvexResources = internalAction({ + args: { + userId: v.id("users"), + }, + handler: async ( + ctx, + args, + ): Promise<{ + allAvailable: boolean; + depletedResources: Array<{ + featureId: string; + available: boolean; + error: string | null; + }>; + checks: Array<{ + featureId: string; + available: boolean; + error: string | null; + }>; + }> => { + const resources: Array = [ + "convex_database_bw", + "convex_compute", + "convex_file_bw", + "convex_function_calls", + ]; + void ctx; + void args; + const checks = resources.map((featureId) => ({ + featureId, + available: true, + error: null, + })); + + return { + allAvailable: true, + depletedResources: [], + checks, + }; + }, +}); + +/** + * Unpause user deployments that were automatically paused + */ +export const unpauseUserDeployments = internalAction({ + args: { + userId: v.id("users"), + force: v.optional(v.boolean()), + }, + handler: async (ctx, args): Promise => { + console.log(`Attempting to unpause deployments for user ${args.userId}`); + + // Check if user has an active pause record with auto-unpause enabled + const pauseRecord = await ctx.runQuery( + internal.deployment_queries.getUserPauseStatusInternal, + { + userId: args.userId, + }, + ); + + if (!pauseRecord) { + console.log(`No active pause record found for user ${args.userId}`); + return { + success: false, + message: "No active pause found for this user", + unpaused: false, + }; + } + + if (!args.force && !pauseRecord.autoUnpauseEnabled) { + console.log( + `User ${args.userId} was manually paused, skipping auto-unpause`, + ); + return { + success: false, + message: "User was manually paused and cannot be auto-unpaused", + unpaused: false, + }; + } + + if (!args.force) { + console.log(`Checking all Convex resources for user ${args.userId}`); + + const resourceCheck = await ctx.runAction( + internal.deployment_management.checkAllConvexResources, + { userId: args.userId }, + ); + + if (!resourceCheck.allAvailable) { + const depletedList = resourceCheck.depletedResources + .map((r: any) => r.featureId) + .join(", "); + + console.log( + `Cannot unpause user ${args.userId}: resources still depleted: ${depletedList}`, + ); + + return { + success: false, + message: `Cannot unpause: ${depletedList} still depleted`, + unpaused: false, + depletedResources: resourceCheck.depletedResources, + }; + } + } + + console.log( + `${args.force ? "[Force] " : ""}All checks passed for user ${args.userId}, proceeding with unpause`, + ); + + // Mark the pause record as inactive + await ctx.runMutation(internal.paused_users.deactivatePauseRecord, { + pauseId: pauseRecord._id, + }); + + // Unpause all user deployments + const result = await ctx.runAction( + internal.deployment_management.unpauseAllUserDeployments, + { userId: args.userId }, + ); + + console.log( + `Unpaused ${result.successCount} deployments for user ${args.userId}`, + ); + + return { + success: true, + message: `Successfully unpaused ${result.successCount} deployments`, + unpaused: true, + ...result, + }; + }, +}); + +/** + * Record a user pause in the database (internal action) + */ +export const recordUserPause = internalAction({ + args: { + userId: v.id("users"), + pauseReason: PAUSE_REASON_VALIDATOR, + autoUnpauseEnabled: v.boolean(), + pausedBy: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + // Check if there's an existing active pause record + const existingPause = await ctx.runQuery( + internal.deployment_queries.getUserPauseStatusInternal, + { + userId: args.userId, + }, + ); + + if (existingPause) { + // Update existing record + await ctx.runMutation(internal.paused_users.updatePauseRecord, { + pauseId: existingPause._id, + pauseReason: args.pauseReason, + autoUnpauseEnabled: args.autoUnpauseEnabled, + pausedBy: args.pausedBy, + }); + } else { + // Create new record + await ctx.runMutation(internal.paused_users.createPauseRecord, { + userId: args.userId, + pauseReason: args.pauseReason, + autoUnpauseEnabled: args.autoUnpauseEnabled, + pausedBy: args.pausedBy, + }); + } + }, +}); + +/** + * Check all paused users and unpause if their resource limits are replenished + * Runs daily via cron job + */ +export const checkAndUnpausePausedUsers = internalAction({ + args: {}, + handler: async (ctx): Promise => { + console.log("[Cron] Checking paused users for resource replenishment..."); + + const pausedUsers = await ctx.runQuery( + internal.deployment_queries.getPausedUsers, + ); + + const autoUnpausableUsers = pausedUsers.filter( + (p: any) => p.autoUnpauseEnabled === true, + ); + + console.log( + `[Cron] Found ${autoUnpausableUsers.length} auto-unpausable paused users`, + ); + + if (autoUnpausableUsers.length === 0) { + return { + totalChecked: 0, + unpausedCount: 0, + stillPausedCount: 0, + errorCount: 0, + results: [], + }; + } + + // Process in parallel batches to avoid timeout + const BATCH_SIZE = 5; + const results: Array<{ + userId: Id<"users">; + pauseReason: string; + resourceChecked: string; + resourceAvailable: boolean; + unpauseAttempted: boolean; + unpauseSuccess: boolean; + message: string; + }> = []; + + for (let i = 0; i < autoUnpausableUsers.length; i += BATCH_SIZE) { + const batch = autoUnpausableUsers.slice(i, i + BATCH_SIZE); + + const batchResults = await Promise.all( + batch.map(async (pausedUser: any) => { + const featureId = mapPauseReasonToFeatureId(pausedUser.pauseReason); + + try { + const user = await ctx.runQuery(internal.users.get, { + userId: pausedUser.userId, + }); + + if (!user?.clerk_id) { + return { + userId: pausedUser.userId, + pauseReason: pausedUser.pauseReason, + resourceChecked: featureId, + resourceAvailable: false, + unpauseAttempted: false, + unpauseSuccess: false, + message: "User not found or missing Clerk ID", + }; + } + + const data = { allowed: true }; + + const resourceAvailable = data?.allowed === true; + + if (resourceAvailable) { + console.log( + `[Cron] Resource ${featureId} is available for user ${pausedUser.userId}, unpausing...`, + ); + + const unpauseResult = await ctx.runAction( + internal.deployment_management.unpauseUserDeployments, + { userId: pausedUser.userId }, + ); + + return { + userId: pausedUser.userId, + pauseReason: pausedUser.pauseReason, + resourceChecked: featureId, + resourceAvailable: true, + unpauseAttempted: true, + unpauseSuccess: unpauseResult.success, + message: unpauseResult.message, + }; + } + + return { + userId: pausedUser.userId, + pauseReason: pausedUser.pauseReason, + resourceChecked: featureId, + resourceAvailable: false, + unpauseAttempted: false, + unpauseSuccess: false, + message: `Resource ${featureId} still depleted`, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[Cron] Unexpected error processing user ${pausedUser.userId}:`, + errorMessage, + ); + return { + userId: pausedUser.userId, + pauseReason: pausedUser.pauseReason, + resourceChecked: featureId, + resourceAvailable: false, + unpauseAttempted: false, + unpauseSuccess: false, + message: `Unexpected error: ${errorMessage}`, + }; + } + }), + ); + + results.push(...batchResults); + } + + const unpausedCount = results.filter((r) => r.unpauseSuccess).length; + const stillPausedCount = results.filter((r) => !r.resourceAvailable).length; + const errorCount = results.filter( + (r) => r.unpauseAttempted && !r.unpauseSuccess, + ).length; + + console.log( + `[Cron] Unpause check complete: ${unpausedCount} unpaused, ${stillPausedCount} still paused, ${errorCount} errors`, + ); + + return { + totalChecked: autoUnpausableUsers.length, + unpausedCount, + stillPausedCount, + errorCount, + results, + }; + }, +}); + +/** + * Pause current user's deployments (public action) + */ +export const pauseCurrentUserDeployments = action({ + args: { + pauseReason: v.optional(PAUSE_REASON_VALIDATOR), + }, + handler: async ( + ctx, + args, + ): Promise<{ + totalDeployments: number; + successCount: number; + failureCount: number; + results: Array<{ + projectId: Id<"project">; + projectName: string; + deploymentName: string; + type: "dev" | "prod"; + success: boolean; + message: string; + }>; + }> => { + // Get authenticated user + const user = await getAuthUser(ctx); + + if (!user) { + throw new Error("Not authenticated"); + } + + // Pause all user deployments with specified reason (default to manual_admin) + const result: { + totalDeployments: number; + successCount: number; + failureCount: number; + results: Array<{ + projectId: Id<"project">; + projectName: string; + deploymentName: string; + type: "dev" | "prod"; + success: boolean; + message: string; + }>; + } = await ctx.runAction( + internal.deployment_management.pauseAllUserDeployments, + { + userId: user._id, + pauseReason: args.pauseReason || "manual_admin", + autoUnpauseEnabled: args.pauseReason !== "manual_admin", + pausedBy: user._id, + }, + ); + + return result; + }, +}); + +/** + * Unpause current user's deployments (public action) + * This is for MANUAL unpausing (e.g., from devtools or after adding credits) + * It bypasses the autoUnpauseEnabled check since the user is explicitly requesting unpause + */ +export const unpauseCurrentUserDeployments = action({ + args: {}, + handler: async (ctx): Promise => { + // Get authenticated user + const user = await getAuthUser(ctx); + + if (!user) { + throw new Error("Not authenticated"); + } + + console.log( + `Manual unpause requested for user ${user._id} (${user.email})`, + ); + + // Check if user has an active pause record + const pauseRecord = await ctx.runQuery( + internal.deployment_queries.getUserPauseStatusInternal, + { + userId: user._id, + }, + ); + + if (!pauseRecord) { + console.log(`No active pause record found for user ${user._id}`); + return { + success: false, + message: "No active pause found for this user", + unpaused: false, + }; + } + + // Check all resources before allowing manual unpause + const resourceCheck = await ctx.runAction( + internal.deployment_management.checkAllConvexResources, + { userId: user._id }, + ); + + if (!resourceCheck.allAvailable) { + const depletedList = resourceCheck.depletedResources + .map((r: any) => r.featureId) + .join(", "); + console.log( + `Cannot manually unpause user ${user._id}: resources still depleted: ${depletedList}`, + ); + return { + success: false, + message: `Cannot unpause: ${depletedList} still depleted`, + unpaused: false, + depletedResources: resourceCheck.depletedResources, + }; + } + + // For manual unpause, deactivate the pause record regardless of autoUnpauseEnabled + console.log( + `Deactivating pause record for user ${user._id} (was: ${pauseRecord.pauseReason}, autoUnpause: ${pauseRecord.autoUnpauseEnabled})`, + ); + + await ctx.runMutation(internal.paused_users.deactivatePauseRecord, { + pauseId: pauseRecord._id, + }); + + // Unpause all user deployments + const result = await ctx.runAction( + internal.deployment_management.unpauseAllUserDeployments, + { userId: user._id }, + ); + + console.log( + `Manual unpause complete: ${result.successCount} deployments unpaused for user ${user._id}`, + ); + + return { + success: true, + message: `Successfully unpaused ${result.successCount} deployments`, + unpaused: true, + ...result, + }; + }, +}); diff --git a/freebuff/web/convex/deployment_queries.ts b/freebuff/web/convex/deployment_queries.ts new file mode 100644 index 0000000000..585166d882 --- /dev/null +++ b/freebuff/web/convex/deployment_queries.ts @@ -0,0 +1,323 @@ +import { v } from 'convex/values' +import { internalMutation, internalQuery, query } from './_generated/server' +import { internal } from './_generated/api' +import { Doc } from './_generated/dataModel' +import { PAUSE_REASON_VALIDATOR } from './constants' +import { pausedProjectsByActive } from './aggregates/admin_aggregates' +import { getAuthUser } from './users' + +/** + * Get all Convex deployments for a user + */ +export const getUserDeployments = internalQuery({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + // Get all project memberships for the user + const projectMembers = await ctx.db + .query('project_member') + .withIndex('by_user', (q) => q.eq('user', args.userId)) + .collect() + + // Get deployment info for each project + const deployments = [] + + for (const pm of projectMembers) { + // Only include projects where user is the owner + if (pm.project_role !== 'owner') { + continue + } + + const project = await ctx.db.get(pm.project) + + // Skip deleted projects + if (!project || project.deleted) { + continue + } + + // Get the convex instance for this project + const convexInstance = await ctx.db + .query('project_convex_instance') + .withIndex('by_project', (q) => q.eq('projectId', pm.project)) + .first() + + if (convexInstance) { + deployments.push({ + projectId: pm.project, + projectName: project.name || project.semantic_identifier, + devDeploymentName: convexInstance.devDeploymentName, + prodDeploymentName: convexInstance.prodDeploymentName, + convexProjectId: convexInstance.convexProjectId, + }) + } + } + + return deployments + }, +}) + +/** + * Get current user's pause status (internal cacheable version) + * Accepts userId and clerk_id to enable Convex caching + */ +export const getCurrentUserPauseStatusInternal = internalQuery({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + // Billing is disabled during the Freebuff migration, so billing-related + // pause records should not block users from interacting with projects. + return null + + // Get active pause record + const pauseRecord = await ctx.db + .query('paused_users') + .withIndex('by_user_and_active', (q) => + q.eq('userId', args.userId).eq('active', true), + ) + .first() + + return pauseRecord + }, +}) + +/** + * Get current user's pause status (authenticated) + */ +export const getCurrentUserPauseStatus = query({ + args: {}, + handler: async (ctx): Promise | null> => { + const user = await getAuthUser(ctx) + + if (!user) { + return null + } + + // Delegate to internal cached version + return await ctx.runQuery( + internal.deployment_queries.getCurrentUserPauseStatusInternal, + { + userId: user._id, + }, + ) + }, +}) + +/** + * Get current pause status for a user (internal) + */ +export const getUserPauseStatusInternal = internalQuery({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + // Billing is disabled during the Freebuff migration, so project pause + // records should not render billing paywalls or overlays. + return null + + const pauseRecord = await ctx.db + .query('paused_users') + .withIndex('by_user_and_active', (q) => + q.eq('userId', args.userId).eq('active', true), + ) + .first() + + return pauseRecord + }, +}) + +/** + * Get all currently paused users (admin only) + */ +export const getPausedUsers = internalQuery({ + args: {}, + handler: async (ctx) => { + // Query all paused_users and filter in memory since we can't use partial index + const allPausedUsers = await ctx.db.query('paused_users').collect() + const activePausedUsers = allPausedUsers.filter((p) => p.active === true) + + return activePausedUsers + }, +}) + +/** + * Debug query to see all user projects and their Convex instance status + */ +export const debugUserProjects = internalQuery({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + const projectMembers = await ctx.db + .query('project_member') + .withIndex('by_user', (q) => q.eq('user', args.userId)) + .collect() + + const projectsInfo = [] + + for (const pm of projectMembers) { + const project = await ctx.db.get(pm.project) + + if (!project) { + continue + } + + const convexInstance = await ctx.db + .query('project_convex_instance') + .withIndex('by_project', (q) => q.eq('projectId', pm.project)) + .first() + + projectsInfo.push({ + projectId: pm.project, + projectName: project.name || project.semantic_identifier, + projectRole: pm.project_role, + deleted: project.deleted, + hasConvexInstance: !!convexInstance, + devDeploymentName: convexInstance?.devDeploymentName || null, + prodDeploymentName: convexInstance?.prodDeploymentName || null, + }) + } + + return projectsInfo + }, +}) + +/** + * Create a pause record for a project + */ +export const createProjectPauseRecord = internalMutation({ + args: { + projectId: v.id('project'), + pauseReason: PAUSE_REASON_VALIDATOR, + pausedBy: v.id('users'), + }, + handler: async (ctx, args) => { + // Check if there's already an active pause record + const existingPause = await ctx.db + .query('paused_projects') + .withIndex('by_project_and_active', (q) => + q.eq('projectId', args.projectId).eq('active', true), + ) + .first() + + if (existingPause) { + // Get old record before updating + const oldRecord = { ...existingPause } + + // Update existing record + await ctx.db.patch(existingPause._id, { + pauseReason: args.pauseReason, + pausedAt: Date.now(), + pausedBy: args.pausedBy, + active: true, + unpausedAt: undefined, + }) + + // Update aggregates + const newRecord = await ctx.db.get(existingPause._id) + if (newRecord) { + await pausedProjectsByActive.replace(ctx, oldRecord, newRecord) + } + + return existingPause._id + } + + // Create new pause record + const pauseId = await ctx.db.insert('paused_projects', { + projectId: args.projectId, + pauseReason: args.pauseReason, + pausedAt: Date.now(), + pausedBy: args.pausedBy, + active: true, + }) + + // Update aggregates for new paused project + const newPausedProject = await ctx.db.get(pauseId) + if (newPausedProject) { + await pausedProjectsByActive.insert(ctx, newPausedProject) + } + + return pauseId + }, +}) + +/** + * Mark a project pause record as unpaused + */ +export const unpauseProjectRecord = internalMutation({ + args: { + projectId: v.id('project'), + }, + handler: async (ctx, args) => { + // Find active pause record + const pauseRecord = await ctx.db + .query('paused_projects') + .withIndex('by_project_and_active', (q) => + q.eq('projectId', args.projectId).eq('active', true), + ) + .first() + + if (!pauseRecord) { + return null + } + + // Get old record before updating + const oldRecord = { ...pauseRecord } + + // Mark as unpaused + await ctx.db.patch(pauseRecord._id, { + active: false, + unpausedAt: Date.now(), + }) + + // Update aggregates + const newRecord = await ctx.db.get(pauseRecord._id) + if (newRecord) { + await pausedProjectsByActive.replace(ctx, oldRecord, newRecord) + } + + return pauseRecord._id + }, +}) + +/** + * Get pause status for a project (internal) + */ +export const getProjectPauseStatusInternal = internalQuery({ + args: { + projectId: v.id('project'), + }, + handler: async (ctx, args) => { + const pauseRecord = await ctx.db + .query('paused_projects') + .withIndex('by_project_and_active', (q) => + q.eq('projectId', args.projectId).eq('active', true), + ) + .first() + + return pauseRecord + }, +}) + +/** + * Get pause status for a project (public, authenticated) + */ +export const getProjectPauseStatus = query({ + args: { + projectId: v.id('project'), + }, + handler: async (ctx, args): Promise | null> => { + const identity = await ctx.auth.getUserIdentity() + if (!identity) return null + + const pauseRecord = await ctx.db + .query('paused_projects') + .withIndex('by_project_and_active', (q) => + q.eq('projectId', args.projectId).eq('active', true), + ) + .first() + + return pauseRecord + }, +}) diff --git a/freebuff/web/convex/domains.ts b/freebuff/web/convex/domains.ts new file mode 100644 index 0000000000..6b7022a9df --- /dev/null +++ b/freebuff/web/convex/domains.ts @@ -0,0 +1,899 @@ +import { api, internal } from "!/_generated/api"; +import { + action, + internalAction, + internalMutation, + internalQuery, + query, +} from "!/_generated/server"; +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { getRootDomain } from "../lib/utils"; +import { + requireFeatureAccess, + BOOLEAN_FEATURE_IDS, +} from "./lib/featureAccessControl"; + +type SuccessOrError = + | { + success: true; + } + | { + success: false; + message: string; + }; + +type DnsAnswer = { + name: string; + type: number; + TTL: number; + data: string; +}; + +type DnsResponse = { + Answer?: DnsAnswer[]; +}; + +const DNS_RECORD_TYPES = { + A: 1, + CNAME: 5, +} as const; + +const DNS_CONFIG = { + A_RECORD_IP: "76.76.21.21", + CNAME_TARGETS: [ + "cname.vercel-dns.com.", + "cname.vercel-dns.com", + ] as readonly string[], +} as const; + +export const create = internalMutation({ + args: { + domain: v.string(), + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + + if (!user) { + throw new Error("User not found"); + } + + const domainId = await ctx.db.insert("domain", { + domain: args.domain.toLowerCase(), + ownership_verified: false, + wildcard_cert_generated: true, + pointing_verified: false, + owner: user._id, + rootDomain: getRootDomain(args.domain), + }); + + await ctx.db.insert("project_domain", { + projectId: args.projectId, + domainId: domainId, + }); + + return domainId; + }, +}); + +export const getDomainDetails = query({ + args: { + domain: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("domain") + .withIndex("by_domain", (q) => q.eq("domain", args.domain)) + .first(); + }, +}); + +export const setVerificationCode = internalMutation({ + args: { + domainId: v.id("domain"), + verificationCode: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.domainId, { + ownershipVerificationCode: args.verificationCode, + }); + }, +}); + +async function initializeDomainVerification( + ctx: any, + domainId: any, + domain: string, + vercelProjectId: string, +): Promise { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains?teamId=${teamId}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${vercelToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: domain }), + }, + ); + + const data = await response.json(); + + if (data.verified) { + await ctx.runMutation(internal.domains.setDomainState, { + domainId, + ownershipVerified: true, + }); + } else if (data.verification) { + const txtRecord = data.verification.find( + (v: { type: string }) => v.type === "TXT", + ); + if (txtRecord) { + await ctx.runMutation(internal.domains.setVerificationCode, { + domainId, + verificationCode: txtRecord.value, + }); + } + } +} + +export const listAllWithRoot = internalQuery({ + args: { + rootDomain: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("domain") + .withIndex("by_rootDomain", (q) => q.eq("rootDomain", args.rootDomain)) + .collect(); + }, +}); + +export const registerDomainAndGetVerificationCode = action({ + args: { + projectId: v.id("project"), + domain: v.string(), + }, + handler: async (ctx, args): Promise => { + // Resilient server-side check: SDK + REST API fallback for infrastructure-cost protection + const featureResult = await requireFeatureAccess( + ctx, + BOOLEAN_FEATURE_IDS.CUSTOM_DOMAINS, + ); + if (!featureResult.success) { + throw new Error(featureResult.message); + } + + const existingDomain = await ctx.runQuery(api.domains.getDomainDetails, { + domain: args.domain, + }); + + if (existingDomain) { + throw new Error("Domain already exists"); + } + + const domainId = await ctx.runMutation(internal.domains.create, { + domain: args.domain, + projectId: args.projectId, + }); + + // Get the Vercel project ID from the latest active deployment + const latestDeployment = await ctx.runQuery( + api.deployment.getLatestActiveDeployment, + { projectId: args.projectId }, + ); + const vercelProjectId = latestDeployment?.freestyleDeploymentId; + + if (vercelProjectId) { + await initializeDomainVerification( + ctx, + domainId, + args.domain, + vercelProjectId, + ); + } + + await ctx.runAction(api.domains.verifyAll, { domain: args.domain }); + }, +}); + +function buildDomainStateFlags(args: { + ownershipVerified?: boolean; + wildcardCertGenerated?: boolean; + pointingVerified?: boolean; +}) { + return Object.fromEntries( + Object.entries({ + ownership_verified: args.ownershipVerified, + wildcard_cert_generated: args.wildcardCertGenerated, + pointing_verified: args.pointingVerified, + }).filter(([_key, value]) => value !== undefined), + ); +} + +export const setDomainState = internalMutation({ + args: { + domainId: v.id("domain"), + ownershipVerified: v.optional(v.boolean()), + wildcardCertGenerated: v.optional(v.boolean()), + pointingVerified: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const flags = buildDomainStateFlags({ + ownershipVerified: args.ownershipVerified, + wildcardCertGenerated: args.wildcardCertGenerated, + pointingVerified: args.pointingVerified, + }); + + await ctx.db.patch(args.domainId, flags); + }, +}); + +export const checkVerification = action({ + args: { + domain: v.string(), + }, + handler: async (ctx, args): Promise => { + const domainDetails = await ctx.runQuery(api.domains.getDomainDetails, { + domain: args.domain, + }); + + if (!domainDetails) { + throw new Error("Domain not found"); + } + + const projectId = await ctx.runQuery( + internal.domains.getProjectIdFromDomain, + { domainId: domainDetails._id }, + ); + + if (!projectId) { + return { success: false, message: "Domain not linked to a project" }; + } + + const latestDeployment = await ctx.runQuery( + api.deployment.getLatestActiveDeployment, + { projectId }, + ); + + const vercelProjectId = latestDeployment?.freestyleDeploymentId; + + if (!vercelProjectId) { + return { success: false, message: "No Vercel project found" }; + } + + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + try { + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains/${encodeURIComponent(args.domain)}/verify?teamId=${teamId}`, + { + method: "POST", + headers: { Authorization: `Bearer ${vercelToken}` }, + }, + ); + + const data = await response.json(); + + if (data.verified) { + return { success: true }; + } else { + return { + success: false, + message: "Domain not yet verified. Please check your DNS records.", + }; + } + } catch (err) { + console.error("Failed to verify domain"); + throw err; + } + }, +}); + +export const generateCert = action({ + args: { + domain: v.string(), + }, + handler: async ( + _ctx, + _args, + ): Promise<{ success: true } | { success: false; message: string }> => { + // Vercel auto-provisions SSL certificates — no manual cert generation needed + return { success: true }; + }, +}); + +async function fetchDnsRecords(domain: string): Promise { + const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(domain)}`; + const options = { + method: "GET", + headers: { Accept: "application/dns-json" }, + }; + + const result = await fetch(url, options); + if (result.status !== 200) { + console.error("Failed to fetch DNS records", await result.text()); + throw new Error("Failed to fetch DNS records"); + } + + return await result.json(); +} + +function checkDnsRecords(domain: string, dnsAnswers: DnsAnswer[]): boolean { + const isRootDomain = getRootDomain(domain) === domain; + + if (isRootDomain) { + return dnsAnswers.some( + (answer) => + answer.data === DNS_CONFIG.A_RECORD_IP && + answer.type === DNS_RECORD_TYPES.A, + ); + } else { + return dnsAnswers.some( + (answer) => + DNS_CONFIG.CNAME_TARGETS.includes(answer.data) && + answer.type === DNS_RECORD_TYPES.CNAME, + ); + } +} + +async function getVercelDomainConfigErrors( + vercelProjectId: string, + domain: string, +): Promise { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + try { + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains/${encodeURIComponent(domain)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + + if (!response.ok) return null; + + const data = await response.json(); + + if (!data.misconfigured) return null; + + // Vercel returns conflicts and required records in the config response + // Build a user-friendly message + const parts: string[] = []; + + if (data.conflicts && data.conflicts.length > 0) { + const conflictList = data.conflicts + .map( + (c: { type: string; name: string; value: string }) => + `${c.type} record "${c.name}" → ${c.value}`, + ) + .join(", "); + parts.push(`Remove conflicting DNS records: ${conflictList}`); + } + + if (data.intended) { + parts.push( + `Set ${data.intended.type} record for "${data.intended.name || "@"}" → ${data.intended.value}`, + ); + } + + return parts.length > 0 + ? parts.join(". ") + "." + : "Domain is misconfigured. Check your DNS settings in your domain provider."; + } catch { + return null; + } +} + +export const verifyPointing = internalAction({ + args: { + domain: v.string(), + }, + handler: async (_ctx, args) => { + try { + const response = await fetchDnsRecords(args.domain); + console.log("DNS response", response); + + if (!response.Answer) { + return { + success: false, + message: "No DNS answer found", + }; + } + + const pointingCorrectly = checkDnsRecords(args.domain, response.Answer); + + return { + success: pointingCorrectly, + ...(pointingCorrectly + ? {} + : { message: "DNS pointing is not correctly configured" }), + }; + } catch (error) { + console.error("DNS verification error:", error); + return { + success: false, + message: "Failed to verify DNS pointing", + }; + } + }, +}); + +export const getProjectIdFromDomain = internalQuery({ + args: { + domainId: v.id("domain"), + }, + handler: async (ctx, args) => { + const domainProject = await ctx.db + .query("project_domain") + .withIndex("by_domain", (q) => q.eq("domainId", args.domainId)) + .first(); + + return domainProject?.projectId; + }, +}); + +export const deleteDomain = action({ + args: { + domain: v.string(), + }, + handler: async (ctx, args): Promise => { + // Feature access is enforced client-side via useFeatureAccess hook. + // Server-side autumn.check() was incorrectly blocking paying users + // due to Autumn API sync issues, so the hard gate was removed here. + + try { + const domainDetails = await ctx.runQuery(api.domains.getDomainDetails, { + domain: args.domain, + }); + + if (!domainDetails) { + return { + success: false, + message: "Domain not found", + }; + } + + // Check if domain is verified + const isFullyVerified = + domainDetails.ownership_verified && domainDetails.pointing_verified; + + // If verified, remove from Vercel first + if (isFullyVerified) { + console.log( + `[DEBUG] Removing verified domain ${args.domain} from Vercel`, + ); + + try { + const projectId = await ctx.runQuery( + internal.domains.getProjectIdFromDomain, + { domainId: domainDetails._id }, + ); + + if (projectId) { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + const latestDeployment = await ctx.runQuery( + api.deployment.getLatestActiveDeployment, + { projectId }, + ); + + let vercelProjectId = + latestDeployment?.freestyleDeploymentId || null; + + // Verify stored ID, fall back to slug lookup + if (vercelProjectId) { + const checkResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(vercelProjectId)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (!checkResponse.ok) vercelProjectId = null; + } + + if (!vercelProjectId) { + const project: any = await ctx.runQuery( + internal.project.getProject, + { projectId }, + ); + if (project?.prod_deployment_slug) { + const lookupResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(project.prod_deployment_slug)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (lookupResponse.ok) { + const data = await lookupResponse.json(); + vercelProjectId = data.id; + } + } + } + + if (vercelProjectId) { + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains/${encodeURIComponent(args.domain.toLowerCase())}?teamId=${teamId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${vercelToken}`, + }, + }, + ); + + console.log( + `[DEBUG] Vercel domain removal status: ${response.status}`, + ); + + if (!response.ok && response.status !== 404) { + const text = await response.text(); + console.error( + `[DEBUG] Failed to remove domain. Status: ${response.status}, Response: ${text}`, + ); + return { + success: false, + message: `Failed to remove domain from Vercel: ${text || "Unknown error"}`, + }; + } + } + } + } catch (error) { + console.error("Failed to call Vercel API:", error); + return { + success: false, + message: "Failed to remove domain from Vercel", + }; + } + } + + // Delete from database + await ctx.runMutation(internal.domains.removeDomain, { + domainId: domainDetails._id, + }); + + return { success: true }; + } catch (error) { + console.error("Failed to delete domain:", error); + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to delete domain", + }; + } + }, +}); + +export const removeDomain = internalMutation({ + args: { + domainId: v.id("domain"), + }, + handler: async (ctx, args) => { + // Delete the project_domain relationship first + const projectDomain = await ctx.db + .query("project_domain") + .withIndex("by_domain", (q) => q.eq("domainId", args.domainId)) + .first(); + + if (projectDomain) { + await ctx.db.delete(projectDomain._id); + } + + // Delete the domain itself + await ctx.db.delete(args.domainId); + }, +}); +export const pointDomainToDeployment = action({ + args: { + domain: v.string(), + freestyleDeploymentId: v.string(), + }, + handler: async (ctx, args): Promise => { + console.log( + "[DEBUG] Pointing domain", + args.domain, + "to Vercel project", + args.freestyleDeploymentId, + ); + + try { + const vercelToken = process.env.VERCEL_API_TOKEN; + const teamId = process.env.VERCEL_TEAM_ID; + + // Resolve Vercel project ID: verify stored ID, fall back to slug lookup + let vercelProjectId: string | null = args.freestyleDeploymentId; + + const checkResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(vercelProjectId)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + + if (!checkResponse.ok) { + console.log( + `[DEBUG] Stored ID ${vercelProjectId} not found on Vercel, looking up by domain`, + ); + vercelProjectId = null; + + // Find project slug from domain table → project → prod_deployment_slug + const domainDetails = await ctx.runQuery(api.domains.getDomainDetails, { + domain: args.domain, + }); + if (domainDetails) { + const projectId = await ctx.runQuery( + internal.domains.getProjectIdFromDomain, + { domainId: domainDetails._id }, + ); + if (projectId) { + const project: any = await ctx.runQuery( + internal.project.getProject, + { projectId }, + ); + if (project?.prod_deployment_slug) { + const lookupResponse = await fetch( + `https://api.vercel.com/v9/projects/${encodeURIComponent(project.prod_deployment_slug)}?teamId=${teamId}`, + { headers: { Authorization: `Bearer ${vercelToken}` } }, + ); + if (lookupResponse.ok) { + const projectData = await lookupResponse.json(); + vercelProjectId = projectData.id; + console.log( + `[DEBUG] Found Vercel project by slug ${project.prod_deployment_slug}: ${vercelProjectId}`, + ); + } + } + } + } + } + + if (!vercelProjectId) { + return { + success: false, + message: "Could not find Vercel project for this deployment", + }; + } + + const response = await fetch( + `https://api.vercel.com/v10/projects/${vercelProjectId}/domains?teamId=${teamId}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${vercelToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name: args.domain }), + }, + ); + + if (response.ok || response.status === 409) { + console.log("[DEBUG] Successfully pointed domain to Vercel project"); + return { success: true }; + } + + const text = await response.text(); + console.error( + "[DEBUG] Failed to point domain. Status:", + response.status, + "Response:", + text, + ); + + return { + success: false, + message: text || "Failed to point domain to deployment", + }; + } catch (error) { + console.error("[DEBUG] Error in pointDomainToDeployment:", error); + return { + success: false, + message: + error instanceof Error ? error.message : "Unknown error occurred", + }; + } + }, +}); + +async function verifyOwnership( + ctx: any, + domain: string, + domainId: any, +): Promise { + try { + const verificationResult = await ctx.runAction( + api.domains.checkVerification, + { domain }, + ); + + if (verificationResult.success) { + await ctx.runMutation(internal.domains.setDomainState, { + domainId, + ownershipVerified: true, + }); + return true; + } + + return false; + } catch (error) { + console.error("Ownership verification failed:", error); + return false; + } +} + +async function generateWildcardCert( + ctx: any, + _domain: string, + domainId: any, +): Promise { + // Vercel auto-provisions SSL — always mark as generated + await ctx.runMutation(internal.domains.setDomainState, { + domainId, + wildcardCertGenerated: true, + }); + return true; +} + +async function verifyDnsPointing( + ctx: any, + domain: string, + domainId: any, +): Promise { + try { + const result = await ctx.runAction(internal.domains.verifyPointing, { + domain, + }); + + if (result.success) { + await ctx.runMutation(internal.domains.setDomainState, { + domainId, + pointingVerified: true, + }); + return true; + } + + return false; + } catch (error) { + console.error("Pointing verification failed:", error); + return false; + } +} + +async function pointToLatestDeployment( + ctx: any, + domain: string, + domainId: any, +): Promise { + const projectId = await ctx.runQuery( + internal.domains.getProjectIdFromDomain, + { domainId }, + ); + + if (!projectId) { + return { + success: false, + message: "Domain not found in any project", + }; + } + + const latestDeployment = await ctx.runQuery( + api.deployment.getLatestActiveDeployment, + { projectId }, + ); + + console.log("latestDeployment", latestDeployment); + + if (!latestDeployment?.freestyleDeploymentId) { + return { + success: false, + message: "No active deployment found", + }; + } + + await ctx.runAction(api.domains.pointDomainToDeployment, { + domain, + freestyleDeploymentId: latestDeployment.freestyleDeploymentId, // now stores Vercel project ID + }); + + console.log("Successfully pointed domain to deployment"); + return { success: true }; +} + +export const verifyAll = action({ + args: { + domain: v.string(), + }, + handler: async (ctx, args): Promise => { + try { + const domainDetails = await ctx.runQuery(api.domains.getDomainDetails, { + domain: args.domain, + }); + + if (!domainDetails) { + return { + success: false, + message: "Domain not found", + }; + } + + // Check if all verifications are already complete + // Vercel handles SSL automatically, so wildcard_cert is always true + const isAlreadyVerified = + domainDetails.ownership_verified && domainDetails.pointing_verified; + + if (isAlreadyVerified) { + console.log( + "Domain already fully verified, pointing to latest deployment", + ); + return await pointToLatestDeployment( + ctx, + args.domain, + domainDetails._id, + ); + } + + // Perform verification checks only for incomplete steps + const verificationResults = { + ownership: + domainDetails.ownership_verified || + (await verifyOwnership(ctx, args.domain, domainDetails._id)), + wildcardCert: + domainDetails.wildcard_cert_generated || + (await generateWildcardCert(ctx, args.domain, domainDetails._id)), + dnsPointing: + domainDetails.pointing_verified || + (await verifyDnsPointing(ctx, args.domain, domainDetails._id)), + }; + + const allVerified = Object.values(verificationResults).every( + (result) => result === true, + ); + + if (!allVerified) { + const failedSteps = Object.entries(verificationResults) + .filter(([_step, success]) => !success) + .map(([step]) => step); + + // Try to get specific DNS config errors from Vercel + let configError: string | null = null; + try { + const projectId = await ctx.runQuery( + internal.domains.getProjectIdFromDomain, + { domainId: domainDetails._id }, + ); + if (projectId) { + const latestDeployment = await ctx.runQuery( + api.deployment.getLatestActiveDeployment, + { projectId }, + ); + const vercelProjId = latestDeployment?.freestyleDeploymentId; + if (vercelProjId) { + configError = await getVercelDomainConfigErrors( + vercelProjId, + args.domain, + ); + } + } + } catch { + // Best-effort — fall back to generic message + } + + return { + success: false, + message: + configError || + `Verification failed for: ${failedSteps.join(", ")}. Check your DNS records.`, + }; + } + + console.log("All verifications passed, updating domain pointers"); + return await pointToLatestDeployment(ctx, args.domain, domainDetails._id); + } catch (error) { + console.error("Domain verification failed:", error); + return { + success: false, + message: + error instanceof Error ? error.message : "Domain verification failed", + }; + } + }, +}); diff --git a/freebuff/web/convex/earn.ts b/freebuff/web/convex/earn.ts new file mode 100644 index 0000000000..821ff549fb --- /dev/null +++ b/freebuff/web/convex/earn.ts @@ -0,0 +1,1932 @@ +import { v } from "convex/values"; +import { + action, + internalAction, + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import { getAuthUser } from "./users"; +import { attachProduct, trackUsage } from "../lib/autumn-api"; + +const MILLION = 1_000_000; +const MAX_BOUNTY_LINKS = 10; +const MAX_EVIDENCE_LINKS = 12; +const MAX_TEXT_LENGTH = 8000; + +// Mapping from credit amounts to earn reward product IDs. +// These products are free add-ons defined in autumn/config.ts that grant +// credits when attached to a customer via the Autumn SDK. +const EARN_REWARD_PRODUCTS: Record = { + [1 * MILLION]: "earn_reward_1m", + [2 * MILLION]: "earn_reward_2m", + [4 * MILLION]: "earn_reward_4m", + [5 * MILLION]: "earn_reward_5m", + [10 * MILLION]: "earn_reward_10m", + [20 * MILLION]: "earn_reward_20m", + [30 * MILLION]: "earn_reward_30m", + [50 * MILLION]: "earn_reward_50m", + [70 * MILLION]: "earn_reward_70m", + [100 * MILLION]: "earn_reward_100m", +}; + +const EARN_REWARD_DENOMINATIONS = [ + 100 * MILLION, + 70 * MILLION, + 50 * MILLION, + 30 * MILLION, + 20 * MILLION, + 10 * MILLION, + 5 * MILLION, + 4 * MILLION, + 2 * MILLION, + 1 * MILLION, +]; + +const VALID_BOUNTY_REWARD_AMOUNTS = new Set(EARN_REWARD_DENOMINATIONS); + +function decomposeCreditsToProductIds(totalCredits: number): string[] { + const productIds: string[] = []; + let remaining = totalCredits; + + for (const denomination of EARN_REWARD_DENOMINATIONS) { + while (remaining >= denomination) { + const productId = EARN_REWARD_PRODUCTS[denomination]; + if (productId) { + productIds.push(productId); + remaining -= denomination; + } else { + break; + } + } + } + + if (remaining > 0) { + productIds.push(EARN_REWARD_PRODUCTS[1 * MILLION]); + } + + return productIds; +} + +const SPIN_REWARDS = [ + { label: "1M", credits: 1 * MILLION, weight: 0.2561 }, + { label: "2M", credits: 2 * MILLION, weight: 0.274 }, + { label: "4M", credits: 4 * MILLION, weight: 0.274 }, + { label: "10M", credits: 10 * MILLION, weight: 0.1941 }, + { label: "20M", credits: 20 * MILLION, weight: 0.001 }, + { label: "30M", credits: 30 * MILLION, weight: 0.0005 }, + { label: "70M", credits: 70 * MILLION, weight: 0.0002 }, + { label: "100M", credits: 100 * MILLION, weight: 0.0001 }, +] as const; + +const EXPECTED_SPIN_CREDITS = Math.round( + SPIN_REWARDS.reduce((total, reward) => { + return total + reward.credits * reward.weight; + }, 0), +); + +function requireUser(user: Doc<"users"> | null): Doc<"users"> { + if (!user) { + throw new Error("Not authenticated"); + } + return user; +} + +function requireAdmin(user: Doc<"users"> | null): Doc<"users"> { + if (!user || (user.role !== "god" && user.role !== "admin")) { + throw new Error("Unauthorized"); + } + return user; +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function normalizeLinks(links: string[] | undefined, max: number): string[] { + if (!links || links.length === 0) { + return []; + } + + const deduped = new Set(); + for (const rawLink of links) { + const link = rawLink.trim(); + if (!link) { + continue; + } + deduped.add(link.slice(0, 2000)); + if (deduped.size >= max) { + break; + } + } + + return Array.from(deduped); +} + +function pickSpinReward() { + const roll = Math.random(); + let cumulative = 0; + + for (let index = 0; index < SPIN_REWARDS.length; index += 1) { + const reward = SPIN_REWARDS[index]; + cumulative += reward.weight; + if (roll <= cumulative) { + return { index, reward }; + } + } + + return { index: 0, reward: SPIN_REWARDS[0] }; +} + +export const getSpinConfig = query({ + handler: async () => { + return { + expectedSpinCredits: EXPECTED_SPIN_CREDITS, + rewards: SPIN_REWARDS.map((reward, index) => ({ + index, + label: reward.label, + credits: reward.credits, + oddsPercent: Number((reward.weight * 100).toFixed(1)), + })), + legal: { + noPurchaseNecessary: true, + }, + }; + }, +}); + +export const generateUploadUrl = mutation({ + args: {}, + handler: async (ctx) => { + const user = requireUser(await getAuthUser(ctx)); + void user; + return await ctx.storage.generateUploadUrl(); + }, +}); + +export const getUserSpinSummary = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return null; + } + + const [spins, referralCodes] = await Promise.all([ + ctx.db + .query("referral_spins") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(), + ctx.db + .query("referral_codes") + .withIndex("by_owner", (q) => q.eq("owner", user._id)) + .collect(), + ]); + + const availableSpins = spins.filter((spin) => spin.status === "available"); + const awardedSpins = spins.filter((spin) => spin.status === "awarded"); + const referralCreditsEarned = awardedSpins.reduce((total, spin) => { + return total + (spin.awarded_credits ?? 0); + }, 0); + + const referralSpinCount = spins.filter( + (spin) => spin.source === "referral", + ).length; + + const primaryReferralCode = + referralCodes.find((code) => code.active) ?? referralCodes[0] ?? null; + + const referredUserIds = Array.from( + new Set( + spins + .map((spin) => spin.referred_user_id) + .filter((id): id is Id<"users"> => !!id), + ), + ); + const referredUsersById = new Map, Doc<"users">>(); + + await Promise.all( + referredUserIds.map(async (referredUserId) => { + const referredUser = await ctx.db.get(referredUserId); + if (referredUser) { + referredUsersById.set(referredUserId, referredUser); + } + }), + ); + + const spinHistory = [...spins] + .sort((a, b) => b.granted_at - a.granted_at) + .slice(0, 25) + .map((spin) => ({ + _id: spin._id, + source: spin.source, + status: spin.status, + rewardLabel: spin.reward_label, + awardedCredits: spin.awarded_credits ?? 0, + grantedAt: spin.granted_at, + spunAt: spin.spun_at, + awardedAt: spin.awarded_at, + referredUserId: spin.referred_user_id ?? null, + referredUserName: + (spin.referred_user_id + ? referredUsersById.get(spin.referred_user_id)?.name + : null) ?? null, + referredUserEmail: + (spin.referred_user_id + ? referredUsersById.get(spin.referred_user_id)?.email + : null) ?? null, + })); + + return { + availableSpins: availableSpins.length, + totalSpins: spins.length, + successfulSpins: awardedSpins.length, + referralSpinCount, + referralCreditsEarned, + referralCode: primaryReferralCode?.code ?? null, + spinHistory, + }; + }, +}); + +export const ensureWelcomeSpin = mutation({ + args: {}, + handler: async (ctx) => { + const user = requireUser(await getAuthUser(ctx)); + + const existingWelcomeSpin = await ctx.db + .query("referral_spins") + .withIndex("by_user_and_source", (q) => + q.eq("user", user._id).eq("source", "welcome"), + ) + .first(); + + if (existingWelcomeSpin) { + return { created: false, spinId: existingWelcomeSpin._id }; + } + + const spinId = await ctx.db.insert("referral_spins", { + user: user._id, + referred_user_id: undefined, + referred_user_email: undefined, + referral_code: undefined, + source: "welcome", + status: "available", + awarded_credits: undefined, + reward_label: undefined, + granted_at: Date.now(), + spun_at: undefined, + awarded_at: undefined, + revoked_at: undefined, + revoked_reason: undefined, + }); + + return { created: true, spinId }; + }, +}); + +export const adminGrantTestSpin = mutation({ + args: { + userId: v.optional(v.id("users")), + count: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + + // Use provided userId or default to admin's own id + const targetUserId = args.userId ?? admin._id; + + // Verify target user exists if granting to someone else + if (args.userId) { + const targetUser = await ctx.db.get(args.userId); + if (!targetUser) { + throw new Error("Target user not found"); + } + } + + const requestedCount = args.count ?? 1; + const count = Math.max(1, Math.min(Math.floor(requestedCount), 20)); + const now = Date.now(); + + for (let index = 0; index < count; index += 1) { + await ctx.db.insert("referral_spins", { + user: targetUserId, + referred_user_id: undefined, + referred_user_email: undefined, + referral_code: undefined, + source: "manual", + status: "available", + awarded_credits: undefined, + reward_label: undefined, + granted_at: now + index, + spun_at: undefined, + awarded_at: undefined, + revoked_at: undefined, + revoked_reason: undefined, + }); + } + + const availableSpins = await ctx.db + .query("referral_spins") + .withIndex("by_user_and_status", (q) => + q.eq("user", targetUserId).eq("status", "available"), + ) + .collect(); + + return { + created: count, + availableSpins: availableSpins.length, + }; + }, +}); + +export const getReferredUsers = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const referralCodes = await ctx.db + .query("referral_codes") + .withIndex("by_owner", (q) => q.eq("owner", user._id)) + .collect(); + + if (referralCodes.length === 0) { + return []; + } + + const referredUsersMap = new Map, Doc<"users">>(); + + for (const code of referralCodes) { + const referredUsers = await ctx.db + .query("users") + .withIndex("by_referral_code", (q) => q.eq("referral_code", code.code)) + .collect(); + + for (const referredUser of referredUsers) { + referredUsersMap.set(referredUser._id, referredUser); + } + } + + return Array.from(referredUsersMap.values()) + .sort((a, b) => b._creationTime - a._creationTime) + .map((referredUser) => ({ + _id: referredUser._id, + name: referredUser.name, + email: referredUser.email, + profileImage: referredUser.profile_image, + communityBadgeTier: referredUser.community_badge_tier ?? 0, + joinedAt: referredUser._creationTime, + referralCodeUsed: referredUser.referral_code ?? null, + })); + }, +}); + +export const spinWheel = mutation({ + args: {}, + handler: async (ctx) => { + const user = requireUser(await getAuthUser(ctx)); + + const availableSpin = await ctx.db + .query("referral_spins") + .withIndex("by_user_and_status", (q) => + q.eq("user", user._id).eq("status", "available"), + ) + .first(); + + if (!availableSpin) { + throw new Error("No spins available"); + } + + const { index, reward } = pickSpinReward(); + const now = Date.now(); + + await ctx.db.patch(availableSpin._id, { + status: "spinning", + spun_at: now, + awarded_credits: reward.credits, + reward_label: reward.label, + }); + + await ctx.scheduler.runAfter(0, internal.earn.processSpinCreditGrant, { + spinId: availableSpin._id, + }); + + const remainingSpins = await ctx.db + .query("referral_spins") + .withIndex("by_user_and_status", (q) => + q.eq("user", user._id).eq("status", "available"), + ) + .collect(); + + return { + spinId: availableSpin._id, + rewardIndex: index, + rewardLabel: reward.label, + rewardCredits: reward.credits, + remainingSpins: remainingSpins.length, + }; + }, +}); + +export const getBountiesForUser = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const [bounties, submissions] = await Promise.all([ + ctx.db + .query("bounties") + .withIndex("by_status", (q) => q.eq("status", "active")) + .collect(), + ctx.db + .query("bounty_submissions") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .collect(), + ]); + + const submissionByBounty = new Map< + Id<"bounties">, + Doc<"bounty_submissions"> + >(); + for (const submission of submissions) { + submissionByBounty.set(submission.bounty_id, submission); + } + + const sortedBounties = [...bounties].sort( + (a, b) => b.updated_at - a.updated_at, + ); + + return await Promise.all( + sortedBounties.map(async (bounty) => { + const submission = submissionByBounty.get(bounty._id); + + const [previewImageUrl, evidenceImageUrls] = await Promise.all([ + bounty.preview_image_storage_id + ? ctx.storage.getUrl(bounty.preview_image_storage_id) + : Promise.resolve(null), + submission + ? Promise.all( + submission.evidence_image_ids.map((storageId) => + ctx.storage.getUrl(storageId), + ), + ) + : Promise.resolve([]), + ]); + + const submissionStatus = + !submission || submission.status === "draft" + ? "incomplete" + : submission.status; + + return { + _id: bounty._id, + title: bounty.title, + description: bounty.description, + instructions: bounty.instructions, + evidenceRequirements: bounty.evidence_requirements, + links: bounty.links, + rewardCredits: bounty.reward_credits, + previewImageUrl: previewImageUrl ?? null, + createdAt: bounty.created_at, + updatedAt: bounty.updated_at, + submission: submission + ? { + _id: submission._id, + status: submissionStatus, + rawStatus: submission.status, + evidenceText: submission.evidence_text ?? "", + evidenceLinks: submission.evidence_links, + evidenceImageIds: submission.evidence_image_ids, + evidenceImageUrls: evidenceImageUrls.filter( + (imageUrl): imageUrl is string => !!imageUrl, + ), + adminReviewNote: submission.admin_review_note, + submittedAt: submission.submitted_at, + reviewedAt: submission.reviewed_at, + creditStatus: submission.credit_status, + awardedCredits: submission.credited_amount ?? 0, + } + : { + _id: null, + status: "incomplete", + rawStatus: "draft", + evidenceText: "", + evidenceLinks: [], + evidenceImageIds: [], + evidenceImageUrls: [], + adminReviewNote: null, + submittedAt: null, + reviewedAt: null, + creditStatus: "not_granted", + awardedCredits: 0, + }, + }; + }), + ); + }, +}); + +export const submitBountySubmission = mutation({ + args: { + bountyId: v.id("bounties"), + evidenceText: v.optional(v.string()), + evidenceLinks: v.optional(v.array(v.string())), + evidenceImageIds: v.optional(v.array(v.id("_storage"))), + }, + handler: async (ctx, args) => { + const user = requireUser(await getAuthUser(ctx)); + + const bounty = await ctx.db.get(args.bountyId); + if (!bounty || bounty.status !== "active") { + throw new Error("Bounty is unavailable"); + } + + const evidenceText = args.evidenceText?.trim().slice(0, MAX_TEXT_LENGTH); + const evidenceLinks = normalizeLinks( + args.evidenceLinks, + MAX_EVIDENCE_LINKS, + ); + const evidenceImageIds = args.evidenceImageIds ?? []; + + if ( + !evidenceText && + evidenceLinks.length === 0 && + evidenceImageIds.length === 0 + ) { + throw new Error("Please include text, links, or screenshots as evidence"); + } + + const existingSubmission = await ctx.db + .query("bounty_submissions") + .withIndex("by_user_and_bounty", (q) => + q.eq("user_id", user._id).eq("bounty_id", bounty._id), + ) + .first(); + + if (existingSubmission?.status === "pending") { + throw new Error("Your submission is already pending review"); + } + + if ( + existingSubmission?.status === "approved" && + (existingSubmission.credit_status === "granted" || + existingSubmission.credit_status === "grant_pending") + ) { + throw new Error("This bounty is already approved for your account"); + } + + const now = Date.now(); + + if (existingSubmission) { + await ctx.db.patch(existingSubmission._id, { + evidence_text: evidenceText, + evidence_links: evidenceLinks, + evidence_image_ids: evidenceImageIds, + status: "pending", + admin_review_note: undefined, + reviewed_by: undefined, + reviewed_at: undefined, + submitted_at: now, + credit_status: "not_granted", + credited_amount: undefined, + credit_awarded_at: undefined, + credit_revoked_at: undefined, + updated_at: now, + }); + + return { + submissionId: existingSubmission._id, + status: "pending", + }; + } + + const submissionId = await ctx.db.insert("bounty_submissions", { + bounty_id: bounty._id, + user_id: user._id, + evidence_text: evidenceText, + evidence_links: evidenceLinks, + evidence_image_ids: evidenceImageIds, + status: "pending", + admin_review_note: undefined, + reviewed_by: undefined, + submitted_at: now, + reviewed_at: undefined, + credit_status: "not_granted", + credited_amount: undefined, + credit_awarded_at: undefined, + credit_revoked_at: undefined, + created_at: now, + updated_at: now, + }); + + return { + submissionId, + status: "pending", + }; + }, +}); + +export const getEarnLeaderboard = query({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const viewer = await getAuthUser(ctx); + const limit = Math.max(1, Math.min(args.limit ?? 10, 5000)); + + const [referralCodes, awardedSpins, grantedSubmissions] = await Promise.all( + [ + ctx.db.query("referral_codes").collect(), + ctx.db + .query("referral_spins") + .withIndex("by_status", (q) => q.eq("status", "awarded")) + .collect(), + ctx.db + .query("bounty_submissions") + .withIndex("by_credit_status", (q) => + q.eq("credit_status", "granted"), + ) + .collect(), + ], + ); + + const referralCreditsByUser = new Map, number>(); + for (const spin of awardedSpins) { + referralCreditsByUser.set( + spin.user, + (referralCreditsByUser.get(spin.user) ?? 0) + + (spin.awarded_credits ?? 0), + ); + } + + const referralsCountByUser = new Map, number>(); + for (const code of referralCodes) { + if (!code.uses_count) { + continue; + } + + referralsCountByUser.set( + code.owner, + (referralsCountByUser.get(code.owner) ?? 0) + code.uses_count, + ); + } + + const bountyIdsNeedingLookup = Array.from( + new Set( + grantedSubmissions + .filter((submission) => submission.credited_amount === undefined) + .map((submission) => submission.bounty_id), + ), + ); + const lookedUpBounties = await Promise.all( + bountyIdsNeedingLookup.map((bountyId) => ctx.db.get(bountyId)), + ); + const bountyById = new Map( + lookedUpBounties + .filter((bounty): bounty is Doc<"bounties"> => !!bounty) + .map((bounty) => [bounty._id, bounty]), + ); + + const bountyCreditsByUser = new Map, number>(); + for (const submission of grantedSubmissions) { + const rewardCredits = + submission.credited_amount ?? + bountyById.get(submission.bounty_id)?.reward_credits ?? + 0; + + if (rewardCredits <= 0) { + continue; + } + + bountyCreditsByUser.set( + submission.user_id, + (bountyCreditsByUser.get(submission.user_id) ?? 0) + rewardCredits, + ); + } + + const participantIds = new Set>([ + ...referralCreditsByUser.keys(), + ...referralsCountByUser.keys(), + ...bountyCreditsByUser.keys(), + ]); + + const rankedRows = Array.from(participantIds) + .map((userId) => { + const bountyCredits = bountyCreditsByUser.get(userId) ?? 0; + const referralCredits = referralCreditsByUser.get(userId) ?? 0; + const referralsCount = referralsCountByUser.get(userId) ?? 0; + const totalCredits = bountyCredits + referralCredits; + + return { + userId, + bountyCredits, + referralCredits, + totalCredits, + referralsCount, + }; + }) + .filter((row) => row.totalCredits > 0 || row.referralsCount > 0) + .sort((a, b) => { + if (b.totalCredits !== a.totalCredits) { + return b.totalCredits - a.totalCredits; + } + if (b.referralsCount !== a.referralsCount) { + return b.referralsCount - a.referralsCount; + } + return a.userId.localeCompare(b.userId); + }); + + const hasMore = rankedRows.length > limit; + const topRows = rankedRows.slice(0, limit); + + const userIdsToFetch = Array.from( + new Set([ + ...topRows.map((row) => row.userId), + ...(viewer ? [viewer._id] : []), + ]), + ); + + const users = await Promise.all( + userIdsToFetch.map((userId) => ctx.db.get(userId)), + ); + const usersById = new Map( + users + .filter((user): user is Doc<"users"> => !!user) + .map((user) => [user._id, user]), + ); + + const communityProfiles = await Promise.all( + topRows.map((row) => + ctx.db + .query("community_profiles") + .withIndex("by_user", (q) => q.eq("userId", row.userId)) + .unique(), + ), + ); + const profilesByUserId = new Map( + communityProfiles + .filter((p): p is Doc<"community_profiles"> => !!p) + .map((p) => [p.userId, p]), + ); + + let viewerFollows: Set> | null = null; + if (viewer) { + const follows = await ctx.db + .query("community_follows") + .withIndex("by_follower", (q) => q.eq("followerId", viewer._id)) + .collect(); + viewerFollows = new Set(follows.map((f) => f.followingId)); + } + + const entries = topRows.map((row, index) => { + const user = usersById.get(row.userId); + const profile = profilesByUserId.get(row.userId); + return { + userId: row.userId, + rank: index + 1, + name: user?.name ?? "Unknown user", + profileImage: user?.profile_image ?? null, + communityBadgeTier: user?.community_badge_tier ?? 0, + bountyCredits: row.bountyCredits, + referralCredits: row.referralCredits, + totalCredits: row.totalCredits, + referralsCount: row.referralsCount, + followersCount: profile?.followersCount ?? 0, + isFollowing: viewerFollows?.has(row.userId) ?? false, + isViewer: viewer?._id === row.userId, + }; + }); + + let viewerPosition: (typeof entries)[number] | null = null; + if (viewer) { + const viewerRow = rankedRows.find((row) => row.userId === viewer._id); + if (viewerRow) { + const viewerProfile = profilesByUserId.get(viewer._id); + viewerPosition = { + userId: viewer._id, + rank: rankedRows.findIndex((row) => row.userId === viewer._id) + 1, + name: viewer.name, + profileImage: viewer.profile_image ?? null, + communityBadgeTier: viewer.community_badge_tier ?? 0, + bountyCredits: viewerRow.bountyCredits, + referralCredits: viewerRow.referralCredits, + totalCredits: viewerRow.totalCredits, + referralsCount: viewerRow.referralsCount, + followersCount: viewerProfile?.followersCount ?? 0, + isFollowing: false, + isViewer: true, + }; + } + } + + return { + entries, + hasMore, + viewerPosition, + }; + }, +}); + +export const getEarnCreditActivity = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const [spins, submissions] = await Promise.all([ + ctx.db + .query("referral_spins") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(), + ctx.db + .query("bounty_submissions") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .collect(), + ]); + + const bountyIds = Array.from( + new Set(submissions.map((submission) => submission.bounty_id)), + ); + const bounties = await Promise.all( + bountyIds.map((bountyId) => ctx.db.get(bountyId)), + ); + const bountyById = new Map( + bounties + .filter((bounty): bounty is Doc<"bounties"> => !!bounty) + .map((bounty) => [bounty._id, bounty]), + ); + + const events: Array<{ + id: string; + timestamp: number; + amount: number; + featureId: "agent_credits"; + source: "bounty" | "referral_spin"; + title: string; + subtitle: string; + }> = []; + + for (const submission of submissions) { + if ( + submission.credit_status !== "granted" || + !submission.credit_awarded_at + ) { + continue; + } + + const bounty = bountyById.get(submission.bounty_id); + const amount = submission.credited_amount ?? bounty?.reward_credits ?? 0; + + if (amount <= 0) { + continue; + } + + events.push({ + id: `bounty-${submission._id}`, + timestamp: submission.credit_awarded_at, + amount, + featureId: "agent_credits", + source: "bounty", + title: "Bounty Approved", + subtitle: bounty?.title ?? "Community bounty", + }); + } + + for (const spin of spins) { + if ( + spin.status !== "awarded" || + !spin.awarded_at || + !spin.awarded_credits + ) { + continue; + } + + events.push({ + id: `spin-${spin._id}`, + timestamp: spin.awarded_at, + amount: spin.awarded_credits, + featureId: "agent_credits", + source: "referral_spin", + title: "Referral Spin Award", + subtitle: + spin.source === "welcome" + ? "Welcome spin reward" + : "Referral spin reward", + }); + } + + return events.sort((a, b) => b.timestamp - a.timestamp).slice(0, 120); + }, +}); + +export const getAdminBounties = query({ + handler: async (ctx) => { + const admin = requireAdmin(await getAuthUser(ctx)); + void admin; + + const [bounties, submissions] = await Promise.all([ + ctx.db.query("bounties").collect(), + ctx.db.query("bounty_submissions").collect(), + ]); + + const statsByBounty = new Map< + Id<"bounties">, + { + pending: number; + approved: number; + rejected: number; + revoked: number; + } + >(); + + for (const submission of submissions) { + const current = statsByBounty.get(submission.bounty_id) ?? { + pending: 0, + approved: 0, + rejected: 0, + revoked: 0, + }; + + if (submission.status === "pending") { + current.pending += 1; + } else if (submission.status === "approved") { + current.approved += 1; + } else if (submission.status === "rejected") { + current.rejected += 1; + } else if (submission.status === "revoked") { + current.revoked += 1; + } + + statsByBounty.set(submission.bounty_id, current); + } + + const sortedBounties = [...bounties].sort( + (a, b) => b.created_at - a.created_at, + ); + + return await Promise.all( + sortedBounties.map(async (bounty) => { + const previewImageUrl = bounty.preview_image_storage_id + ? await ctx.storage.getUrl(bounty.preview_image_storage_id) + : null; + + return { + _id: bounty._id, + title: bounty.title, + description: bounty.description, + instructions: bounty.instructions, + evidenceRequirements: bounty.evidence_requirements, + links: bounty.links, + status: bounty.status, + rewardCredits: bounty.reward_credits, + previewImageUrl, + createdAt: bounty.created_at, + updatedAt: bounty.updated_at, + stats: statsByBounty.get(bounty._id) ?? { + pending: 0, + approved: 0, + rejected: 0, + revoked: 0, + }, + }; + }), + ); + }, +}); + +export const createBounty = mutation({ + args: { + title: v.string(), + description: v.string(), + instructions: v.string(), + evidenceRequirements: v.string(), + rewardCredits: v.number(), + links: v.optional(v.array(v.string())), + previewImageId: v.optional(v.id("_storage")), + status: v.optional( + v.union(v.literal("active"), v.literal("paused"), v.literal("archived")), + ), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + + const now = Date.now(); + const title = args.title.trim().slice(0, 140); + const description = args.description.trim().slice(0, MAX_TEXT_LENGTH); + const instructions = args.instructions.trim().slice(0, MAX_TEXT_LENGTH); + const evidenceRequirements = args.evidenceRequirements + .trim() + .slice(0, MAX_TEXT_LENGTH); + + if (!title || !description || !instructions || !evidenceRequirements) { + throw new Error( + "Title, description, instructions, and evidence are required", + ); + } + + if (args.rewardCredits <= 0) { + throw new Error("Reward credits must be greater than 0"); + } + + if (!VALID_BOUNTY_REWARD_AMOUNTS.has(args.rewardCredits)) { + throw new Error( + "Reward credits must be one of the preset amounts (1M, 2M, 4M, 5M, 10M, 20M, 30M, 50M, 70M, or 100M)", + ); + } + + const bountyId = await ctx.db.insert("bounties", { + title, + description, + instructions, + evidence_requirements: evidenceRequirements, + links: normalizeLinks(args.links, MAX_BOUNTY_LINKS), + reward_credits: Math.round(args.rewardCredits), + preview_image_storage_id: args.previewImageId, + status: args.status ?? "active", + created_by: admin._id, + updated_by: admin._id, + created_at: now, + updated_at: now, + archived_at: args.status === "archived" ? now : undefined, + }); + + return { bountyId }; + }, +}); + +export const updateBounty = mutation({ + args: { + bountyId: v.id("bounties"), + title: v.optional(v.string()), + description: v.optional(v.string()), + instructions: v.optional(v.string()), + evidenceRequirements: v.optional(v.string()), + rewardCredits: v.optional(v.number()), + links: v.optional(v.array(v.string())), + previewImageId: v.optional(v.union(v.id("_storage"), v.null())), + status: v.optional( + v.union(v.literal("active"), v.literal("paused"), v.literal("archived")), + ), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + + const bounty = await ctx.db.get(args.bountyId); + if (!bounty) { + throw new Error("Bounty not found"); + } + + const now = Date.now(); + + const patch: Partial> = { + updated_by: admin._id, + updated_at: now, + }; + + if (args.title !== undefined) { + patch.title = args.title.trim().slice(0, 140); + } + + if (args.description !== undefined) { + patch.description = args.description.trim().slice(0, MAX_TEXT_LENGTH); + } + + if (args.instructions !== undefined) { + patch.instructions = args.instructions.trim().slice(0, MAX_TEXT_LENGTH); + } + + if (args.evidenceRequirements !== undefined) { + patch.evidence_requirements = args.evidenceRequirements + .trim() + .slice(0, MAX_TEXT_LENGTH); + } + + if (args.rewardCredits !== undefined) { + if (args.rewardCredits <= 0) { + throw new Error("Reward credits must be greater than 0"); + } + if (!VALID_BOUNTY_REWARD_AMOUNTS.has(args.rewardCredits)) { + throw new Error( + "Reward credits must be one of the preset amounts (1M, 2M, 4M, 5M, 10M, 20M, 30M, 50M, 70M, or 100M)", + ); + } + patch.reward_credits = Math.round(args.rewardCredits); + } + + if (args.links !== undefined) { + patch.links = normalizeLinks(args.links, MAX_BOUNTY_LINKS); + } + + if (args.previewImageId !== undefined) { + patch.preview_image_storage_id = args.previewImageId ?? undefined; + } + + if (args.status !== undefined) { + patch.status = args.status; + patch.archived_at = args.status === "archived" ? now : undefined; + } + + await ctx.db.patch(args.bountyId, patch); + + return { success: true }; + }, +}); + +export const deleteBounty = mutation({ + args: { + bountyId: v.id("bounties"), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + + const bounty = await ctx.db.get(args.bountyId); + if (!bounty) { + throw new Error("Bounty not found"); + } + + await ctx.db.patch(args.bountyId, { + status: "archived", + archived_at: Date.now(), + updated_at: Date.now(), + updated_by: admin._id, + }); + + return { success: true }; + }, +}); + +export const getAdminBountySubmissions = query({ + args: { + status: v.optional( + v.union( + v.literal("pending"), + v.literal("approved"), + v.literal("rejected"), + v.literal("revoked"), + ), + ), + bountyId: v.optional(v.id("bounties")), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + void admin; + + const rawSubmissions = args.status + ? await ctx.db + .query("bounty_submissions") + .withIndex("by_status", (q) => q.eq("status", args.status!)) + .collect() + : await ctx.db.query("bounty_submissions").collect(); + + const submissions = args.bountyId + ? rawSubmissions.filter( + (submission) => submission.bounty_id === args.bountyId, + ) + : rawSubmissions; + + const sortedSubmissions = [...submissions].sort( + (a, b) => b.updated_at - a.updated_at, + ); + + return await Promise.all( + sortedSubmissions.map(async (submission) => { + const [user, bounty, evidenceImageUrls] = await Promise.all([ + ctx.db.get(submission.user_id), + ctx.db.get(submission.bounty_id), + Promise.all( + submission.evidence_image_ids.map((storageId) => + ctx.storage.getUrl(storageId), + ), + ), + ]); + + return { + _id: submission._id, + status: submission.status, + evidenceText: submission.evidence_text, + evidenceLinks: submission.evidence_links, + evidenceImageIds: submission.evidence_image_ids, + evidenceImageUrls: evidenceImageUrls.filter( + (imageUrl): imageUrl is string => !!imageUrl, + ), + adminReviewNote: submission.admin_review_note, + submittedAt: submission.submitted_at, + reviewedAt: submission.reviewed_at, + creditStatus: submission.credit_status, + awardedCredits: submission.credited_amount ?? 0, + user: user + ? { + _id: user._id, + name: user.name, + email: user.email, + profileImage: user.profile_image, + communityBadgeTier: user.community_badge_tier ?? 0, + } + : null, + bounty: bounty + ? { + _id: bounty._id, + title: bounty.title, + rewardCredits: bounty.reward_credits, + } + : null, + }; + }), + ); + }, +}); + +export const reviewBountySubmission = mutation({ + args: { + submissionId: v.id("bounty_submissions"), + action: v.union( + v.literal("approve"), + v.literal("reject"), + v.literal("revoke"), + ), + adminNote: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const admin = requireAdmin(await getAuthUser(ctx)); + + const submission = await ctx.db.get(args.submissionId); + if (!submission) { + throw new Error("Submission not found"); + } + + const now = Date.now(); + const adminReviewNote = args.adminNote?.trim().slice(0, 2000); + + if (args.action === "approve") { + if ( + submission.status === "approved" && + (submission.credit_status === "grant_pending" || + submission.credit_status === "granted") + ) { + return { status: "already_approved" as const }; + } + + await ctx.db.patch(submission._id, { + status: "approved", + admin_review_note: adminReviewNote, + reviewed_by: admin._id, + reviewed_at: now, + credit_status: "grant_pending", + updated_at: now, + }); + + await ctx.scheduler.runAfter(0, internal.earn.processBountyCreditGrant, { + submissionId: submission._id, + }); + + return { status: "approved" as const }; + } + + if (args.action === "reject") { + if (submission.credit_status === "grant_pending") { + throw new Error( + "Credit grant is currently processing. Try again shortly.", + ); + } + + if (submission.credit_status === "granted") { + throw new Error( + "Submission already granted credits. Use revoke instead.", + ); + } + + await ctx.db.patch(submission._id, { + status: "rejected", + admin_review_note: adminReviewNote, + reviewed_by: admin._id, + reviewed_at: now, + credit_status: "not_granted", + credited_amount: undefined, + credit_awarded_at: undefined, + updated_at: now, + }); + + const [bounty, user] = await Promise.all([ + ctx.db.get(submission.bounty_id), + ctx.db.get(submission.user_id), + ]); + if (user?.email && bounty) { + await ctx.scheduler.runAfter( + 0, + internal.email.sendBountySubmissionResultEmail, + { + recipientEmail: user.email, + recipientName: user.name ?? "there", + bountyTitle: bounty.title, + approved: false, + rewardAmount: 0, + }, + ); + } + + return { status: "rejected" as const }; + } + + if (submission.credit_status !== "granted") { + throw new Error("Only credited submissions can be revoked."); + } + + await ctx.db.patch(submission._id, { + status: "revoked", + admin_review_note: adminReviewNote, + reviewed_by: admin._id, + reviewed_at: now, + credit_status: "revoke_pending", + updated_at: now, + }); + + await ctx.scheduler.runAfter(0, internal.earn.processBountyCreditRevoke, { + submissionId: submission._id, + }); + + return { status: "revoke_pending" as const }; + }, +}); + +export const internalGrantWelcomeSpin = internalMutation({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const existingWelcomeSpin = await ctx.db + .query("referral_spins") + .withIndex("by_user_and_source", (q) => + q.eq("user", args.userId).eq("source", "welcome"), + ) + .first(); + + if (existingWelcomeSpin) { + return { created: false, spinId: existingWelcomeSpin._id }; + } + + const spinId = await ctx.db.insert("referral_spins", { + user: args.userId, + referred_user_id: undefined, + referred_user_email: undefined, + referral_code: undefined, + source: "welcome", + status: "available", + awarded_credits: undefined, + reward_label: undefined, + granted_at: Date.now(), + spun_at: undefined, + awarded_at: undefined, + revoked_at: undefined, + revoked_reason: undefined, + }); + + return { created: true, spinId }; + }, +}); + +export const internalGrantReferralSpin = internalMutation({ + args: { + userId: v.id("users"), + referredUserId: v.id("users"), + referralCode: v.string(), + }, + handler: async (ctx, args) => { + if (args.userId === args.referredUserId) { + return { created: false, reason: "self_referral" as const }; + } + + const referredUser = await ctx.db.get(args.referredUserId); + if (!referredUser) { + return { created: false, reason: "missing_referred_user" as const }; + } + + const normalizedReferredEmail = normalizeEmail(referredUser.email); + const existingSpinForEmail = await ctx.db + .query("referral_spins") + .withIndex("by_referred_email", (q) => + q.eq("referred_user_email", normalizedReferredEmail), + ) + .first(); + + if (existingSpinForEmail) { + return { + created: false, + spinId: existingSpinForEmail._id, + reason: "duplicate_referred_email" as const, + }; + } + + // Fallback for legacy rows created before referred_user_email existed. + const usersWithSameEmail = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", normalizedReferredEmail)) + .collect(); + + const referredUserIds = new Set>([args.referredUserId]); + for (const user of usersWithSameEmail) { + referredUserIds.add(user._id); + } + + if (referredUser.email !== normalizedReferredEmail) { + const rawEmailMatches = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", referredUser.email)) + .collect(); + for (const user of rawEmailMatches) { + referredUserIds.add(user._id); + } + } + + const existingLegacySpins = await Promise.all( + Array.from(referredUserIds).map((referredUserId) => + ctx.db + .query("referral_spins") + .withIndex("by_referred_user", (q) => + q.eq("referred_user_id", referredUserId), + ) + .first(), + ), + ); + const existingLegacySpinForEmail = existingLegacySpins.find( + (spin) => spin !== null, + ); + if (existingLegacySpinForEmail) { + return { + created: false, + spinId: existingLegacySpinForEmail._id, + reason: "duplicate_referred_email" as const, + }; + } + + const spinId = await ctx.db.insert("referral_spins", { + user: args.userId, + referred_user_id: args.referredUserId, + referred_user_email: normalizedReferredEmail, + referral_code: args.referralCode, + source: "referral", + status: "available", + awarded_credits: undefined, + reward_label: undefined, + granted_at: Date.now(), + spun_at: undefined, + awarded_at: undefined, + revoked_at: undefined, + revoked_reason: undefined, + }); + + return { created: true, spinId }; + }, +}); + +export const getSubmissionForCreditAction = internalQuery({ + args: { + submissionId: v.id("bounty_submissions"), + }, + handler: async (ctx, args) => { + const submission = await ctx.db.get(args.submissionId); + if (!submission) { + return null; + } + + const [bounty, user] = await Promise.all([ + ctx.db.get(submission.bounty_id), + ctx.db.get(submission.user_id), + ]); + + return { + submission, + bounty, + user, + }; + }, +}); + +export const markBountyGrantResult = internalMutation({ + args: { + submissionId: v.id("bounty_submissions"), + success: v.boolean(), + creditedAmount: v.number(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const submission = await ctx.db.get(args.submissionId); + if (!submission) { + return { success: false }; + } + + const now = Date.now(); + + if (args.success) { + if (submission.credit_status === "granted") { + return { success: true }; + } + + await ctx.db.patch(args.submissionId, { + credit_status: "granted", + credited_amount: args.creditedAmount, + credit_awarded_at: now, + updated_at: now, + }); + + return { success: true }; + } + + await ctx.db.patch(args.submissionId, { + credit_status: "grant_failed", + updated_at: now, + admin_review_note: args.error + ? `${submission.admin_review_note ? `${submission.admin_review_note}\n\n` : ""}Auto-grant error: ${args.error}` + : submission.admin_review_note, + }); + + return { success: false }; + }, +}); + +export const markBountyRevokeResult = internalMutation({ + args: { + submissionId: v.id("bounty_submissions"), + success: v.boolean(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const submission = await ctx.db.get(args.submissionId); + if (!submission) { + return { success: false }; + } + + const now = Date.now(); + + if (args.success) { + if (submission.credit_status === "revoked") { + return { success: true }; + } + + await ctx.db.patch(args.submissionId, { + credit_status: "revoked", + credit_revoked_at: now, + updated_at: now, + }); + + return { success: true }; + } + + await ctx.db.patch(args.submissionId, { + credit_status: "revoke_failed", + updated_at: now, + admin_review_note: args.error + ? `${submission.admin_review_note ? `${submission.admin_review_note}\n\n` : ""}Auto-revoke error: ${args.error}` + : submission.admin_review_note, + }); + + return { success: false }; + }, +}); + +export const processBountyCreditGrant = internalAction({ + args: { + submissionId: v.id("bounty_submissions"), + }, + handler: async (ctx, args) => { + const data = await ctx.runQuery( + internal.earn.getSubmissionForCreditAction, + { + submissionId: args.submissionId, + }, + ); + + if (!data?.submission || !data.bounty || !data.user?.clerk_id) { + await ctx.runMutation(internal.earn.markBountyGrantResult, { + submissionId: args.submissionId, + success: false, + creditedAmount: 0, + error: "Missing submission, bounty, or user data", + }); + return { success: false }; + } + + if (data.submission.credit_status !== "grant_pending") { + return { success: true, skipped: true }; + } + + const creditsToGrant = + data.submission.credited_amount ?? data.bounty.reward_credits; + + if (creditsToGrant <= 0) { + await ctx.runMutation(internal.earn.markBountyGrantResult, { + submissionId: args.submissionId, + success: false, + creditedAmount: 0, + error: "Invalid bounty reward amount", + }); + return { success: false }; + } + + // Use Autumn attach to grant credits via free add-on products. + // Decompose arbitrary bounty amounts into available product denominations. + const productIds = decomposeCreditsToProductIds(creditsToGrant); + + if (productIds.length === 0) { + await ctx.runMutation(internal.earn.markBountyGrantResult, { + submissionId: args.submissionId, + success: false, + creditedAmount: 0, + error: "No matching earn reward products for amount", + }); + return { success: false }; + } + + for (const productId of productIds) { + const result = await attachProduct(data.user.clerk_id, productId); + if (!result.success) { + console.error( + `[Earn] Failed to grant bounty credits for submission ${args.submissionId}:`, + result.error, + ); + + await ctx.runMutation(internal.earn.markBountyGrantResult, { + submissionId: args.submissionId, + success: false, + creditedAmount: 0, + error: result.error ?? "Attach failed", + }); + + return { success: false }; + } + } + + await ctx.runMutation(internal.earn.markBountyGrantResult, { + submissionId: args.submissionId, + success: true, + creditedAmount: creditsToGrant, + }); + + if (data.user.email) { + await ctx.runAction(internal.email.sendBountySubmissionResultEmail, { + recipientEmail: data.user.email, + recipientName: data.user.name ?? "there", + bountyTitle: data.bounty.title, + approved: true, + rewardAmount: creditsToGrant, + }); + } + + return { success: true }; + }, +}); + +export const processBountyCreditRevoke = internalAction({ + args: { + submissionId: v.id("bounty_submissions"), + }, + handler: async (ctx, args) => { + const data = await ctx.runQuery( + internal.earn.getSubmissionForCreditAction, + { + submissionId: args.submissionId, + }, + ); + + if (!data?.submission || !data.bounty || !data.user?.clerk_id) { + await ctx.runMutation(internal.earn.markBountyRevokeResult, { + submissionId: args.submissionId, + success: false, + error: "Missing submission, bounty, or user data", + }); + return { success: false }; + } + + if (data.submission.credit_status !== "revoke_pending") { + return { success: true, skipped: true }; + } + + const creditsToRevoke = + data.submission.credited_amount ?? data.bounty.reward_credits; + + if (creditsToRevoke <= 0) { + await ctx.runMutation(internal.earn.markBountyRevokeResult, { + submissionId: args.submissionId, + success: false, + error: "Invalid bounty reward amount", + }); + return { success: false }; + } + + const result = await trackUsage( + data.user.clerk_id, + creditsToRevoke, + "agent_credits", + { + action: "bounty_credit_revoke", + submission_id: data.submission._id, + bounty_id: data.bounty._id, + bounty_title: data.bounty.title, + timestamp: Date.now(), + }, + ); + + await ctx.runMutation(internal.earn.markBountyRevokeResult, { + submissionId: args.submissionId, + success: result.success, + error: result.error, + }); + + return { success: result.success }; + }, +}); + +export const adminGrantCreditsInternal = internalAction({ + args: { + userId: v.id("users"), + amount: v.number(), + adminName: v.string(), + }, + handler: async (ctx, args) => { + if (!VALID_BOUNTY_REWARD_AMOUNTS.has(args.amount)) { + throw new Error( + "Credit amount must be one of the preset amounts (1M, 2M, 4M, 5M, 10M, 20M, 30M, 50M, 70M, or 100M)", + ); + } + + const user = await ctx.runQuery(internal.admin.getUserByIdInternal, { + userId: args.userId, + }); + + if (!user?.clerk_id) { + throw new Error("User not found or missing Clerk ID"); + } + + const productId = EARN_REWARD_PRODUCTS[args.amount]; + if (!productId) { + throw new Error("No matching earn reward product for this amount"); + } + + const result = await attachProduct(user.clerk_id, productId); + if (!result.success) { + throw new Error(result.error ?? "Failed to attach earn reward product"); + } + + console.log( + `[Earn] Admin ${args.adminName} granted ${args.amount} credits to user ${user.name} (${user.clerk_id})`, + ); + + return { success: true }; + }, +}); + +export const adminGrantCredits = action({ + args: { + userId: v.id("users"), + amount: v.number(), + }, + handler: async (ctx, args): Promise<{ success: boolean }> => { + const admin = await getAuthUser(ctx); + if (!admin || (admin.role !== "god" && admin.role !== "admin")) { + throw new Error("Unauthorized: admin role required"); + } + + await ctx.runAction(internal.earn.adminGrantCreditsInternal, { + userId: args.userId, + amount: args.amount, + adminName: admin.name ?? "Unknown admin", + }); + + return { success: true }; + }, +}); + +export const getSpinForCreditAction = internalQuery({ + args: { + spinId: v.id("referral_spins"), + }, + handler: async (ctx, args) => { + const spin = await ctx.db.get(args.spinId); + if (!spin) { + return null; + } + + const user = await ctx.db.get(spin.user); + + return { + spin, + user, + }; + }, +}); + +export const markSpinCreditResult = internalMutation({ + args: { + spinId: v.id("referral_spins"), + success: v.boolean(), + error: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const spin = await ctx.db.get(args.spinId); + if (!spin) { + return { success: false }; + } + + const now = Date.now(); + + if (args.success) { + if (spin.status === "awarded") { + return { success: true }; + } + + await ctx.db.patch(args.spinId, { + status: "awarded", + awarded_at: now, + }); + + return { success: true }; + } + + await ctx.db.patch(args.spinId, { + status: "failed", + revoked_reason: args.error, + }); + + return { success: false }; + }, +}); + +export const processSpinCreditGrant = internalAction({ + args: { + spinId: v.id("referral_spins"), + }, + handler: async (ctx, args) => { + const data = await ctx.runQuery(internal.earn.getSpinForCreditAction, { + spinId: args.spinId, + }); + + if (!data?.spin || !data.user?.clerk_id) { + await ctx.runMutation(internal.earn.markSpinCreditResult, { + spinId: args.spinId, + success: false, + error: "Missing spin or user data", + }); + return { success: false }; + } + + if (data.spin.status !== "spinning") { + return { success: true, skipped: true }; + } + + const creditsToGrant = data.spin.awarded_credits ?? 0; + if (creditsToGrant <= 0) { + await ctx.runMutation(internal.earn.markSpinCreditResult, { + spinId: args.spinId, + success: false, + error: "Invalid spin reward", + }); + return { success: false }; + } + + // Use Autumn attach to grant credits via a free add-on product. + // Direct lookup for spin rewards (amounts match exactly). + const productIds = EARN_REWARD_PRODUCTS[creditsToGrant] + ? [EARN_REWARD_PRODUCTS[creditsToGrant]] + : decomposeCreditsToProductIds(creditsToGrant); + + if (productIds.length === 0) { + await ctx.runMutation(internal.earn.markSpinCreditResult, { + spinId: args.spinId, + success: false, + error: "No matching earn reward product for amount", + }); + return { success: false }; + } + + for (const productId of productIds) { + const result = await attachProduct(data.user.clerk_id, productId); + if (!result.success) { + console.error( + `[Earn] Failed to grant spin credits for spin ${args.spinId}:`, + result.error, + ); + + await ctx.runMutation(internal.earn.markSpinCreditResult, { + spinId: args.spinId, + success: false, + error: result.error ?? "Attach failed", + }); + + return { success: false }; + } + } + + await ctx.runMutation(internal.earn.markSpinCreditResult, { + spinId: args.spinId, + success: true, + }); + + return { success: true }; + }, +}); diff --git a/freebuff/web/convex/editor/filesystem.ts b/freebuff/web/convex/editor/filesystem.ts new file mode 100644 index 0000000000..d11fecbbfe --- /dev/null +++ b/freebuff/web/convex/editor/filesystem.ts @@ -0,0 +1,884 @@ +"use node"; + +import { v } from "convex/values"; +import { initializeCodebase } from "../../codebase-utils/codebase/initializeCodebase"; +import { DaytonaCodebase } from "../../codebase-utils/codebase/DaytonaCodebase"; +import { action } from "../_generated/server"; +import { getAuthUser } from "../users"; +import { getVerifiedAccessProject } from "../project"; +import { + validateAndFixCronIntervals, + isCronsFile, +} from "../coding_agent/agent/process/cronValidator"; +export const listFiles = action({ + args: { + projectId: v.id("project"), + path: v.optional(v.string()), + }, + returns: v.array( + v.object({ + name: v.string(), + path: v.string(), + type: v.union(v.literal("file"), v.literal("directory")), + size: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + // Validate path to prevent directory traversal + if ( + args.path && + (args.path === ".." || + args.path.startsWith("../") || + args.path.includes("/../") || + args.path.endsWith("/..") || + /[/\\]\.\./.test(args.path) || + /\\/.test(args.path)) + ) { + throw new Error("Invalid path: directory traversal not allowed"); + } + + // Get authenticated user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + const targetPath = args.path || "./"; + + try { + // Use the codebase's getAllFilePaths method to get all files + const allFilePaths = await codebase.getAllFilePaths(); + + // Filter files based on the target path + let filteredPaths: string[]; + if (targetPath === "./") { + // For root directory, show only top-level files and directories + filteredPaths = allFilePaths.filter((path) => !path.includes("/")); + + // Also include top-level directories by checking if any files are in subdirectories + const topLevelDirs = new Set(); + allFilePaths.forEach((path) => { + const firstSlash = path.indexOf("/"); + if (firstSlash > 0) { + topLevelDirs.add(path.substring(0, firstSlash)); + } + }); + + // Add top-level directories to the list + topLevelDirs.forEach((dir) => { + if (!filteredPaths.includes(dir)) { + filteredPaths.push(dir); + } + }); + } else { + // For subdirectories, filter paths that start with the target path + const normalizedTargetPath = targetPath.endsWith("/") + ? targetPath + : targetPath + "/"; + filteredPaths = allFilePaths + .filter((path) => path.startsWith(normalizedTargetPath)) + .map((path) => path.substring(normalizedTargetPath.length)) + .filter((path) => path.length > 0); + + // Show only immediate children (no nested paths) + const immediateChildren = new Set(); + filteredPaths.forEach((path) => { + const firstSlash = path.indexOf("/"); + if (firstSlash === -1) { + // It's a file in this directory + immediateChildren.add(path); + } else { + // It's in a subdirectory, add the subdirectory name + immediateChildren.add(path.substring(0, firstSlash)); + } + }); + + filteredPaths = Array.from(immediateChildren); + } + + // Check each path to determine if it's a file or directory + const fileList = await Promise.all( + filteredPaths + .filter((name) => name !== ".git") + .map(async (name) => { + const fullPath = + targetPath === "./" ? name : `${targetPath}/${name}`; + + // Check if this is a directory by seeing if any files exist with this prefix + const isDirectory = allFilePaths.some((path) => { + if (targetPath === "./") { + return path.startsWith(name + "/"); + } else { + const normalizedTargetPath = targetPath.endsWith("/") + ? targetPath + : targetPath + "/"; + return path.startsWith(normalizedTargetPath + name + "/"); + } + }); + + return { + name, + path: fullPath, + type: isDirectory ? ("directory" as const) : ("file" as const), + size: undefined, + }; + }), + ); + + // Sort directories first, then files + return fileList.sort((a, b) => { + if (a.type === b.type) { + return a.name.localeCompare(b.name); + } + return a.type === "directory" ? -1 : 1; + }); + } catch (error) { + console.error("Error listing files:", error); + throw new Error("Failed to list files"); + } + }, +}); + +export const readFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + }, + returns: v.object({ + content: v.string(), + language: v.string(), + isImage: v.optional(v.boolean()), + }), + handler: async (ctx, args) => { + // Validate path to prevent directory traversal + if ( + !args.path || + args.path === "." || + args.path === ".." || + args.path.startsWith("../") || + args.path.includes("/../") || + args.path.endsWith("/..") || + /[/\\]\.\./.test(args.path) || + /\\/.test(args.path) + ) { + throw new Error("Invalid file path: backslashes are not allowed"); + } + + // Get authenticated user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + try { + // Determine file type from extension + const ext = args.path.split(".").pop()?.toLowerCase() || ""; + const fileName = args.path.split("/").pop()?.toLowerCase() || ""; + + // Check if it's an image file (excluding SVG which should be treated as code) + const imageExtensions = [ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "bmp", + "ico", + ]; + const isImage = imageExtensions.includes(ext); + + let content: string; + let language: string; + + if (isImage) { + // Check if file exists + const fileExists = await ( + codebase as DaytonaCodebase + ).checkIfFileExistsInCodebase(args.path); + if (!fileExists) { + throw new Error("Image file not found: " + args.path); + } + const imageBytes = await codebase.readFileBytes(args.path); + + content = Buffer.from(imageBytes).toString("base64"); + + language = "image"; + + return { + content: content, + language, + isImage: true, + }; + } else { + // For text files, read as text + content = await codebase.readFile(args.path); + + const languageMap: Record = { + js: "javascript", + jsx: "javascript", + ts: "typescript", + tsx: "typescript", + css: "css", + scss: "scss", + less: "less", + html: "html", + json: "json", + md: "markdown", + py: "python", + java: "java", + c: "c", + cpp: "cpp", + cs: "csharp", + php: "php", + rb: "ruby", + go: "go", + rs: "rust", + swift: "swift", + kt: "kotlin", + yaml: "yaml", + yml: "yaml", + xml: "xml", + svg: "xml", + sql: "sql", + sh: "shell", + bash: "shell", + dockerfile: "dockerfile", + gitignore: "gitignore", + }; + + if (fileName.includes("dockerfile")) language = "dockerfile"; + else if (fileName.includes("gitignore")) language = "gitignore"; + else if (fileName.includes("package.json")) language = "json"; + else language = languageMap[ext] || "plaintext"; + + return { + content, + language, + isImage: false, + }; + } + } catch (error) { + console.error("Error reading file"); + throw new Error("Failed to read file"); + } + }, +}); + +export const searchInFiles = action({ + args: { + projectId: v.id("project"), + query: v.string(), + caseSensitive: v.optional(v.boolean()), + wholeWord: v.optional(v.boolean()), + regex: v.optional(v.boolean()), + maxResults: v.optional(v.number()), + }, + returns: v.array( + v.object({ + path: v.string(), + line: v.number(), + column: v.number(), + preview: v.string(), + }), + ), + handler: async (ctx, args) => { + const query = args.query.trim(); + if (!query) return []; + + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + const runCommandImpl = (codebase as any).runCommand as + | (( + command: string, + timeout?: number, + ) => Promise<{ output: string; exitCode?: number }>) + | undefined; + + if (!runCommandImpl) { + throw new Error("Search is not supported for this sandbox type"); + } + + const runCommand = (command: string, timeout?: number) => + runCommandImpl.call(codebase, command, timeout); + + const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + const maxResults = Math.min(Math.max(args.maxResults ?? 200, 1), 1000); + + const flags: string[] = [ + "--line-number", + "--column", + "--no-heading", + "--color", + "never", + "--max-count", + String(maxResults), + "--glob", + shellQuote("!.git"), + "--glob", + shellQuote("!node_modules"), + ]; + + if (args.caseSensitive) { + flags.push("--case-sensitive"); + } else { + flags.push("--ignore-case"); + } + + if (args.wholeWord) flags.push("--word-regexp"); + if (!args.regex) flags.push("--fixed-strings"); + + const parseMatches = ( + output: string, + parser: (raw: string) => { + path: string; + line: number; + column: number; + preview: string; + } | null, + ) => { + const lines = output.split("\n").filter(Boolean); + const matches: Array<{ + path: string; + line: number; + column: number; + preview: string; + }> = []; + + for (const raw of lines) { + const parsed = parser(raw); + if (!parsed) continue; + matches.push(parsed); + if (matches.length >= maxResults) break; + } + + return matches; + }; + + const command = `rg ${flags.join(" ")} ${shellQuote(query)} .`; + const result = await runCommand(command, 30000); + + if (result.exitCode === 1 || !result.output?.trim()) { + return []; + } + + if ( + result.exitCode && + result.exitCode !== 0 && + /rg: .*not found|command not found/i.test(result.output) + ) { + const grepFlags: string[] = [ + "-R", + "-n", + "--exclude-dir=.git", + "--exclude-dir=node_modules", + ]; + + if (!args.caseSensitive) grepFlags.push("-i"); + if (args.wholeWord) grepFlags.push("-w"); + if (args.regex) { + grepFlags.push("-E"); + } else { + grepFlags.push("-F"); + } + + const grepCommand = `grep ${grepFlags.join(" ")} -- ${shellQuote(query)} .`; + const grepResult = await runCommand(grepCommand, 30000); + + if (grepResult.exitCode === 1 || !grepResult.output?.trim()) { + return []; + } + + if (grepResult.exitCode && grepResult.exitCode !== 0) { + throw new Error(`Search failed: ${grepResult.output}`); + } + + return parseMatches(grepResult.output, (raw) => { + const parsed = raw.match(/^(.*?):(\d+):(.*)$/); + if (!parsed) return null; + const [, path, line, preview] = parsed; + return { + path, + line: Number(line), + column: 1, + preview, + }; + }); + } + + if (result.exitCode && result.exitCode !== 0) { + throw new Error(`Search failed: ${result.output}`); + } + + return parseMatches(result.output, (raw) => { + const parsed = raw.match(/^(.*?):(\d+):(\d+):(.*)$/); + if (!parsed) return null; + + const [, path, line, column, preview] = parsed; + return { + path, + line: Number(line), + column: Number(column), + preview, + }; + }); + }, +}); + +export const writeFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + content: v.string(), + }, + returns: v.object({ + success: v.boolean(), + adjustedContent: v.optional(v.string()), + adjustments: v.optional( + v.array( + v.object({ + lineNumber: v.number(), + originalInterval: v.string(), + adjustedInterval: v.string(), + }), + ), + ), + }), + handler: async (ctx, args) => { + // Validate path to prevent directory traversal + if ( + !args.path || + args.path === "." || + args.path === ".." || + args.path.startsWith("../") || + args.path.includes("/../") || + args.path.endsWith("/..") || + /[/\\]\.\./.test(args.path) || + /\\/.test(args.path) + ) { + throw new Error("Invalid file path: backslashes are not allowed"); + } + + // Get authenticated user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Validate and fix cron intervals for crons.ts files + let contentToWrite = args.content; + let cronAdjustments = undefined; + + if (isCronsFile(args.path)) { + const validation = validateAndFixCronIntervals(args.content); + if (validation.adjustments.length > 0) { + // Log adjustments to Axiom via console + console.log("[CRON_INTERVAL_ADJUSTMENT]", { + projectId: args.projectId, + filePath: args.path, + source: "manual_edit", + adjustments: validation.adjustments, + }); + // Use the adjusted content + contentToWrite = validation.content; + cronAdjustments = validation.adjustments; + } + } + + try { + await codebase.writeFile(args.path, contentToWrite); + return { + success: true, + adjustedContent: cronAdjustments ? contentToWrite : undefined, + adjustments: cronAdjustments, + }; + } catch (error) { + console.error("Error writing file"); + throw new Error("Failed to write file"); + } + }, +}); + +export const createFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + isDirectory: v.boolean(), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + // Validate file path + if ( + !args.path || + args.path === "." || + args.path === ".." || + args.path.startsWith("../") || + args.path.includes("/../") || + args.path.endsWith("/..") || + /[/\\]\.\./.test(args.path) || + /\\/.test(args.path) + ) { + throw new Error( + "Invalid file path for creation: backslashes are not allowed", + ); + } + + // Get authenticated user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + try { + if (args.isDirectory) { + await codebase.createDirectory(args.path); + } else { + await codebase.writeFile(args.path, ""); + } + return { success: true }; + } catch (error) { + console.error("Error creating file/directory:", error); + throw new Error("Failed to create file or directory"); + } + }, +}); + +export const deleteFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + }, + returns: v.object({ + success: v.boolean(), + }), + handler: async (ctx, args) => { + // Validate file path + if ( + !args.path || + args.path === "." || + args.path === ".." || + args.path.startsWith("../") || + args.path.includes("/../") || + args.path.endsWith("/..") || + /[/\\]\.\./.test(args.path) || + /\\/.test(args.path) + ) { + throw new Error( + "Invalid file path for delete: backslashes are not allowed", + ); + } + + // Get authenticated user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + try { + await codebase.deleteFile(args.path); + return { success: true }; + } catch (error) { + console.error("Error deleting file"); + throw new Error("Failed to delete file"); + } + }, +}); + +/** + * God mode only - List files at an absolute path from /home/daytona/ + */ +export const godModeListFiles = action({ + args: { + projectId: v.id("project"), + path: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: god mode required"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + const safePath = args.path.replace(/'/g, "'\\''"); + const absolutePath = `/home/daytona/${safePath}`.replace(/\/+/g, "/"); + const result = await codebase.runCommand(`ls -la '${absolutePath}' 2>&1`); + + const lines = result.output.trim().split("\n").filter(Boolean); + const entries: { + name: string; + isDirectory: boolean; + size: string; + permissions: string; + }[] = []; + + for (const line of lines) { + if (line.startsWith("total ")) continue; + const parts = line.split(/\s+/); + if (parts.length < 9) continue; + const permissions = parts[0]; + const size = parts[4]; + const name = parts.slice(8).join(" "); + if (name === "." || name === "..") continue; + entries.push({ + name, + isDirectory: permissions.startsWith("d"), + size, + permissions, + }); + } + + return { entries, rawOutput: result.output }; + }, +}); + +/** + * God mode only - Read a file at an absolute path from /home/daytona/ + */ +export const godModeReadFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: god mode required"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + const safePath = args.path.replace(/'/g, "'\\''"); + const absolutePath = `/home/daytona/${safePath}`.replace(/\/+/g, "/"); + const result = await codebase.runCommand(`cat '${absolutePath}'`); + + if (result.exitCode !== 0) { + throw new Error(`Failed to read file: ${result.output}`); + } + + return { content: result.output }; + }, +}); + +/** + * God mode only - Write a file at an absolute path from /home/daytona/ + */ +export const godModeWriteFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + content: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: god mode required"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + const safePath = args.path.replace(/'/g, "'\\''"); + const absolutePath = `/home/daytona/${safePath}`.replace(/\/+/g, "/"); + const base64Content = Buffer.from(args.content).toString("base64"); + + const result = await codebase.runCommand( + `mkdir -p "$(dirname '${absolutePath}')" && printf '%s' '${base64Content}' | base64 -d > '${absolutePath}'`, + ); + + if (result.exitCode !== 0) { + throw new Error(`Failed to write file: ${result.output}`); + } + + return { success: true }; + }, +}); + +/** + * God mode only - Delete a file or directory at a path relative to /home/daytona/ + * Uses the Daytona file system API (DELETE /files) via sandbox.fs.deleteFile(). + * Sandbox: the project's sandbox (project.sandbox_id, e.g. daytona:). + */ +export const godModeDeleteFile = action({ + args: { + projectId: v.id("project"), + path: v.string(), + recursive: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: god mode required"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + if (!project || !project.sandbox_id) { + throw new Error("Project not found or has no sandbox"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + if (!(codebase instanceof DaytonaCodebase)) { + throw new Error( + "Daytona FS delete is only supported for Daytona sandboxes", + ); + } + + const normalizedPath = args.path.replace(/\/+/g, "/").replace(/^\//, ""); + await codebase.deleteFileAtPath(normalizedPath, { + recursive: args.recursive ?? false, + }); + + return { success: true }; + }, +}); diff --git a/freebuff/web/convex/email.ts b/freebuff/web/convex/email.ts new file mode 100644 index 0000000000..e09b64423e --- /dev/null +++ b/freebuff/web/convex/email.ts @@ -0,0 +1,270 @@ +'use node' + +import { Resend } from 'resend' +import { + BountySubmissionResultEmail, + TicketReplyEmail, +} from '../src/vly/components/emails/send-to-computer' +import { action, internalAction } from './_generated/server' +import { internal } from './_generated/api' +import { getAuthUser } from './users' +import { v } from 'convex/values' +import { renderToStaticMarkup } from 'react-dom/server' +import React from 'react' + +const DEFAULT_APP_URL = 'https://freebuff.app' +const DISCORD_INVITE_URL = 'https://discord.gg/yXG3w7wxfs' +const FREEBUFF_FROM_EMAIL = 'James from Freebuff ' +const FREEBUFF_REPLY_TO_EMAIL = 'support@codebuff.com' + +function getAppBaseUrl(): string { + const rawUrl = + process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || DEFAULT_APP_URL + return rawUrl.endsWith('/') ? rawUrl.slice(0, -1) : rawUrl +} + +function firstNameFromDisplayName(name?: string | null): string { + const trimmed = (name ?? '').trim() + if (!trimmed) { + return 'there' + } + return trimmed.split(/\s+/)[0] ?? 'there' +} + +export const sendTicketReplyEmail = action({ + args: { + recipientEmail: v.string(), + recipientName: v.string(), + ticketTitle: v.string(), + messageContent: v.string(), + ticketUrl: v.string(), + }, + handler: async (ctx, args) => { + const apiKey = process.env.RESEND_API_KEY + + if (!apiKey) { + console.error('[sendTicketReplyEmail] RESEND_API_KEY is not configured') + return { success: false, error: 'RESEND_API_KEY not configured' } + } + + const resend = new Resend(apiKey) + + const emailSubject = `New reply on your ticket: "${args.ticketTitle}"` + const emailHtml = renderToStaticMarkup( + React.createElement(TicketReplyEmail, { + recipientName: args.recipientName, + ticketTitle: args.ticketTitle, + messageContent: args.messageContent, + ticketUrl: args.ticketUrl, + }), + ) + + const { data, error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [args.recipientEmail], + subject: emailSubject, + html: emailHtml, + }) + + if (error) { + console.error( + `[sendTicketReplyEmail] Failed to send email: ${error.message}`, + ) + return { success: false, error: error.message } + } + + console.log(`[sendTicketReplyEmail] Sent to ${args.recipientEmail}`) + return { success: true, error: null } + }, +}) + +export const sendBountySubmissionResultEmail = internalAction({ + args: { + recipientEmail: v.string(), + recipientName: v.string(), + bountyTitle: v.string(), + approved: v.boolean(), + rewardAmount: v.number(), + }, + handler: async (ctx, args) => { + const apiKey = process.env.RESEND_API_KEY + + if (!apiKey) { + console.error( + '[sendBountySubmissionResultEmail] RESEND_API_KEY is not configured', + ) + return { success: false, error: 'RESEND_API_KEY not configured' } + } + + const resend = new Resend(apiKey) + + const subject = args.approved + ? `Bounty approved: "${args.bountyTitle}"` + : `Bounty not approved: "${args.bountyTitle}"` + + const emailHtml = renderToStaticMarkup( + React.createElement(BountySubmissionResultEmail, { + recipientName: args.recipientName, + bountyTitle: args.bountyTitle, + approved: args.approved, + rewardAmount: args.rewardAmount, + }), + ) + + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [args.recipientEmail], + subject, + html: emailHtml, + }) + + if (error) { + console.error( + `[sendBountySubmissionResultEmail] Failed to send: ${error.message}`, + ) + return { success: false, error: error.message } + } + + console.log( + `[sendBountySubmissionResultEmail] Sent to ${args.recipientEmail}`, + ) + return { success: true, error: null } + }, +}) + +export const sendWelcomeEmailInternal = internalAction({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) { + console.error( + '[sendWelcomeEmailInternal] RESEND_API_KEY is not configured', + ) + return { success: false, error: 'RESEND_API_KEY not configured' } + } + + const user = await ctx.runQuery(internal.users.get, { + userId: args.userId, + }) + if (!user || !user.email) { + return { success: false, error: 'User not found or missing email' } + } + + const resend = new Resend(apiKey) + const firstName = firstNameFromDisplayName(user.name) + + const subject = 'Welcome to Freebuff' + const text = [ + `Hi ${firstName},`, + '', + `Welcome to Freebuff. My name is James, and I’ll be your point of contact here.`, + '', + `You can email me any time at ${FREEBUFF_REPLY_TO_EMAIL}.`, + '', + `You can also get live support from our team in our Discord: ${DISCORD_INVITE_URL}`, + '', + `Freebuff is the free coding agent from Codebuff. You can use it to build, fix, and ship projects without worrying about credits.`, + '', + 'If you get stuck, want help, or have feedback, just reply to this email.', + '', + 'Excited to see what you build with us,', + 'James', + ].join('\n') + + const html = ` +
+

Hi ${firstName},

+

Welcome to Freebuff. My name is James, and I’ll be your point of contact here.

+

You can email me any time at ${FREEBUFF_REPLY_TO_EMAIL}.

+

You can also get live support from our team in our Discord.

+

Freebuff is the free coding agent from Codebuff. You can use it to build, fix, and ship projects without worrying about credits.

+

If you get stuck, want help, or have feedback, just reply to this email.

+

Excited to see what you build with us,

+

James

+
+ ` + + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [user.email], + subject, + text, + html, + }) + + if (error) { + console.error( + `[sendWelcomeEmailInternal] Failed to send to ${user.email}: ${error.message}`, + ) + return { success: false, error: error.message } + } + + console.log(`[sendWelcomeEmailInternal] Sent to ${user.email}`) + return { success: true, error: null } + }, +}) + +export const sendCancellationEmail = action({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user || !user.email) { + return { success: false, error: 'User not found or missing email' } + } + + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) { + console.error('[sendCancellationEmail] RESEND_API_KEY is not configured') + return { success: false, error: 'RESEND_API_KEY not configured' } + } + + const resend = new Resend(apiKey) + const firstName = firstNameFromDisplayName(user.name) + const reactivateUrl = `${getAppBaseUrl()}/web/dashboard` + + const text = [ + `Hi ${firstName},`, + '', + "Sorry to see you go. I'd love to know what we could have done better. Just reply to this email.", + '', + `As a heads up, we're keeping your early-bird 50% discount available for a limited time. You can reactivate at half price here: ${reactivateUrl}`, + '', + 'Thanks for giving Freebuff a try,', + 'James from Freebuff', + ].join('\n') + + const html = ` +
+

Hi ${firstName},

+

Sorry to see you go. I'd love to know what we could have done better. Just reply to this email.

+

As a heads up, we're keeping your early-bird 50% discount available for a limited time. You can reactivate at half price here.

+

Thanks for giving Freebuff a try,

+

James from Freebuff

+
+ ` + + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [user.email], + subject: "We're sorry to see you go", + text, + html, + }) + + if (error) { + console.error( + `[sendCancellationEmail] Failed to send to ${user.email}: ${error.message}`, + ) + return { success: false, error: error.message } + } + + console.log(`[sendCancellationEmail] Sent to ${user.email}`) + return { success: true, error: null } + }, +}) diff --git a/freebuff/web/convex/email_blasts_node.ts b/freebuff/web/convex/email_blasts_node.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/freebuff/web/convex/email_blasts_node.ts @@ -0,0 +1 @@ +export {}; diff --git a/freebuff/web/convex/entry_point.ts b/freebuff/web/convex/entry_point.ts new file mode 100644 index 0000000000..1c4f8fd10d --- /dev/null +++ b/freebuff/web/convex/entry_point.ts @@ -0,0 +1,277 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery, mutation } from "./_generated/server"; +import { getAuthUser } from "./users"; +import { getVerifiedAccessProject } from "./project"; + +export const editEntryPoint = internalMutation({ + args: { + entryPointId: v.id("entry_point"), + project: v.optional(v.id("project")), + type: v.optional(v.union(v.literal("page"))), + associated_files: v.optional(v.array(v.string())), + abstraction: v.optional(v.string()), + status: v.optional(v.union(v.literal("active"), v.literal("processing"))), + page: v.optional( + v.object({ + page_title: v.optional(v.string()), + page_display_url: v.optional(v.string()), + page_file: v.optional(v.string()), + navigation_paths: v.optional( + v.array( + v.object({ + navigation_path: v.string(), + description: v.string(), + }), + ), + ), + }), + ), + }, + handler: async (ctx, args) => { + const updateData: Record = {}; + + if (args.project !== undefined) updateData.project = args.project; + if (args.type !== undefined) updateData.type = args.type; + if (args.associated_files !== undefined) + updateData.associated_files = args.associated_files; + if (args.abstraction !== undefined) + updateData.abstraction = args.abstraction; + if (args.status !== undefined) updateData.status = args.status; + if (args.page !== undefined) updateData.page = args.page; + + await ctx.db.patch(args.entryPointId, updateData); + }, +}); +export const getProjectEntryPoints = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("entry_point") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + }, +}); +export const getEntryPoint = internalQuery({ + args: { + entryPointId: v.id("entry_point"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.entryPointId); + }, +}); + +export const deleteEntryPoint = mutation({ + args: { + entryPointId: v.id("entry_point"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const ep = await ctx.db.get(args.entryPointId); + if (!ep) return; + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + ep.project, + "read", + user, + ); + if (!project) { + throw new Error("Unauthorized"); + } + if (ep.page?.page_display_url === "/") { + throw new Error("Cannot delete landing page"); + } + await ctx.db.delete(args.entryPointId); + }, +}); + +export const addFilesToContext = internalMutation({ + args: { + entryPointId: v.id("entry_point"), + filePaths: v.array(v.string()), + }, + handler: async (ctx, args) => { + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + await ctx.db.patch(args.entryPointId, { + associated_files: [...entryPoint.associated_files, ...args.filePaths], + }); + }, +}); + +export const removeFilesFromContext = internalMutation({ + args: { + entryPointId: v.id("entry_point"), + filePaths: v.array(v.string()), + }, + handler: async (ctx, args) => { + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + await ctx.db.patch(args.entryPointId, { + associated_files: entryPoint.associated_files.filter( + (file) => !args.filePaths.includes(file), + ), + }); + }, +}); + +export const editPageTitleOrRouteURL = mutation({ + args: { + entryPointId: v.id("entry_point"), + new_title: v.optional(v.string()), + dynamic_data: v.optional(v.string()), + resolved_display_url: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + entryPoint.project, + "read", + user, + ); + if (!project) { + throw new Error("Unauthorized"); + } + if (entryPoint.page?.page_display_url === "/") { + throw new Error("Cannot edit landing page"); + } + + if (!entryPoint.page) { + throw new Error("Page data not found"); + } + + const page = entryPoint.page; + const nextDisplay = args.dynamic_data + ? args.dynamic_data + : page.page_display_url; + const nextResolved = + args.resolved_display_url && args.resolved_display_url !== nextDisplay + ? args.resolved_display_url + : undefined; + + await ctx.db.patch(args.entryPointId, { + page: { + page_title: args.new_title ? args.new_title : page.page_title, + page_display_url: nextDisplay, + page_file: page.page_file, + navigation_paths: page.navigation_paths, + has_dynamic_params: nextDisplay ? /:[^/]+/.test(nextDisplay) : false, + resolved_display_url: nextResolved, + }, + }); + }, +}); + +// Internal variant for system/agent calls (no user auth required) +export const editPageTitleOrRouteURLInternal = internalMutation({ + args: { + entryPointId: v.id("entry_point"), + new_title: v.optional(v.string()), + dynamic_data: v.optional(v.string()), + resolved_display_url: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + if (!entryPoint.page) { + throw new Error("Page data not found"); + } + + const page = entryPoint.page; + const nextDisplay = args.dynamic_data + ? args.dynamic_data + : page.page_display_url; + const nextResolved = + args.resolved_display_url && args.resolved_display_url !== nextDisplay + ? args.resolved_display_url + : undefined; + + await ctx.db.patch(args.entryPointId, { + page: { + page_title: args.new_title ? args.new_title : page.page_title, + page_display_url: nextDisplay, + page_file: page.page_file, + navigation_paths: page.navigation_paths, + has_dynamic_params: nextDisplay ? /:[^/]+/.test(nextDisplay) : false, + resolved_display_url: nextResolved, + }, + }); + }, +}); + +export const updateEntryPointNavigationPaths = internalMutation({ + args: { + entryPointId: v.id("entry_point"), + navigationPaths: v.array( + v.object({ + navigation_path: v.string(), + description: v.string(), + line_number: v.number(), + }), + ), + }, + handler: async (ctx, args) => { + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + if (!entryPoint.page) { + throw new Error("Page data not found"); + } + + await ctx.db.patch(args.entryPointId, { + page: { + page_title: entryPoint.page.page_title, + page_display_url: entryPoint.page.page_display_url, + page_file: entryPoint.page.page_file, + navigation_paths: args.navigationPaths, + has_dynamic_params: entryPoint.page.has_dynamic_params, + }, + }); + }, +}); + +export const setEntryPointCanvasState = mutation({ + args: { + entryPointId: v.id("entry_point"), + canvasState: v.object({ + x: v.number(), + y: v.number(), + }), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.entryPointId, { + canvas_state: args.canvasState, + }); + }, +}); diff --git a/freebuff/web/convex/fallback_dist.ts b/freebuff/web/convex/fallback_dist.ts new file mode 100644 index 0000000000..e0759e7666 --- /dev/null +++ b/freebuff/web/convex/fallback_dist.ts @@ -0,0 +1,102 @@ +import { internal } from "./_generated/api"; +import { internalMutation, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUser } from "./users"; + +const DIST_REFRESH_WINDOW_MS = 10 * 60 * 1000; +/** Extra delay for catch-up publishes so the eligibility re-check inside + * publishFallbackDist (now - lastDistBuildAt > window) can't race the + * scheduler firing a hair early and drop the refresh. */ +const DIST_REFRESH_CATCHUP_BUFFER_MS = 30 * 1000; + +export const enqueueRefreshIfEligible = internalMutation({ + args: { + projectId: v.id("project"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + return false; + } + + if (!project.sandbox_id?.startsWith("daytona:")) { + return false; + } + + // Inside the refresh window we don't drop the refresh — we schedule a + // catch-up publish for when the window expires. Dropping it meant a + // commit landing shortly after a dist build (e.g. the styling pass right + // after the initial build) never refreshed the fallback snapshot, so the + // preview iframe served a stale unstyled dist whenever the dev server was + // down. Duplicate catch-ups self-cancel: publishFallbackDist re-checks + // eligibility against last_dist_build_at, so after the first one builds, + // the rest early-return. + const lastDistBuildAt = project.last_dist_build_at ?? 0; + const windowRemainingMs = + lastDistBuildAt + DIST_REFRESH_WINDOW_MS - Date.now(); + const delayMs = + windowRemainingMs > 0 + ? windowRemainingMs + DIST_REFRESH_CATCHUP_BUFFER_MS + : 0; + + await ctx.scheduler.runAfter( + delayMs, + internal.fallback_dist_publish.publishFallbackDist, + { + projectId: args.projectId, + }, + ); + + return delayMs === 0; + }, +}); + +export const triggerFallbackDistPublish = mutation({ + args: { + projectId: v.id("project"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + const membership = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if ( + !membership && + user.role !== "god" + ) { + throw new Error( + "Unauthorized: You do not have permission to publish fallback dist", + ); + } + + if (!project.sandbox_id?.startsWith("daytona:")) { + throw new Error("Fallback dist publish is only supported for Daytona projects"); + } + + await ctx.scheduler.runAfter( + 0, + internal.fallback_dist_publish.publishFallbackDist, + { + projectId: args.projectId, + force: true, + }, + ); + + return null; + }, +}); diff --git a/freebuff/web/convex/fallback_dist_publish.ts b/freebuff/web/convex/fallback_dist_publish.ts new file mode 100644 index 0000000000..1d9d9eb849 --- /dev/null +++ b/freebuff/web/convex/fallback_dist_publish.ts @@ -0,0 +1,545 @@ +"use node"; + +import { createHash, createHmac } from "node:crypto"; + +import type { Codebase, VercelDeploymentFile } from "../codebase-utils/codebase/Codebase"; +import { + hasPackageManager, + isVercelDeployable, +} from "../codebase-utils/codebase/Codebase"; +import { initializeCodebase } from "../codebase-utils/codebase/initializeCodebase"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; +import { v } from "convex/values"; + +type FrameworkType = "vite" | "nextjs" | "unsupported"; + +async function detectFramework(codebase: Codebase): Promise { + try { + const raw = await codebase.readFile("package.json"); + const pkg = JSON.parse(raw) as { + dependencies?: Record; + devDependencies?: Record; + }; + const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + if ("next" in allDeps) return "nextjs"; + if ("vite" in allDeps) return "vite"; + return "unsupported"; + } catch { + // No package.json → Python, Ruby, or other non-JS project + return "unsupported"; + } +} + +const DIST_REFRESH_WINDOW_MS = 10 * 60 * 1000; +const DIST_BUILD_ROOT_PREFIX = "distBuild"; +const UPLOAD_SCRIPT_PATH = ".vly-dist-upload.sh"; +const PRESIGN_EXPIRY_SECONDS = 15 * 60; + +type R2Config = { + accountId: string; + apiToken: string; + bucketName: string; +}; + +function getR2Config(): R2Config { + const accountId = process.env.R2_ACCOUNT_ID; + const apiToken = process.env.R2_API_TOKEN; + const bucketName = process.env.R2_BUCKET_NAME; + + if (!accountId || !apiToken || !bucketName) { + throw new Error( + "R2 credentials missing. Expected R2_ACCOUNT_ID, R2_API_TOKEN, and R2_BUCKET_NAME.", + ); + } + + return { accountId, apiToken, bucketName }; +} + +type R2S3Credentials = { + accessKeyId: string; + secretAccessKey: string; +}; + +/** + * S3-style credentials used only to presign PUT URLs so the sandbox can + * upload the dist directly to R2. The master Cloudflare API token must never + * enter the sandbox (user code runs there); presigned URLs are scoped to a + * single object key and expire after a few minutes. + */ +function getR2S3Credentials(): R2S3Credentials | null { + const accessKeyId = process.env.R2_ACCESS_KEY_ID; + const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY; + if (!accessKeyId || !secretAccessKey) { + return null; + } + return { accessKeyId, secretAccessKey }; +} + +function getR2ObjectsBaseUrl(config: R2Config): string { + return `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/r2/buckets/${config.bucketName}/objects`; +} + +function encodeObjectKeyPath(key: string): string { + return key + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +function getFallbackPrefix(semanticIdentifier: string): string { + return `${DIST_BUILD_ROOT_PREFIX}/${semanticIdentifier}/`; +} + +function getContentType(filePath: string): string { + const lower = filePath.toLowerCase(); + if (lower.endsWith(".html")) return "text/html; charset=utf-8"; + if (lower.endsWith(".js") || lower.endsWith(".mjs")) { + return "application/javascript; charset=utf-8"; + } + if (lower.endsWith(".css")) return "text/css; charset=utf-8"; + if (lower.endsWith(".json")) return "application/json; charset=utf-8"; + if (lower.endsWith(".svg")) return "image/svg+xml"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".gif")) return "image/gif"; + if (lower.endsWith(".ico")) return "image/x-icon"; + if (lower.endsWith(".woff")) return "font/woff"; + if (lower.endsWith(".woff2")) return "font/woff2"; + if (lower.endsWith(".ttf")) return "font/ttf"; + if (lower.endsWith(".map")) return "application/json; charset=utf-8"; + return "application/octet-stream"; +} + +async function listR2KeysByPrefix(config: R2Config, prefix: string) { + const baseUrl = getR2ObjectsBaseUrl(config); + const keys: string[] = []; + let cursor: string | undefined; + + while (true) { + const url = new URL(baseUrl); + url.searchParams.set("prefix", prefix); + url.searchParams.set("limit", "1000"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Authorization: `Bearer ${config.apiToken}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to list R2 objects for prefix ${prefix}: ${response.status} ${text}`, + ); + } + + const body = (await response.json()) as { + result?: { + objects?: Array<{ key?: string }>; + cursor?: string; + result_info?: { cursor?: string }; + }; + }; + + const result = body.result ?? {}; + const batchKeys = (result.objects ?? []) + .map((item) => item.key) + .filter((item): item is string => Boolean(item)); + + keys.push(...batchKeys); + + const nextCursor = result.cursor ?? result.result_info?.cursor; + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return keys; +} + +async function deleteR2Keys(config: R2Config, keys: string[]) { + if (keys.length === 0) { + return; + } + + const baseUrl = getR2ObjectsBaseUrl(config); + + for (const key of keys) { + const response = await fetch(`${baseUrl}/${encodeObjectKeyPath(key)}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${config.apiToken}`, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to delete R2 object ${key}: ${response.status} ${text}`, + ); + } + } +} + +async function deleteR2Prefix(config: R2Config, prefix: string) { + const keys = await listR2KeysByPrefix(config, prefix); + await deleteR2Keys(config, keys); +} + +// --- SigV4 presigning (query-string auth) for R2's S3 API ------------------- + +function sha256Hex(data: string): string { + return createHash("sha256").update(data, "utf8").digest("hex"); +} + +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac("sha256", key).update(data, "utf8").digest(); +} + +/** RFC 3986 encoding as required by SigV4 canonical URIs/query strings. */ +function awsUriEncode(value: string, encodeSlash: boolean): string { + let out = ""; + for (const char of value) { + if (/[A-Za-z0-9\-._~]/.test(char)) { + out += char; + } else if (char === "/" && !encodeSlash) { + out += char; + } else { + out += Array.from(Buffer.from(char, "utf8")) + .map((b) => `%${b.toString(16).toUpperCase().padStart(2, "0")}`) + .join(""); + } + } + return out; +} + +function presignR2PutUrl(opts: { + accountId: string; + bucketName: string; + credentials: R2S3Credentials; + key: string; + expiresSeconds: number; + now?: Date; +}): string { + const { accountId, bucketName, credentials, key, expiresSeconds } = opts; + const host = `${accountId}.r2.cloudflarestorage.com`; + const region = "auto"; + const service = "s3"; + + const now = opts.now ?? new Date(); + const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); // YYYYMMDDTHHMMSSZ + const dateStamp = amzDate.slice(0, 8); + const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; + + const canonicalUri = `/${awsUriEncode(bucketName, false)}/${awsUriEncode(key, false)}`; + + const queryParams: Array<[string, string]> = [ + ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"], + ["X-Amz-Credential", `${credentials.accessKeyId}/${credentialScope}`], + ["X-Amz-Date", amzDate], + ["X-Amz-Expires", String(expiresSeconds)], + ["X-Amz-SignedHeaders", "host"], + ]; + const canonicalQueryString = queryParams + .map(([k, v2]) => `${awsUriEncode(k, true)}=${awsUriEncode(v2, true)}`) + .sort() + .join("&"); + + const canonicalRequest = [ + "PUT", + canonicalUri, + canonicalQueryString, + `host:${host}\n`, + "host", + "UNSIGNED-PAYLOAD", + ].join("\n"); + + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join("\n"); + + const kDate = hmac(`AWS4${credentials.secretAccessKey}`, dateStamp); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + const kSigning = hmac(kService, "aws4_request"); + const signature = createHmac("sha256", kSigning) + .update(stringToSign, "utf8") + .digest("hex"); + + return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`; +} + +// --- Sandbox-side upload ---------------------------------------------------- + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Build a shell script that uploads each dist file from inside the sandbox + * directly to R2 via presigned PUT URLs. The dist bytes never transit the + * Convex action, which previously accounted for the bulk of this function's + * data egress. + */ +function buildUploadScript( + uploads: Array<{ relativePath: string; url: string; contentType: string }>, +): string { + const lines = [ + "#!/bin/bash", + "set -u", + "cd isolate", + "fail=0", + "upload() {", + ' local p="$1"; local u="$2"; local ct="$3"', + " for attempt in 1 2 3; do", + ' if curl --fail --silent --show-error -X PUT -H "Content-Type: $ct" --upload-file "$p" "$u"; then', + " return 0", + " fi", + " sleep 1", + " done", + ' echo "UPLOAD FAILED: $p" >&2', + " return 1", + "}", + ]; + for (const upload of uploads) { + lines.push( + `upload ${shellQuote(upload.relativePath)} ${shellQuote(upload.url)} ${shellQuote(upload.contentType)} || fail=1`, + ); + } + lines.push('exit "$fail"'); + return lines.join("\n") + "\n"; +} + +async function listDistFiles(codebase: Codebase): Promise { + const result = await codebase.runCommandThrow( + `cd isolate && find . -type f | sed 's|^\\./||'`, + 30_000, + ); + return result.output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +/** Upload directly from the sandbox to R2 using presigned URLs. */ +async function uploadDistFromSandbox( + codebase: Codebase, + config: R2Config, + s3Credentials: R2S3Credentials, + semanticIdentifier: string, +): Promise<{ fileCount: number; uploadedKeys: string[] }> { + const relativePaths = await listDistFiles(codebase); + if (relativePaths.length === 0) { + throw new Error("Dist build produced no files to upload"); + } + + const prefix = getFallbackPrefix(semanticIdentifier); + const uploads = relativePaths.map((relativePath) => ({ + relativePath, + url: presignR2PutUrl({ + accountId: config.accountId, + bucketName: config.bucketName, + credentials: s3Credentials, + key: `${prefix}${relativePath}`, + expiresSeconds: PRESIGN_EXPIRY_SECONDS, + }), + contentType: getContentType(relativePath), + })); + + await codebase.writeFile(UPLOAD_SCRIPT_PATH, buildUploadScript(uploads)); + try { + // Timeout scales with file count; uploads run sequentially with retries. + const timeoutMs = Math.max(120_000, uploads.length * 5_000); + await codebase.runCommandThrow(`bash ${UPLOAD_SCRIPT_PATH}`, timeoutMs); + } finally { + try { + await codebase.deleteFile(UPLOAD_SCRIPT_PATH); + } catch { + // Best effort cleanup; a leftover script contains only expired URLs. + } + } + + return { + fileCount: uploads.length, + uploadedKeys: uploads.map((u) => `${prefix}${u.relativePath}`), + }; +} + +/** + * Legacy path: download every dist file into the action and PUT it to R2. + * Only used when R2 S3 credentials are not configured. Expensive — the whole + * dist transits the Convex action (egress + compute). + */ +async function uploadDistThroughAction( + codebase: Codebase & { prepareForDeployment: () => Promise }, + config: R2Config, + semanticIdentifier: string, +): Promise<{ fileCount: number }> { + const files = await codebase.prepareForDeployment(); + const baseUrl = getR2ObjectsBaseUrl(config); + const prefix = getFallbackPrefix(semanticIdentifier); + + await deleteR2Prefix(config, prefix); + + for (const file of files) { + const key = `${prefix}${file.file}`; + const response = await fetch(`${baseUrl}/${encodeObjectKeyPath(key)}`, { + method: "PUT", + headers: { + Authorization: `Bearer ${config.apiToken}`, + "Content-Type": getContentType(file.file), + }, + body: new Uint8Array(file.content), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Failed to upload fallback dist file ${key}: ${response.status} ${text}`, + ); + } + } + + return { fileCount: files.length }; +} + +export const publishFallbackDist = internalAction({ + args: { + projectId: v.id("project"), + force: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + if (!project) { + throw new Error("Project not found"); + } + + if (!project.sandbox_id?.startsWith("daytona:")) { + return null; + } + + const now = Date.now(); + const force = args.force === true; + const lastDistBuildAt = project.last_dist_build_at ?? 0; + if (!force && now - lastDistBuildAt <= DIST_REFRESH_WINDOW_MS) { + return null; + } + + const migration = await ctx.runQuery(internal.project.getProjectDaytonaMigration, { + projectId: project._id, + }); + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + migration?.daytona_server ?? "legacy", + ); + + if (!hasPackageManager(codebase)) { + throw new Error("Codebase does not support package manager detection"); + } + + if (!isVercelDeployable(codebase)) { + throw new Error("Codebase does not support deployment artifact preparation"); + } + + const packageManager = codebase.getPackageManager(); + + const framework = await detectFramework(codebase); + if (framework === "nextjs") { + throw new Error( + "Fallback dist publishing is not supported for Next.js projects yet.", + ); + } + if (framework === "unsupported") { + throw new Error( + "Fallback dist publishing is not supported for this project type yet. Only Vite projects are currently supported.", + ); + } + + console.log("[FallbackDist] Starting dist build", { + projectId: project._id, + semanticIdentifier: project.semantic_identifier, + lastDistBuildAt, + force, + framework, + }); + + const tscResult = await codebase.runCommand( + packageManager.run("tsc -b"), + 60_000, + ); + if (tscResult.exitCode !== 0) { + console.warn("[FallbackDist] TypeScript errors in project (build continues):", tscResult.output); + } + + await codebase.runCommandThrow( + packageManager.run("vite build"), + 120_000, + ); + + await codebase.runCommandThrow( + "mkdir -p isolate && find isolate -mindepth 1 -maxdepth 1 -exec rm -rf {} + && cp -R dist/* isolate", + 20_000, + ); + + const config = getR2Config(); + const prefix = getFallbackPrefix(project.semantic_identifier); + const s3Credentials = getR2S3Credentials(); + + let fileCount: number; + if (s3Credentials) { + // Upload new files first, then delete only stale keys, so a failed + // upload never leaves the fallback prefix empty. + const existingKeys = await listR2KeysByPrefix(config, prefix); + const result = await uploadDistFromSandbox( + codebase, + config, + s3Credentials, + project.semantic_identifier, + ); + fileCount = result.fileCount; + + const uploadedKeySet = new Set(result.uploadedKeys); + const staleKeys = existingKeys.filter((key) => !uploadedKeySet.has(key)); + await deleteR2Keys(config, staleKeys); + } else { + console.warn( + "[FallbackDist] R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY not set; falling back to uploading through the Convex action (expensive egress). Configure R2 S3 credentials to enable direct sandbox-to-R2 uploads.", + ); + const result = await uploadDistThroughAction( + codebase as any, + config, + project.semantic_identifier, + ); + fileCount = result.fileCount; + } + + await ctx.runMutation(internal.project.setLastDistBuildAt, { + projectId: project._id, + lastDistBuildAt: Date.now(), + }); + + console.log("[FallbackDist] Dist publish complete", { + projectId: project._id, + semanticIdentifier: project.semantic_identifier, + fileCount, + directUpload: !!s3Credentials, + }); + + return null; + }, +}); diff --git a/freebuff/web/convex/featureFlags.ts b/freebuff/web/convex/featureFlags.ts new file mode 100644 index 0000000000..e3fb11aabd --- /dev/null +++ b/freebuff/web/convex/featureFlags.ts @@ -0,0 +1,460 @@ +import { v } from "convex/values"; +import { mutation, query, internalQuery } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { getAuthUser } from "./users"; +import { Doc } from "./_generated/dataModel"; + +/** + * Feature flag keys used throughout the application + * Note: Most feature flags have been removed as features are now always enabled. + * Only organizations_enabled remains as an active feature flag. + */ +export const FEATURE_FLAGS = { + ORGANIZATIONS: "organizations_enabled", + REFERRALS: "referrals_enabled", + USAGE_TAB: "usage_tab_enabled", + STATS_MONITORING_ENABLED: "stats_monitoring_enabled", + BILLING_ENFORCEMENT: "billing_enforcement", + // Gates the WebContainer-backed project creation path (in-browser + // sandboxing) as a replacement for Daytona pooled sandboxes. Defaults to + // DISABLED so production /web keeps creating Daytona projects. Admins can + // test WebContainer via /web/test (explicit opt-in in codesandbox.create), + // or the flag can be enabled later via a DB record / env var for rollout. + WEBCONTAINER_PROJECTS: "webcontainer_projects_enabled", + // Add more feature flags as needed +} as const; + +/** + * Flags that are enabled by default when no DB record or env var is present. + * Everything not listed here defaults to false (safe/off). + */ +const FEATURE_DEFAULTS: Record = { + [FEATURE_FLAGS.WEBCONTAINER_PROJECTS]: false, +}; + +/** + * Simple deterministic hash function for percentage-based rollouts + * Uses a combination of user ID and flag key to ensure consistent rollout behavior + */ +function simpleHash(userId: string, flagKey: string): number { + const combined = `${userId}:${flagKey}`; + let hash = 0; + for (let i = 0; i < combined.length; i++) { + const char = combined.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash); +} + +/** + * Evaluate if a user has access to a feature based on the flag's rollout strategy + */ +function evaluateFeatureAccess( + user: Doc<"users"> | null, + flag: Doc<"feature_flags">, +): boolean { + const { rollout_strategy, rollout_percentage } = flag; + + switch (rollout_strategy) { + case "disabled": + return false; + + case "god_only": + return user?.role === "god"; + + case "beta": + return user?.role === "god" || user?.is_beta === true; + + case "percentage": + if (!user) return false; + // God and beta users always get access + if (user.role === "god" || user.is_beta === true) { + return true; + } + // For other users, use deterministic hashing + if (rollout_percentage === undefined || rollout_percentage === null) { + return false; // No percentage set, deny access + } + const hash = simpleHash(user._id, flag.key); + const userPercentile = hash % 100; + return userPercentile < rollout_percentage; + + case "enabled": + return true; + + default: + return false; + } +} + +/** + * Check if a feature is enabled for the current user (or a specified user) + * Falls back to environment variable if not in database + * + * @param userId - Optional user ID to check feature flag for. If not provided, uses authenticated user. + * Useful in scheduled functions where auth context is not available. + */ +export const isEnabled = internalQuery({ + args: { + key: v.string(), + userId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + // Get the user - either from provided userId or authenticated context + let user: Doc<"users"> | null = null; + + if (args.userId) { + // Fetch user directly by ID (for scheduled functions) + user = await ctx.db.get(args.userId); + } else { + // Get authenticated user (for normal queries/mutations) + user = await getAuthUser(ctx); + } + + // First check database + const flag = await ctx.db + .query("feature_flags") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + if (flag !== null) { + return evaluateFeatureAccess(user, flag); + } + + // Fallback to environment variable (legacy support) + // Convert key to env var format: billing_enforcement -> FEATURE_BILLING_ENFORCEMENT + const envKey = `FEATURE_${args.key.toUpperCase()}`; + const envValue = process.env[envKey]; + + // Explicit env var wins; otherwise fall back to per-flag default. + if (envValue !== undefined) return envValue === "true"; + return FEATURE_DEFAULTS[args.key] ?? false; + }, +}); + +/** + * Batch check if multiple features are enabled for the current user (or a specified user) + * More efficient than multiple individual isEnabled calls + * + * @param keys - Array of feature flag keys to check + * @param userId - Optional user ID to check feature flags for. If not provided, uses authenticated user. + * @returns Record mapping each key to its enabled status + */ +export const batchIsEnabled = internalQuery({ + args: { + keys: v.array(v.string()), + userId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + // Get the user - either from provided userId or authenticated context + let user: Doc<"users"> | null = null; + + if (args.userId) { + // Fetch user directly by ID (for scheduled functions) + user = await ctx.db.get(args.userId); + } else { + // Get authenticated user (for normal queries/mutations) + user = await getAuthUser(ctx); + } + + // Fetch all feature flags from database in one query + const allFlags = await ctx.db.query("feature_flags").collect(); + const flagsMap = new Map(allFlags.map((flag) => [flag.key, flag])); + + // Build result map for each requested key + const result: Record = {}; + + for (const key of args.keys) { + const flag = flagsMap.get(key); + + if (flag !== null && flag !== undefined) { + // Use database flag + result[key] = evaluateFeatureAccess(user, flag); + } else { + // Fallback to environment variable, then per-flag default. + const envKey = `FEATURE_${key.toUpperCase()}`; + const envValue = process.env[envKey]; + result[key] = + envValue !== undefined + ? envValue === "true" + : (FEATURE_DEFAULTS[key] ?? false); + } + } + + console.log( + `[FeatureFlags] Batch check for user ${args.userId || "authenticated"}:`, + result, + ); + + return result; + }, +}); + +// Internal cacheable version - accepts clerkId to enable caching +export const checkFeatureInternal = internalQuery({ + args: { + key: v.string(), + clerkId: v.optional(v.string()), + }, + handler: async ( + ctx, + args, + ): Promise<{ + enabled: boolean; + description?: string; + rollout_strategy: + | "disabled" + | "god_only" + | "beta" + | "percentage" + | "enabled"; + rollout_percentage?: number; + }> => { + // Get user if clerkId provided + let user: Doc<"users"> | null = null; + if (args.clerkId !== undefined) { + const clerkId = args.clerkId; + user = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerk_id", clerkId)) + .unique(); + } + + // First check database + const flag = await ctx.db + .query("feature_flags") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + if (flag !== null) { + const enabled = evaluateFeatureAccess(user, flag); + return { + enabled, + description: flag.description, + rollout_strategy: flag.rollout_strategy, + rollout_percentage: flag.rollout_percentage, + }; + } + + // Fallback to environment variable, then per-flag default. + const envKey = `FEATURE_${args.key.toUpperCase()}`; + const envValue = process.env[envKey]; + const effectiveEnabled = + envValue !== undefined + ? envValue === "true" + : (FEATURE_DEFAULTS[args.key] ?? false); + + return { + enabled: effectiveEnabled, + description: + envValue !== undefined + ? `Controlled by ${envKey} environment variable` + : `Default value for ${args.key}`, + rollout_strategy: effectiveEnabled + ? ("enabled" as const) + : ("disabled" as const), + rollout_percentage: undefined, + }; + }, +}); + +/** + * Public query to check if a feature is enabled for the current user + * Can be called from frontend + */ +export const checkFeature = query({ + args: { + key: v.string(), + }, + handler: async ( + ctx, + args, + ): Promise<{ + enabled: boolean; + description?: string; + rollout_strategy: + | "disabled" + | "god_only" + | "beta" + | "percentage" + | "enabled"; + rollout_percentage?: number; + }> => { + // Get JWT identity once + const identity = await ctx.auth.getUserIdentity(); + + // Delegate to internal cached version + return await ctx.runQuery(internal.featureFlags.checkFeatureInternal, { + key: args.key, + clerkId: identity?.subject ?? undefined, + }); + }, +}); + +/** + * Get all feature flags + */ +export const getAllFlags = query({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: Admin access required"); + } + + const flags = await ctx.db.query("feature_flags").collect(); + return flags; + }, +}); + +/** + * Set a feature flag value + * Requires god role authentication + */ +export const setFlag = mutation({ + args: { + key: v.string(), + rollout_strategy: v.union( + v.literal("disabled"), + v.literal("god_only"), + v.literal("beta"), + v.literal("percentage"), + v.literal("enabled"), + ), + rollout_percentage: v.optional(v.number()), + description: v.optional(v.string()), + categories: v.optional(v.array(v.string())), + runbook: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Require god role for flag modifications + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error( + "Unauthorized: God role required to modify feature flags", + ); + } + + // Validate percentage if strategy is "percentage" + if (args.rollout_strategy === "percentage") { + if ( + args.rollout_percentage === undefined || + args.rollout_percentage < 0 || + args.rollout_percentage > 100 + ) { + throw new Error( + "rollout_percentage must be between 0 and 100 when strategy is 'percentage'", + ); + } + } + + const userId = user._id; + + // Check if flag exists + const existingFlag = await ctx.db + .query("feature_flags") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + const now = Date.now(); + + if (existingFlag) { + // Update existing flag + await ctx.db.patch(existingFlag._id, { + rollout_strategy: args.rollout_strategy, + rollout_percentage: args.rollout_percentage, + description: args.description, + categories: args.categories, + runbook: args.runbook, + updated_at: now, + updated_by: userId, + }); + return { + ...existingFlag, + rollout_strategy: args.rollout_strategy, + rollout_percentage: args.rollout_percentage, + updated_at: now, + }; + } else { + // Create new flag + const flagId = await ctx.db.insert("feature_flags", { + key: args.key, + rollout_strategy: args.rollout_strategy, + rollout_percentage: args.rollout_percentage, + description: args.description, + categories: args.categories, + runbook: args.runbook, + updated_at: now, + updated_by: userId, + }); + return { + _id: flagId, + key: args.key, + rollout_strategy: args.rollout_strategy, + rollout_percentage: args.rollout_percentage, + }; + } + }, +}); + +/** + * Delete a feature flag + * Requires god role authentication + */ +export const deleteFlag = mutation({ + args: { + flagId: v.id("feature_flags"), + }, + handler: async (ctx, args) => { + // Require god role for flag deletion + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error( + "Unauthorized: God role required to delete feature flags", + ); + } + + console.log(`User ${user.email} deleting feature flag ${args.flagId}`); + + await ctx.db.delete(args.flagId); + return { success: true }; + }, +}); + +/** + * Toggle beta status for a user + * Users can toggle their own beta status + * God role required to toggle other users' beta status + */ +export const toggleUserBeta = mutation({ + args: { + userId: v.id("users"), + isBeta: v.boolean(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized: Must be signed in"); + } + + // Users can toggle their own beta status + // God role required to toggle other users' beta status + const isTogglingOwnStatus = user._id === args.userId; + if (!isTogglingOwnStatus && user.role !== "god") { + throw new Error( + "Unauthorized: God role required to modify other users' beta status", + ); + } + + console.log( + `User ${user.email} setting beta status for user ${args.userId} to ${args.isBeta}`, + ); + + await ctx.db.patch(args.userId, { + is_beta: args.isBeta, + }); + + return { success: true, userId: args.userId, isBeta: args.isBeta }; + }, +}); diff --git a/freebuff/web/convex/github/auth.ts b/freebuff/web/convex/github/auth.ts new file mode 100644 index 0000000000..7a84180fc4 --- /dev/null +++ b/freebuff/web/convex/github/auth.ts @@ -0,0 +1,881 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { + action, + internalAction, + internalMutation, + internalQuery, +} from "../_generated/server"; +import { GITHUB_APP_CONFIG } from "./config"; + +/** + * Store GitHub connection + */ + +/** + * Handle GitHub OAuth callback (Step 1: User identification) + */ +export const handleGitHubOAuthCallback = action({ + args: { + code: v.string(), + state: v.string(), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + redirectUrl: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + redirectUrl?: string; + }> => { + const stateInfo: { + user_id: any; + state_id: any; + return_url?: string; + } | null = await ctx.runQuery( + internal.github.auth.verifyOAuthStateInternal, + { + state: args.state, + }, + ); + + if (!stateInfo) { + // Detailed logging already done in verifyOAuthStateInternal + // This is likely a user-caused error (refresh, back button, expired state) + console.warn( + `[OAuth Callback Failed] reason=invalid_state, state_prefix=${args.state.substring(0, 8)}, note=see_state_validation_logs_above`, + ); + throw new Error("Invalid OAuth state"); + } + + const user = await ctx.runQuery(internal.users.get, { + userId: stateInfo.user_id, + }); + if (!user) { + console.error( + `[OAuth Callback Failed] reason=state_user_not_found, user_id=${stateInfo.user_id}, state_prefix=${args.state.substring(0, 8)}`, + ); + throw new Error("OAuth state user not found"); + } + + console.log( + `[OAuth Callback Started] user_email=${user.email}, user_id=${user._id}, state_prefix=${args.state.substring(0, 8)}`, + ); + + try { + // Exchange code for access token + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error("GitHub OAuth credentials not configured"); + } + + const tokenResponse = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code: args.code, + }), + }, + ); + + const tokenData = await tokenResponse.json(); + + if (tokenData.error) { + const errorDesc = tokenData.error_description || tokenData.error; + // Common errors: "bad_verification_code" (code expired/reused), "incorrect_client_credentials", etc. + const isExpiredCode = + errorDesc.includes("expired") || errorDesc.includes("incorrect"); + console.warn( + `[OAuth Code Exchange Failed] user_email=${user.email}, user_id=${user._id}, error=${tokenData.error}, description=${errorDesc}, likely_cause=${isExpiredCode ? "code_reused_or_expired" : "unknown"}`, + ); + throw new Error(`OAuth error: ${errorDesc}`); + } + + // Validate scopes if provided (GitHub doesn't always return scope field for basic OAuth) + if (tokenData.scope) { + const grantedScopes = tokenData.scope.split(" "); + const requiredScopes = ["read:user", "repo"]; + + // Verify all required scopes are present + for (const requiredScope of requiredScopes) { + if (!grantedScopes.includes(requiredScope)) { + throw new Error(`Missing required scope: ${requiredScope}`); + } + } + + // Check for unexpected scopes (security concern) + const allowedScopes = ["read:user", "repo"]; + const unexpectedScopes = grantedScopes.filter( + (scope: string) => !allowedScopes.includes(scope), + ); + + if (unexpectedScopes.length > 0) { + console.warn("Unexpected OAuth scopes granted:", unexpectedScopes); + // Log but don't fail - GitHub may add additional scopes + } + + console.log("OAuth scopes validated:", grantedScopes); + } else { + // GitHub OAuth Apps don't always return explicit scopes + // The requested scopes (read:user, repo) are implicit for basic OAuth + console.log( + "No explicit scopes returned by GitHub - using implicit read:user,repo access", + ); + } + + const accessToken = tokenData.access_token; + + // Get user info from GitHub + const userResponse = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github.v3+json", + }, + }); + + const userData = await userResponse.json(); + + if (!userResponse.ok) { + const errorMsg = userData.message || "Unknown error"; + const statusCode = userResponse.status; + // 401 = Bad credentials (token invalid/revoked), 403 = Rate limit, 404 = API issue + const likelyCause = + statusCode === 401 + ? "token_invalid_or_revoked" + : statusCode === 403 + ? "rate_limit_or_token_permissions" + : "github_api_error"; + console.error( + `[GitHub User Info Fetch Failed] user_email=${user.email}, user_id=${user._id}, http_status=${statusCode}, error=${errorMsg}, likely_cause=${likelyCause}`, + ); + throw new Error(`Failed to get user info: ${errorMsg}`); + } + + console.log( + `[GitHub User Info Fetched] user_email=${user.email}, github_username=${userData.login}, github_user_id=${userData.id}`, + ); + + // Store the OAuth connection temporarily (without installation_id) + await ctx.runMutation( + internal.github.auth.storeGitHubConnectionInternal, + { + user_id: stateInfo.user_id, + github_user_id: userData.id.toString(), + github_username: userData.login, + access_token: accessToken, + refresh_token: tokenData.refresh_token, + token_expires_at: tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined, + installation_id: undefined, // Will be set when app is installed + installation_token: undefined, // Will be set when app is installed + installation_token_expires_at: undefined, // Will be set when app is installed + }, + ); + + console.log("OAuth user identified successfully"); + + // Mark OAuth state as temporarily used to prevent replay attacks during the installation flow + // This prevents the same state from being reused between OAuth and installation callbacks + await ctx.runMutation( + internal.github.auth.markOAuthStateAsTemporaryUsed, + { + stateId: stateInfo.state_id, + }, + ); + + // Step 2: Redirect to GitHub App installation + const redirectUri = + process.env.GITHUB_REDIRECT_URI || + "http://localhost:3000/github/callback"; + const appInstallUrl = `https://github.com/apps/${GITHUB_APP_CONFIG.APP_SLUG}/installations/new?state=${args.state}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + console.log( + "the env var redirect uri is " + process.env.GITHUB_REDIRECT_URI, + ); + + return { + success: true, + message: "User identified successfully", + redirectUrl: appInstallUrl, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "OAuth callback failed"; + console.error( + `[OAuth Callback Error] user_email=${user.email}, user_id=${user._id}, error=${errorMessage}`, + error, + ); + return { + success: false, + message: errorMessage, + }; + } + }, +}); + +/** + * Refresh GitHub OAuth token + */ +export const refreshGitHubToken = internalAction({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.union( + v.object({ + success: v.boolean(), + access_token: v.string(), + refresh_token: v.string(), + expires_in: v.number(), + }), + v.null(), + ), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + access_token: string; + refresh_token: string; + expires_in: number; + } | null> => { + const connection = await ctx.runQuery( + internal.github.auth.getConnectionById, + { connectionId: args.connectionId }, + ); + + if (!connection || !connection.refresh_token) { + return null; + } + + try { + const response: Response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + grant_type: "refresh_token", + refresh_token: connection.refresh_token, + }), + }, + ); + + const tokenData: any = await response.json(); + + if (!response.ok || tokenData.error) { + console.error("Token refresh failed:", tokenData); + return null; + } + + // Update the connection with new tokens + await ctx.runMutation(internal.github.auth.updateConnectionTokens, { + connectionId: args.connectionId, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_in: tokenData.expires_in, + }); + + return { + success: true, + access_token: tokenData.access_token, + refresh_token: tokenData.refresh_token, + expires_in: tokenData.expires_in, + }; + } catch (error) { + console.error("Token refresh error:", error); + return null; + } + }, +}); + +/** + * Get user's GitHub connection + */ + +/** + * Internal function to get GitHub connection with tokens + */ +export const getGitHubConnectionWithTokensInternal = internalQuery({ + args: { + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("github_connections"), + access_token: v.string(), + refresh_token: v.optional(v.string()), + github_username: v.string(), + github_user_id: v.string(), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + + if (!connection) return null; + + // Check if access token is expired + const now = Date.now(); + if (connection.token_expires_at && connection.token_expires_at <= now) { + console.log("Access token expired for user:", args.userId); + // TODO: Trigger token refresh automatically + } + + // Check if installation token is expired + if ( + connection.installation_token_expires_at && + connection.installation_token_expires_at <= now + ) { + console.log("Installation token expired for user:", args.userId); + // TODO: Refresh installation token automatically + } + + return { + _id: connection._id, + access_token: connection.access_token, + refresh_token: connection.refresh_token, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + installation_token: connection.installation_token, + installation_token_expires_at: connection.installation_token_expires_at, + }; + }, +}); + +/** + * Internal function to verify OAuth state + */ +export const verifyOAuthStateInternal = internalQuery({ + args: { + state: v.string(), + userId: v.optional(v.id("users")), + isInstallationCallback: v.optional(v.boolean()), + }, + returns: v.union( + v.object({ + user_id: v.id("users"), + state_id: v.id("oauth_states"), + return_url: v.optional(v.string()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const stateRecord = await ctx.db + .query("oauth_states") + .withIndex("by_state", (q) => q.eq("state", args.state)) + .first(); + + const statePrefix = args.state.substring(0, 8); + const callbackType = args.isInstallationCallback ? "installation" : "oauth"; + + // State not found - likely expired and cleaned up + if (!stateRecord) { + console.warn( + `[OAuth State Validation Failed] reason=state_not_found, callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${args.userId}, likely_cause=expired_or_cleaned_up`, + ); + return null; + } + + const stateAgeMinutes = Math.floor( + (Date.now() - stateRecord.created_at) / 60000, + ); + + const effectiveUserId = args.userId ?? stateRecord.user_id; + + // User mismatch - someone trying to use another user's state + if (args.userId && stateRecord.user_id !== args.userId) { + console.warn( + `[OAuth State Validation Failed] reason=user_mismatch, callback_type=${callbackType}, state_prefix=${statePrefix}, expected_user=${stateRecord.user_id}, actual_user=${args.userId}, state_age_minutes=${stateAgeMinutes}, likely_cause=wrong_user_session`, + ); + return null; + } + + // Check if state has already been used to prevent replay attacks + if (stateRecord.used) { + console.warn( + `[OAuth State Validation Failed] reason=already_used, callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${args.userId}, state_age_minutes=${stateAgeMinutes}, likely_cause=page_refresh_or_back_button`, + ); + return null; + } + + // Check if state has been temporarily used + if (stateRecord.temp_used) { + // Allow installation callback to reuse temporarily used state within 5 minutes + if (args.isInstallationCallback) { + const tempUsedAt = stateRecord.temp_used_at || 0; + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + + if (tempUsedAt < fiveMinutesAgo) { + console.warn( + `[OAuth State Validation Failed] reason=temp_state_expired, callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${effectiveUserId}, state_age_minutes=${stateAgeMinutes}, likely_cause=installation_took_too_long`, + ); + return null; + } + + console.log( + `[OAuth State Validation Success] callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${effectiveUserId}, state_age_minutes=${stateAgeMinutes}, note=reusing_temp_state_for_installation`, + ); + } else { + // Reject reuse for OAuth callback (prevent replay attacks) + console.warn( + `[OAuth State Validation Failed] reason=temp_state_reuse_attempt, callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${effectiveUserId}, state_age_minutes=${stateAgeMinutes}, likely_cause=page_refresh_during_flow`, + ); + return null; + } + } else { + // Normal case - state is valid and unused + console.log( + `[OAuth State Validation Success] callback_type=${callbackType}, state_prefix=${statePrefix}, user_id=${effectiveUserId}, state_age_minutes=${stateAgeMinutes}`, + ); + } + + return { + user_id: effectiveUserId, + state_id: stateRecord._id, + return_url: stateRecord.return_url, + }; + }, +}); + +/** + * Internal function to store GitHub connection + */ +export const storeGitHubConnectionInternal = internalMutation({ + args: { + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + access_token: v.string(), + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existingConnection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.user_id)) + .first(); + + const connectionData = { + user_id: args.user_id, + github_user_id: args.github_user_id, + github_username: args.github_username, + access_token: args.access_token, + refresh_token: args.refresh_token, + token_expires_at: args.token_expires_at, + installation_id: args.installation_id, + installation_token: args.installation_token, + installation_token_expires_at: args.installation_token_expires_at, + created_at: existingConnection + ? existingConnection.created_at + : Date.now(), + updated_at: Date.now(), + }; + + if (existingConnection) { + await ctx.db.patch(existingConnection._id, connectionData); + console.log("Updated existing GitHub connection for user:", args.user_id); + } else { + await ctx.db.insert("github_connections", connectionData); + console.log("Created new GitHub connection for user:", args.user_id); + } + return null; + }, +}); + +/** + * Get project details + */ +export const getProjectDetails = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.projectId); + }, +}); + +/** + * Get project owner + */ +export const getProjectOwner = internalQuery({ + args: { + projectId: v.id("project"), + }, + returns: v.union( + v.object({ + user: v.id("users"), + project_role: v.union( + v.literal("member"), + v.literal("admin"), + v.literal("owner"), + ), + }), + v.null(), + ), + handler: async (ctx, args) => { + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => q.eq("project", args.projectId)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .first(); + + if (!projectMember) { + return null; + } + + return { + user: projectMember.user, + project_role: projectMember.project_role, + }; + }, +}); + +/** + * Get sync states by repository + */ +export const getSyncStatesByRepo = internalQuery({ + args: { + repoOwner: v.string(), + repoName: v.string(), + }, + returns: v.array( + v.object({ + project_id: v.id("project"), + sync_direction: v.literal("bidirectional"), + }), + ), + handler: async (ctx, args) => { + const syncStates = await ctx.db + .query("github_sync_state") + .withIndex("by_repo", (q) => + q + .eq("github_repo_owner", args.repoOwner) + .eq("github_repo_name", args.repoName), + ) + .collect(); + + return syncStates.map((syncState) => ({ + project_id: syncState.project_id, + sync_direction: syncState.sync_direction, + })); + }, +}); + +/** + * Get sync state by project ID + */ +export const getSyncStateByProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + returns: v.union( + v.object({ + _id: v.id("github_sync_state"), + _creationTime: v.number(), + project_id: v.id("project"), + github_repo_name: v.string(), + github_repo_owner: v.string(), + last_sync_time: v.number(), + sync_direction: v.literal("bidirectional"), + last_commit_hash: v.optional(v.string()), + sync_status: v.union( + v.literal("synced"), + v.literal("pending"), + v.literal("error"), + v.literal("conflict"), + ), + error_message: v.optional(v.string()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const syncState = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + return syncState; + }, +}); + +/** + * Get GitHub connections by GitHub user ID + */ +export const getGitHubConnectionsByGitHubUserId = internalQuery({ + args: { + githubUserId: v.string(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .withIndex("by_github_user_id", (q) => + q.eq("github_user_id", args.githubUserId), + ) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, +}); + +/** + * Get GitHub connections by GitHub user ID without installation ID + */ +export const getGitHubConnectionsByGitHubUserIdWithoutInstallation = + internalQuery({ + args: { + githubUserId: v.string(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .withIndex("by_github_user_id", (q) => + q.eq("github_user_id", args.githubUserId), + ) + .filter((q) => q.eq(q.field("installation_id"), undefined)) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, + }); + +/** + * Get GitHub connections by installation ID + */ +export const getGitHubConnectionsByInstallationId = internalQuery({ + args: { + installationId: v.number(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .filter((q) => q.eq(q.field("installation_id"), args.installationId)) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, +}); + +/** + * Get GitHub connection by ID + */ +export const getConnectionById = internalQuery({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.union( + v.object({ + _id: v.id("github_connections"), + access_token: v.string(), + refresh_token: v.optional(v.string()), + github_username: v.string(), + github_user_id: v.string(), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const connection = await ctx.db.get(args.connectionId); + if (!connection) return null; + + return { + _id: connection._id, + access_token: connection.access_token, + refresh_token: connection.refresh_token, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + installation_token: connection.installation_token, + installation_token_expires_at: connection.installation_token_expires_at, + }; + }, +}); + +/** + * Update GitHub connection tokens + */ +export const updateConnectionTokens = internalMutation({ + args: { + connectionId: v.id("github_connections"), + access_token: v.string(), + refresh_token: v.string(), + expires_in: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + access_token: args.access_token, + refresh_token: args.refresh_token, + token_expires_at: Date.now() + args.expires_in * 1000, + updated_at: Date.now(), + }); + return null; + }, +}); + +/** + * Internal function to update GitHub connection with installation ID + */ +export const updateGitHubConnectionWithInstallation = internalMutation({ + args: { + connectionId: v.id("github_connections"), + installationId: v.number(), + installationToken: v.string(), + tokenExpiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: args.installationId, + installation_token: args.installationToken, + installation_token_expires_at: args.tokenExpiresAt, + updated_at: Date.now(), + }); + console.log( + "Updated GitHub connection with installation ID:", + args.connectionId, + ); + }, +}); + +/** + * Mark OAuth state as used to prevent reuse + */ +export const markOAuthStateAsUsed = internalMutation({ + args: { + stateId: v.id("oauth_states"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.stateId, { + used: true, + }); + }, +}); + +/** + * Mark OAuth state as temporarily used during installation flow + */ +export const markOAuthStateAsTemporaryUsed = internalMutation({ + args: { + stateId: v.id("oauth_states"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.stateId, { + temp_used: true, + temp_used_at: Date.now(), + }); + }, +}); + +/** + * Update installation ID for a GitHub connection + */ +export const updateInstallationId = internalMutation({ + args: { + connectionId: v.id("github_connections"), + installationId: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: args.installationId, + updated_at: Date.now(), + }); + }, +}); + +/** + * Remove installation ID from a GitHub connection + */ +export const removeInstallationId = internalMutation({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: undefined, + updated_at: Date.now(), + }); + }, +}); diff --git a/freebuff/web/convex/github/auth/connections.ts b/freebuff/web/convex/github/auth/connections.ts new file mode 100644 index 0000000000..62fb4f661d --- /dev/null +++ b/freebuff/web/convex/github/auth/connections.ts @@ -0,0 +1,493 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { + internalMutation, + internalQuery, + mutation, + query, +} from "../../_generated/server"; +import { internal } from "../../_generated/api"; + +/** + * Store GitHub connection + */ +export const storeGitHubConnection = mutation({ + args: { + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + access_token: v.string(), + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Feature access is enforced client-side via useFeatureAccess hook. + // Server-side autumn.check() was incorrectly blocking paying users + // due to Autumn API sync issues, so the hard gate was removed here. + + const existingConnection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.user_id)) + .first(); + + const connectionData = { + user_id: args.user_id, + github_user_id: args.github_user_id, + github_username: args.github_username, + access_token: args.access_token, + refresh_token: args.refresh_token, + token_expires_at: args.token_expires_at, + created_at: existingConnection + ? existingConnection.created_at + : Date.now(), + updated_at: Date.now(), + }; + + if (existingConnection) { + await ctx.db.patch(existingConnection._id, connectionData); + } else { + await ctx.db.insert("github_connections", connectionData); + } + return null; + }, +}); + +/** + * Get GitHub connection + */ +// Internal cacheable version +export const getGitHubConnectionInternal = internalQuery({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + + if (!connection) return null; + + return { + github_username: connection.github_username, + github_user_id: connection.github_user_id, + repositories: connection.repositories, + created_at: connection.created_at, + }; + }, +}); + +export const getGitHubConnection = query({ + args: {}, + handler: async ( + ctx, + _args, + ): Promise<{ + github_username: string; + github_user_id: string; + repositories?: string[]; + created_at: number; + } | null> => { + const user = await getAuthUser(ctx); + if (!user) return null; + + return await ctx.runQuery( + internal.github.auth.connections.getGitHubConnectionInternal, + { userId: user._id }, + ); + }, +}); + +/** + * Disconnect GitHub account + */ +export const disconnectGitHub = mutation({ + args: {}, + returns: v.object({ + success: v.boolean(), + message: v.string(), + }), + handler: async (ctx, _args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Unauthorized"); + + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .first(); + + if (!connection) { + return { + success: false, + message: "No GitHub connection found", + }; + } + + await ctx.db.delete(connection._id); + + return { + success: true, + message: "GitHub account disconnected successfully", + }; + }, +}); + +/** + * Get detailed GitHub connection status + */ +// Internal cacheable version +export const getGitHubConnectionStatusInternal = internalQuery({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + + if (!connection) { + return { + status: "not_connected" as const, + }; + } + + if (connection.installation_id) { + return { + status: "app_installed" as const, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + created_at: connection.created_at, + }; + } else { + return { + status: "user_identified" as const, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + created_at: connection.created_at, + }; + } + }, +}); + +export const getGitHubConnectionStatus = query({ + args: {}, + handler: async ( + ctx, + _args, + ): Promise<{ + status: "not_connected" | "user_identified" | "app_installed"; + github_username?: string; + github_user_id?: string; + installation_id?: number; + created_at?: number; + } | null> => { + const user = await getAuthUser(ctx); + if (!user) return null; + + return await ctx.runQuery( + internal.github.auth.connections.getGitHubConnectionStatusInternal, + { userId: user._id }, + ); + }, +}); + +/** + * Internal function to get GitHub connection with tokens + */ +export const getGitHubConnectionWithTokensInternal = internalQuery({ + args: { + userId: v.id("users"), + }, + returns: v.union( + v.object({ + _id: v.id("github_connections"), + access_token: v.string(), + refresh_token: v.optional(v.string()), + github_username: v.string(), + github_user_id: v.string(), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + token_expires_at: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + + if (!connection) return null; + + return { + _id: connection._id, + access_token: connection.access_token, + refresh_token: connection.refresh_token, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + installation_token: connection.installation_token, + installation_token_expires_at: connection.installation_token_expires_at, + token_expires_at: connection.token_expires_at, + }; + }, +}); + +/** + * Internal function to store GitHub connection + */ +export const storeGitHubConnectionInternal = internalMutation({ + args: { + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + access_token: v.string(), + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existingConnection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.user_id)) + .first(); + + const connectionData = { + user_id: args.user_id, + github_user_id: args.github_user_id, + github_username: args.github_username, + access_token: args.access_token, + refresh_token: args.refresh_token, + token_expires_at: args.token_expires_at, + installation_id: args.installation_id, + installation_token: args.installation_token, + installation_token_expires_at: args.installation_token_expires_at, + created_at: existingConnection + ? existingConnection.created_at + : Date.now(), + updated_at: Date.now(), + }; + + if (existingConnection) { + await ctx.db.patch(existingConnection._id, connectionData); + } else { + await ctx.db.insert("github_connections", connectionData); + } + }, +}); + +/** + * Get GitHub connections by GitHub user ID + */ +export const getGitHubConnectionsByGitHubUserId = internalQuery({ + args: { + githubUserId: v.string(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .withIndex("by_github_user_id", (q) => + q.eq("github_user_id", args.githubUserId), + ) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, +}); + +/** + * Get GitHub connections by GitHub user ID without installation ID + */ +export const getGitHubConnectionsByGitHubUserIdWithoutInstallation = + internalQuery({ + args: { + githubUserId: v.string(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .withIndex("by_github_user_id", (q) => + q.eq("github_user_id", args.githubUserId), + ) + .filter((q) => q.eq(q.field("installation_id"), undefined)) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, + }); + +/** + * Get GitHub connections by installation ID + */ +export const getGitHubConnectionsByInstallationId = internalQuery({ + args: { + installationId: v.number(), + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + github_user_id: v.string(), + github_username: v.string(), + installation_id: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + const connections = await ctx.db + .query("github_connections") + .filter((q) => q.eq(q.field("installation_id"), args.installationId)) + .collect(); + + return connections.map((connection) => ({ + _id: connection._id, + user_id: connection.user_id, + github_user_id: connection.github_user_id, + github_username: connection.github_username, + installation_id: connection.installation_id, + })); + }, +}); + +/** + * Lightweight token validity check (for serverless caching) + * Convex automatically caches query results within a single backend request context + * This reduces redundant database lookups when called multiple times in one workflow + */ +export const checkTokenValidity = internalQuery({ + args: { + userId: v.id("users"), + }, + returns: v.union( + v.object({ + connectionId: v.id("github_connections"), + installation_token: v.optional(v.string()), + installation_token_expired: v.boolean(), + installation_id: v.optional(v.number()), + oauth_token_expired: v.boolean(), + has_refresh_token: v.boolean(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + + if (!connection) return null; + + const now = Date.now(); + + return { + connectionId: connection._id, + installation_token: connection.installation_token, + installation_token_expired: connection.installation_token_expires_at + ? connection.installation_token_expires_at <= now + : false, + installation_id: connection.installation_id, + oauth_token_expired: connection.token_expires_at + ? connection.token_expires_at <= now + : false, + has_refresh_token: !!connection.refresh_token, + }; + }, +}); + +/** + * Get connection by ID + */ +export const getConnectionById = internalQuery({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.union( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + access_token: v.string(), + refresh_token: v.optional(v.string()), + github_username: v.string(), + github_user_id: v.string(), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + token_expires_at: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx, args) => { + const connection = await ctx.db.get(args.connectionId); + if (!connection) return null; + + return { + _id: connection._id, + user_id: connection.user_id, + access_token: connection.access_token, + refresh_token: connection.refresh_token, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + installation_token: connection.installation_token, + installation_token_expires_at: connection.installation_token_expires_at, + token_expires_at: connection.token_expires_at, + }; + }, +}); + +/** + * Update connection tokens + */ +export const updateConnectionTokens = internalMutation({ + args: { + connectionId: v.id("github_connections"), + accessToken: v.string(), + refreshToken: v.optional(v.string()), + tokenExpiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + access_token: args.accessToken, + refresh_token: args.refreshToken, + token_expires_at: args.tokenExpiresAt, + updated_at: Date.now(), + }); + }, +}); diff --git a/freebuff/web/convex/github/auth/index.ts b/freebuff/web/convex/github/auth/index.ts new file mode 100644 index 0000000000..fda12a60fb --- /dev/null +++ b/freebuff/web/convex/github/auth/index.ts @@ -0,0 +1,8 @@ +// Export OAuth functions +export * from "./oauth"; + +// Export installation functions +export * from "./installation"; + +// Export connection functions +export * from "./connections"; diff --git a/freebuff/web/convex/github/auth/installation.ts b/freebuff/web/convex/github/auth/installation.ts new file mode 100644 index 0000000000..da32d4d989 --- /dev/null +++ b/freebuff/web/convex/github/auth/installation.ts @@ -0,0 +1,60 @@ +import { v } from "convex/values"; +import { internalMutation } from "../../_generated/server"; + +/** + * Internal function to update GitHub connection with installation ID + */ +export const updateGitHubConnectionWithInstallation = internalMutation({ + args: { + connectionId: v.id("github_connections"), + installationId: v.number(), + installationToken: v.string(), + tokenExpiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: args.installationId, + installation_token: args.installationToken, + installation_token_expires_at: args.tokenExpiresAt, + updated_at: Date.now(), + }); + console.log( + "Updated GitHub connection with installation ID:", + args.connectionId, + ); + }, +}); + +/** + * Update installation ID for a GitHub connection + */ +export const updateInstallationId = internalMutation({ + args: { + connectionId: v.id("github_connections"), + installationId: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: args.installationId, + updated_at: Date.now(), + }); + }, +}); + +/** + * Remove installation ID from a GitHub connection + */ +export const removeInstallationId = internalMutation({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_id: undefined, + updated_at: Date.now(), + }); + }, +}); diff --git a/freebuff/web/convex/github/auth/oauth.ts b/freebuff/web/convex/github/auth/oauth.ts new file mode 100644 index 0000000000..8a9963ff27 --- /dev/null +++ b/freebuff/web/convex/github/auth/oauth.ts @@ -0,0 +1,180 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { action, query, internalMutation } from "../../_generated/server"; +import { api, internal } from "../../_generated/api"; + +/** + * Internal mutation to handle OAuth state management + */ +export const _storeOAuthState = internalMutation({ + args: { + user_id: v.id("users"), + state: v.string(), + return_url: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Clean up any existing OAuth states for this user (older than 5 minutes) + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + const existingStates = await ctx.db + .query("oauth_states") + .withIndex("by_user", (q) => q.eq("user_id", args.user_id)) + .filter((q) => q.lt(q.field("created_at"), fiveMinutesAgo)) + .collect(); + + for (const stateRecord of existingStates) { + await ctx.db.delete(stateRecord._id); + } + + // Store state for verification with return URL + await ctx.db.insert("oauth_states", { + user_id: args.user_id, + state: args.state, + return_url: args.return_url, + created_at: Date.now(), + }); + }, +}); + +/** + * Initiate GitHub OAuth flow with GitHub App installation + */ +export const initiateGitHubAuth = action({ + args: { + returnUrl: v.optional(v.string()), + }, + returns: v.string(), // authorization URL + handler: async (ctx, args): Promise => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Your session has expired. Please sign in again to connect GitHub."); + } + let user = await getAuthUser(ctx); + // Cloud can hit this action before a Convex user row is linked/created. + // Self-heal by creating the user record on-demand instead of throwing. + if (!user) { + const userId = await ctx.runMutation(api.users.getOrCreateSignedInUser, {}); + user = await ctx.runQuery(internal.users.get, { userId }); + } + if (!user) { + throw new Error("User account not found. Please sign out and sign in again."); + } + + // Feature access is enforced client-side via useFeatureAccess hook. + // Server-side autumn.check() was incorrectly blocking paying users + // due to Autumn API sync issues, so the hard gate was removed here. + + const state: string = await ctx.runAction( + api.utils.crypto.generateSecureState, + {}, + ); + + // Step 1: Start with OAuth flow to identify the user + // Use GitHub OAuth App to get user identity first + const clientId = process.env.GITHUB_CLIENT_ID; + if (!clientId) { + throw new Error("GitHub OAuth Client ID not configured"); + } + + const redirectUri = + process.env.GITHUB_OAUTH_REDIRECT_URI || + "http://localhost:3000/github/oauth-callback"; + + // OAuth authorization URL to get user identity and repository creation permissions + const authUrl: string = `https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user,repo&state=${state}&redirect_uri=${encodeURIComponent(redirectUri)}`; + + // Store OAuth state + await ctx.runMutation(internal.github.auth.oauth._storeOAuthState, { + user_id: user._id, + state, + return_url: args.returnUrl, + }); + + console.log("initiateGitHubAuth:", { + user_id: user._id, + state, + return_url: args.returnUrl, + auth_url: authUrl, + redirect_uri: redirectUri, + client_id: clientId, + }); + + return authUrl; + }, +}); + +/** + * Verify OAuth state and get user + */ +export const verifyOAuthState = query({ + args: { + state: v.string(), + }, + returns: v.union( + v.object({ + user_id: v.id("users"), + state_id: v.id("oauth_states"), + }), + v.null(), + ), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + const stateRecord = await ctx.db + .query("oauth_states") + .withIndex("by_state", (q) => q.eq("state", args.state)) + .first(); + + if (!stateRecord || stateRecord.user_id !== user._id) { + return null; + } + + return { + user_id: user._id, + state_id: stateRecord._id, + }; + }, +}); + +/** + * Clean up OAuth state - can be called from other actions + */ +export const cleanupOAuthState = action({ + args: { + state_id: v.id("oauth_states"), + }, + returns: v.null(), + handler: async (_ctx, _args) => { + // We can't call mutations from actions, so we'll handle this differently + // For now, we'll just return null and handle cleanup in the mutation + return null; + }, +}); + +/** + * Background job to clean up expired OAuth states + */ +export const cleanupExpiredStates = internalMutation({ + args: {}, + returns: v.null(), + handler: async (ctx) => { + // Clean up states older than 5 minutes + const fiveMinutesAgo = Date.now() - 5 * 60 * 1000; + + const expiredStates = await ctx.db + .query("oauth_states") + .filter((q) => q.lt(q.field("created_at"), fiveMinutesAgo)) + .collect(); + + let deletedCount = 0; + for (const state of expiredStates) { + await ctx.db.delete(state._id); + deletedCount++; + } + + if (deletedCount > 0) { + console.log(`Cleaned up ${deletedCount} expired OAuth states`); + } + }, +}); diff --git a/freebuff/web/convex/github/cloudRepos.ts b/freebuff/web/convex/github/cloudRepos.ts new file mode 100644 index 0000000000..45a1b78ab7 --- /dev/null +++ b/freebuff/web/convex/github/cloudRepos.ts @@ -0,0 +1,270 @@ +"use node"; + +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { api, internal } from "../_generated/api"; +import { action } from "../_generated/server"; +import { GITHUB_APP_CONFIG } from "./config"; + +const GH_HEADERS = (token: string) => ({ + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "freebuff-cloud", + "X-GitHub-Api-Version": "2022-11-28", +}); + +function manageUrlFor( + installationId: number, + accountLogin: string | undefined, + accountType: string | undefined, +): string { + // Org installs live under /organizations//..., user installs under + // /settings/installations/... The /permissions/update page surfaces any + // pending permission request (e.g. the Contents: write upgrade) to accept. + return accountType === "Organization" && accountLogin + ? `https://github.com/organizations/${accountLogin}/settings/installations/${installationId}/permissions/update` + : `https://github.com/settings/installations/${installationId}/permissions/update`; +} + +/** + * Freebuff Cloud repository discovery (multi-org aware). + * + * Uses the user's OAuth token to enumerate EVERY Freebuff app installation the + * user can access (`GET /user/installations`) — personal account plus every + * org they installed on — then lists the repos under each + * (`GET /user/installations/{id}/repositories`). Each repo is tagged with the + * installation_id it belongs to so the connect flow can clone with the right + * installation token, and grouped by owner in the UI. + * + * "Can we push" is the AND of two facts: + * - the app's installation-wide `Contents` grant is write/admin, and + * - the user's own role on the repo allows push. + * + * The result is written to the per-user `github_repo_cache` so the dialog can + * render instantly from the DB; this action is only run on first load or when + * the user hits Refresh. + */ +export const refreshConnectableRepositories = action({ + args: {}, + returns: v.object({ + installations: v.array( + v.object({ + installation_id: v.number(), + account_login: v.string(), + account_type: v.optional(v.string()), + // Installation-wide Contents grant ("read" | "write" | "admin"). + contents_permission: v.optional(v.string()), + can_write: v.boolean(), + manage_url: v.string(), + }), + ), + repos: v.array( + v.object({ + name: v.string(), + full_name: v.string(), + owner: v.string(), + private: v.boolean(), + description: v.union(v.string(), v.null()), + html_url: v.string(), + default_branch: v.string(), + permission_push: v.boolean(), + installation_id: v.number(), + // Last push timestamp (ISO) used to sort most-recent-first. + pushed_at: v.union(v.string(), v.null()), + }), + ), + }), + handler: async ( + ctx, + ): Promise<{ + installations: Array<{ + installation_id: number; + account_login: string; + account_type?: string; + contents_permission?: string; + can_write: boolean; + manage_url: string; + }>; + repos: Array<{ + name: string; + full_name: string; + owner: string; + private: boolean; + description: string | null; + html_url: string; + default_branch: string; + permission_push: boolean; + installation_id: number; + pushed_at: string | null; + }>; + }> => { + const authUser = await getAuthUser(ctx); + if (!authUser) { + throw new Error("Not authenticated"); + } + + const userAndConnection = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + if (!userAndConnection) { + throw new Error("GitHub account not connected"); + } + const userToken = userAndConnection.connection.access_token; + if (!userToken) { + throw new Error("GitHub authorization expired. Reconnect GitHub."); + } + + // 1. Every installation the user can access (personal + each org). + const instRes = await fetch( + "https://api.github.com/user/installations?per_page=100", + { headers: GH_HEADERS(userToken) }, + ); + if (instRes.status === 401) { + throw new Error("GitHub authorization expired. Reconnect GitHub."); + } + if (!instRes.ok) { + throw new Error(`Failed to list installations (${instRes.status}).`); + } + const instData: any = await instRes.json(); + const installationsRaw: any[] = instData?.installations ?? []; + + const installations: Array<{ + installation_id: number; + account_login: string; + account_type?: string; + contents_permission?: string; + can_write: boolean; + manage_url: string; + }> = []; + const repos: Array<{ + name: string; + full_name: string; + owner: string; + private: boolean; + description: string | null; + html_url: string; + default_branch: string; + permission_push: boolean; + installation_id: number; + pushed_at: string | null; + }> = []; + + for (const inst of installationsRaw) { + const installationId: number = inst.id; + const accountLogin: string = inst.account?.login ?? ""; + const accountType: string | undefined = inst.account?.type; + const contentsPermission: string | undefined = inst.permissions?.contents; + const appCanWrite = + contentsPermission === "write" || contentsPermission === "admin"; + + console.log("[cloudRepos] installation", { + installationId, + account_login: accountLogin, + account_type: accountType, + contents_permission: contentsPermission, + }); + + installations.push({ + installation_id: installationId, + account_login: accountLogin, + account_type: accountType, + contents_permission: contentsPermission, + can_write: appCanWrite, + manage_url: manageUrlFor(installationId, accountLogin, accountType), + }); + + // 2. Repos under this installation the user can access. The repo + // `permissions` here reflect the *user's* role (user-token call). + let page = 1; + while (page <= 10) { + const repoRes = await fetch( + `https://api.github.com/user/installations/${installationId}/repositories?per_page=100&page=${page}`, + { headers: GH_HEADERS(userToken) }, + ); + if (!repoRes.ok) break; + const repoData: any = await repoRes.json(); + const batch: any[] = repoData?.repositories ?? []; + for (const repo of batch) { + const userPush = repo.permissions?.push ?? true; + repos.push({ + name: repo.name, + full_name: repo.full_name, + owner: repo.owner?.login ?? repo.full_name.split("/")[0], + private: !!repo.private, + description: repo.description ?? null, + html_url: repo.html_url, + default_branch: repo.default_branch ?? "main", + // Pushable only when BOTH the app (installation-wide) and the user + // can write. + permission_push: appCanWrite && !!userPush, + installation_id: installationId, + pushed_at: repo.pushed_at ?? null, + }); + } + if (batch.length < 100) break; + page += 1; + } + } + + // Most-recently-pushed first so active repos surface at the top. + repos.sort((a, b) => { + const ta = a.pushed_at ? Date.parse(a.pushed_at) : 0; + const tb = b.pushed_at ? Date.parse(b.pushed_at) : 0; + return tb - ta; + }); + + await ctx.runMutation(internal.github.repoCacheStore.setRepoCache, { + userId: authUser._id, + installations, + repos, + }); + + return { installations, repos }; + }, +}); + +/** + * URL where the user manages which repositories/orgs the Freebuff app can + * access. Used by the connect dialog's "add more repositories" link and to + * (re)install on an organization. + * + * When `returnUrl` is provided we mint an OAuth state carrying it so that after + * the install/configure round-trip GitHub's setup callback redirects back to + * the right place (e.g. /cloud) instead of the default /web. + */ +export const getGitHubAppConfigureUrl = action({ + args: { + returnUrl: v.optional(v.string()), + }, + returns: v.string(), + handler: async (ctx, args): Promise => { + const authUser = await getAuthUser(ctx); + if (!authUser) throw new Error("Not authenticated"); + const slug = GITHUB_APP_CONFIG.APP_SLUG; + if (!slug) { + throw new Error("GitHub App slug not configured"); + } + + const base = `https://github.com/apps/${slug}/installations/new`; + if (!args.returnUrl) { + return base; + } + + const state: string = await ctx.runAction( + api.utils.crypto.generateSecureState, + {}, + ); + await ctx.runMutation(internal.github.auth.oauth._storeOAuthState, { + user_id: authUser._id, + state, + return_url: args.returnUrl, + }); + + const redirectUri = + process.env.GITHUB_REDIRECT_URI || + "http://localhost:3000/github/callback"; + + return `${base}?state=${state}&redirect_uri=${encodeURIComponent(redirectUri)}`; + }, +}); diff --git a/freebuff/web/convex/github/config.ts b/freebuff/web/convex/github/config.ts new file mode 100644 index 0000000000..ff6438f5c2 --- /dev/null +++ b/freebuff/web/convex/github/config.ts @@ -0,0 +1,5 @@ +// GitHub App Configuration +export const GITHUB_APP_CONFIG = { + APP_ID: process.env.GITHUB_APP_ID!, + APP_SLUG: process.env.GITHUB_APP_SLUG!, // GitHub App slug - matches the organization name +} as const; diff --git a/freebuff/web/convex/github/deployments.ts b/freebuff/web/convex/github/deployments.ts new file mode 100644 index 0000000000..b0264e64bd --- /dev/null +++ b/freebuff/web/convex/github/deployments.ts @@ -0,0 +1,488 @@ +"use node"; + +import { internalAction } from "../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { + createGitHubDeployment, + updateGitHubDeploymentStatus, + updateRepositoryHomepage, +} from "./utils/octokit"; + +/** + * Create a GitHub deployment for a project + */ +export const createGitHubDeploymentForProject = internalAction({ + args: { + projectId: v.id("project"), + deploymentId: v.id("deployments"), + environment: v.string(), + description: v.string(), + slug: v.string(), // Add slug parameter for production URL + commitHash: v.optional(v.string()), // Optional commit hash to use for deployment + }, + returns: v.object({ + success: v.boolean(), + githubDeploymentId: v.optional(v.number()), + message: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + githubDeploymentId?: number; + message: string; + }> => { + try { + console.log("Creating GitHub deployment for project:", args.projectId); + + // Get project details + const project: any = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + + if (!project) { + return { + success: false, + message: "Project not found", + }; + } + + // Get GitHub context using centralized service + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: true, + requireSyncState: true, + }, + ); + + if ( + !projectContext.success || + !projectContext.syncState || + !projectContext.connection + ) { + return { + success: false, + message: projectContext.error || "GitHub context not available", + }; + } + + const syncState = projectContext.syncState; + const connection = projectContext.connection; + + if (!connection || !connection.installation_id) { + return { + success: false, + message: "GitHub App installation not found", + }; + } + + // Get GitHub config using centralized service + const gitHubConfig: any = await ctx.runAction( + internal.github.services.octokitService.getProjectGitHubConfig, + { projectId: args.projectId }, + ); + + if (!gitHubConfig.success || !gitHubConfig.installationId) { + return { + success: false, + message: gitHubConfig.error || "Failed to get GitHub configuration", + }; + } + + // Create Octokit instance locally using the config + const { createOctokitInstance } = await import( + "./services/octokitService" + ); + const octokit = await createOctokitInstance(gitHubConfig.installationId); + const repoInfo = gitHubConfig.syncState; + + // Use provided commit hash or get the latest commit hash from the repository + let commitToDeploy: any; + + if (args.commitHash) { + // Use the provided commit hash + try { + const commitResponse = await octokit.rest.repos.getCommit({ + owner: repoInfo!.github_repo_owner, + repo: repoInfo!.github_repo_name, + ref: args.commitHash, + }); + commitToDeploy = commitResponse.data; + console.log(`Using provided commit hash: ${args.commitHash}`); + } catch (commitError) { + console.log( + `Failed to get commit ${args.commitHash}, falling back to latest commit:`, + commitError, + ); + // Fall back to getting the latest commit + const commitsResponse = await octokit.rest.repos.listCommits({ + owner: repoInfo!.github_repo_owner, + repo: repoInfo!.github_repo_name, + per_page: 1, + }); + + if (!commitsResponse.data.length) { + return { + success: false, + message: "No commits found in repository", + }; + } + commitToDeploy = commitsResponse.data[0]; + } + } else { + // Get the latest commit hash from the repository + const commitsResponse = await octokit.rest.repos.listCommits({ + owner: repoInfo!.github_repo_owner, + repo: repoInfo!.github_repo_name, + per_page: 1, + }); + + if (!commitsResponse.data.length) { + return { + success: false, + message: "No commits found in repository", + }; + } + commitToDeploy = commitsResponse.data[0]; + } + + // Create GitHub deployment + console.log(`[DEBUG] Creating GitHub deployment with params:`, { + owner: repoInfo!.github_repo_owner, + repo: repoInfo!.github_repo_name, + ref: commitToDeploy.sha, + environment: args.environment, + description: args.description, + targetUrl: `https://${args.slug}.freebuff.app`, // Use slug for production URL + }); + + const deployment = await createGitHubDeployment( + octokit, + repoInfo!.github_repo_owner, + repoInfo!.github_repo_name, + commitToDeploy.sha, + args.environment, + args.description, + false, + `https://${args.slug}.freebuff.app`, // Use slug for production URL + ); + + console.log( + `[DEBUG] GitHub deployment created successfully:`, + deployment, + ); + + // Get the current deployment to preserve its state + const currentDeployment = await ctx.runQuery(internal.deployment.get, { + deploymentId: args.deploymentId, + }); + + if (!currentDeployment) { + throw new Error("Deployment not found"); + } + + // Store the GitHub deployment ID in the database + await ctx.runMutation(internal.deployment.update, { + deploymentId: args.deploymentId, + state: currentDeployment.state, // Preserve the current state + github_deployment_id: deployment.id, + github_deployment_url: deployment.url, + }); + + console.log("GitHub deployment created successfully:", deployment.id); + + return { + success: true, + githubDeploymentId: deployment.id, + message: "GitHub deployment created successfully", + }; + } catch (error) { + console.error("Failed to create GitHub deployment:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +/** + * Update GitHub deployment status + */ +export const updateGitHubDeploymentStatusAction = internalAction({ + args: { + projectId: v.id("project"), + deploymentId: v.id("deployments"), + githubDeploymentId: v.number(), // Pass this directly to avoid query-from-action issue + state: v.union( + v.literal("pending"), + v.literal("success"), + v.literal("failure"), + v.literal("error"), + v.literal("inactive"), + ), + targetUrl: v.optional(v.string()), + description: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + }), + handler: async (ctx, args) => { + try { + console.log( + "Updating GitHub deployment status for project:", + args.projectId, + ); + + // Get GitHub context using centralized service + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: true, + requireSyncState: true, + }, + ); + + if ( + !projectContext.success || + !projectContext.syncState || + !projectContext.connection + ) { + return { + success: false, + message: projectContext.error || "GitHub context not available", + }; + } + + const syncState = projectContext.syncState; + const connection = projectContext.connection; + + if (!connection || !connection.installation_id) { + return { + success: false, + message: "GitHub App installation not found", + }; + } + + // Get GitHub config using centralized service + const gitHubConfig: any = await ctx.runAction( + internal.github.services.octokitService.getProjectGitHubConfig, + { projectId: args.projectId }, + ); + + if (!gitHubConfig.success || !gitHubConfig.installationId) { + return { + success: false, + message: gitHubConfig.error || "Failed to get GitHub configuration", + }; + } + + // Create Octokit instance locally using the config + const { createOctokitInstance } = await import( + "./services/octokitService" + ); + const octokit = await createOctokitInstance(gitHubConfig.installationId); + const repoInfo = gitHubConfig.syncState; + + // Update GitHub deployment status + console.log(`[DEBUG] Updating GitHub deployment status with params:`, { + owner: repoInfo!.github_repo_owner, + repo: repoInfo!.github_repo_name, + deployment_id: args.githubDeploymentId, + state: args.state, + target_url: args.targetUrl, + description: args.description, + }); + + await updateGitHubDeploymentStatus( + octokit, + repoInfo!.github_repo_owner, + repoInfo!.github_repo_name, + args.githubDeploymentId, + args.state, + args.targetUrl, + args.description, + ); + + console.log("GitHub deployment status updated successfully"); + + return { + success: true, + message: "GitHub deployment status updated successfully", + }; + } catch (error) { + console.error("Failed to update GitHub deployment status:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +/** + * Update repository homepage URL to production deployment + */ +export const updateRepositoryHomepageAction = internalAction({ + args: { + projectId: v.id("project"), + productionUrl: v.string(), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + }), + handler: async (ctx, args) => { + try { + console.log("Updating repository homepage for project:", args.projectId); + + // Get GitHub context using centralized service + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: true, + requireSyncState: true, + }, + ); + + if ( + !projectContext.success || + !projectContext.syncState || + !projectContext.connection + ) { + return { + success: false, + message: projectContext.error || "GitHub context not available", + }; + } + + const syncState = projectContext.syncState; + const connection = projectContext.connection; + + if (!connection || !connection.installation_id) { + return { + success: false, + message: "GitHub App installation not found", + }; + } + + // Get GitHub config using centralized service + const gitHubConfig: any = await ctx.runAction( + internal.github.services.octokitService.getProjectGitHubConfig, + { projectId: args.projectId }, + ); + + if (!gitHubConfig.success || !gitHubConfig.installationId) { + return { + success: false, + message: gitHubConfig.error || "Failed to get GitHub configuration", + }; + } + + // Create Octokit instance locally using the config + const { createOctokitInstance } = await import( + "./services/octokitService" + ); + const octokit = await createOctokitInstance(gitHubConfig.installationId); + const repoInfo = gitHubConfig.syncState; + + // Update repository homepage + await updateRepositoryHomepage( + octokit, + repoInfo!.github_repo_owner, + repoInfo!.github_repo_name, + args.productionUrl, + ); + + console.log("Repository homepage updated successfully"); + + return { + success: true, + message: "Repository homepage updated successfully", + }; + } catch (error) { + console.error("Failed to update repository homepage:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +/** + * Get deployment with GitHub deployment ID - action version for use in actions + */ +export const getDeploymentWithGitHubId = internalAction({ + args: { + deploymentId: v.id("deployments"), + }, + returns: v.any(), + handler: async (ctx, args) => { + // Get the deployment by ID + const deployment: any = await ctx.runQuery(internal.deployment.get, { + deploymentId: args.deploymentId, + }); + + if (!deployment) return null; + + return { + ...deployment, + github_deployment_id: deployment.github_deployment_id, + github_deployment_url: deployment.github_deployment_url, + }; + }, +}); + +/** + * Store GitHub deployment ID in the database + */ +export const storeGitHubDeploymentId = internalAction({ + args: { + deploymentId: v.id("deployments"), + githubDeploymentId: v.number(), + githubDeploymentUrl: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.runMutation(internal.deployment.update, { + deploymentId: args.deploymentId, + state: "deploying", // Add the required state field + github_deployment_id: args.githubDeploymentId, + github_deployment_url: args.githubDeploymentUrl, + }); + return null; + }, +}); + +/** + * Get GitHub deployments for a project - action version for use in actions + */ +export const getProjectGitHubDeployments = internalAction({ + args: { + projectId: v.id("project"), + }, + returns: v.array(v.any()), + handler: async (ctx, args) => { + // Get all deployments for the project + const deployments: any[] = await ctx.runQuery( + internal.deployment.getProjectDeploymentsInternal, + { + projectId: args.projectId, + }, + ); + + // Filter deployments that have GitHub deployment IDs + return deployments.filter((d: any) => d.github_deployment_id); + }, +}); diff --git a/freebuff/web/convex/github/envExport.ts b/freebuff/web/convex/github/envExport.ts new file mode 100644 index 0000000000..f7533661b0 --- /dev/null +++ b/freebuff/web/convex/github/envExport.ts @@ -0,0 +1,705 @@ +"use node"; + +import { action } from "../_generated/server"; +import { v } from "convex/values"; +import { getAuthUser } from "../users"; +import { getVerifiedAccessProject } from "../project"; +import { initializeCodebase } from "../../codebase-utils/codebase/initializeCodebase"; +import { hasEnvironmentVariables } from "../../codebase-utils/codebase/Codebase"; +import { getPackageManager } from "../../codebase-utils/packageManager"; + +/** + * Generate .env.local file content for frontend environment variables + */ +export const generateFrontendEnvFile = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.object({ + content: v.string(), + fileName: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + + let envVars; + try { + if (!hasEnvironmentVariables(codebase)) { + throw new Error("Codebase does not support environment variables"); + } + envVars = await codebase.getEnvVars(); + } catch (error) { + console.error("Error getting env vars from sandbox:", error); + // Fallback: try to get at least frontend vars + try { + const frontendEnvResult = await codebase.runCommand( + pm.run("@dotenvx/dotenvx get -f .env.local"), + ); + if (frontendEnvResult.exitCode === 0) { + const frontendEnv = Object.fromEntries( + Object.entries( + JSON.parse(frontendEnvResult.output) as Record, + ), + ); + envVars = { frontend: frontendEnv, backend: {} }; + } else { + // If even frontend vars fail, return empty env file with instructions + envVars = { frontend: {}, backend: {} }; + } + } catch (fallbackError) { + console.error("Fallback also failed:", fallbackError); + envVars = { frontend: {}, backend: {} }; + } + } + + // Generate .env.local content + let content = "# Frontend Environment Variables\n"; + content += "# Generated from Vly for Git Sync\n\n"; + + if (Object.keys(envVars.frontend).length === 0) { + content += + "# No environment variables found. Please set them in Vly dashboard.\n"; + content += "# Required variables:\n"; + content += "# NEXT_PUBLIC_CONVEX_URL=\n"; + content += "# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=\n"; + content += "# CLERK_SECRET_KEY=\n"; + } else { + for (const [key, value] of Object.entries(envVars.frontend)) { + // Escape any quotes in the value + const escapedValue = String(value).replace(/"/g, '\\"'); + content += `${key}="${escapedValue}"\n`; + } + } + + return { + content, + fileName: ".env.local", + }; + }, +}); + +/** + * Generate a shell script for setting backend Convex environment variables + */ +export const generateBackendEnvScript = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.object({ + content: v.string(), + fileName: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + + let envVars; + try { + if (!hasEnvironmentVariables(codebase)) { + throw new Error("Codebase does not support environment variables"); + } + envVars = await codebase.getEnvVars(); + } catch (error) { + console.error("Error getting env vars from sandbox:", error); + // Return a script with instructions if we can't get the vars + envVars = { frontend: {}, backend: {} }; + } + + // Generate setup script content + let content = "#!/bin/bash\n\n"; + content += "# Backend Environment Variables Setup Script\n"; + content += "# Generated from Vly for Git Sync\n"; + content += + "# Run this script to set up your Convex backend environment variables\n\n"; + + content += "echo 'Setting up Convex backend environment variables...'\n\n"; + + // Add check for convex CLI + content += "# Check if Convex CLI is installed\n"; + content += "if ! command -v npx &> /dev/null; then\n"; + content += + " echo 'Error: npx is not installed. Please install Node.js and npm first.'\n"; + content += " exit 1\n"; + content += "fi\n\n"; + + if (Object.keys(envVars.backend).length === 0) { + content += "echo '⚠️ No backend environment variables found.'\n"; + content += + "echo 'Please set them in the Vly dashboard first, then re-download this script.'\n"; + content += "echo ''\n"; + content += "echo 'Alternatively, you can set them manually:'\n"; + content += `echo ' ${pm.run('convex env set KEY_NAME -- "value"')}'\\n`; + } else { + // Add commands to set each environment variable + for (const [key, value] of Object.entries(envVars.backend)) { + // Properly escape the value for shell + const escapedValue = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$"); + + content += `echo "Setting ${key}..."\n`; + content += `${pm.run(`convex env set "${key}" -- "${escapedValue}"`)}\n\n`; + } + + content += 'echo "✅ All backend environment variables have been set!"\n'; + content += 'echo "You can now run: pnpm dev:backend"\n'; + } + + return { + content, + fileName: "setup-backend-env.sh", + }; + }, +}); + +/** + * Generate a complete setup script for initial project setup + */ +export const generateSetupScript = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.object({ + content: v.string(), + fileName: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + const pmInstallCmd = pm.install(); + + // Generate comprehensive setup script + let content = "#!/bin/bash\n\n"; + content += "# Vly Git Sync Project Setup Script\n"; + content += `# Project: ${project.name}\n`; + content += + "# This script will set up your local development environment\n\n"; + + content += "set -e # Exit on error\n\n"; + + content += "echo '🚀 Starting Vly project setup...'\n\n"; + + // Check for Node.js + content += "# Check Node.js installation\n"; + content += "if ! command -v node &> /dev/null; then\n"; + content += " echo '❌ Error: Node.js is not installed.'\n"; + content += " echo 'Please install Node.js from https://nodejs.org/'\n"; + content += " exit 1\n"; + content += "fi\n"; + content += "echo '✅ Node.js is installed'\n\n"; + + // Package manager setup + if (pm.name === "pnpm") { + content += "# Enable pnpm via corepack\n"; + content += "echo 'Enabling pnpm...'\n"; + content += "corepack enable\n"; + content += "corepack prepare pnpm@latest --activate\n"; + content += "echo '✅ pnpm is enabled'\n\n"; + } else { + content += "# Check for bun installation\n"; + content += "if ! command -v bun &> /dev/null; then\n"; + content += " echo '❌ Error: Bun is not installed.'\n"; + content += " echo 'Please install Bun from https://bun.sh/'\n"; + content += " exit 1\n"; + content += "fi\n"; + content += "echo '✅ Bun is installed'\n\n"; + } + + // Install dependencies + content += "# Install dependencies\n"; + content += "echo 'Installing dependencies...'\n"; + content += `${pmInstallCmd}\n`; + content += "echo '✅ Dependencies installed'\n\n"; + + // Setup Convex + content += "# Setup Convex\n"; + content += "echo 'Setting up Convex...'\n"; + content += "if [ ! -f .env.local ]; then\n"; + content += " echo '⚠️ Warning: .env.local file not found.'\n"; + content += + " echo 'Please download it from the Vly dashboard and place it in the project root.'\n"; + content += "fi\n\n"; + + content += "# Initialize Convex (if not already initialized)\n"; + content += "if [ ! -d 'convex/_generated' ]; then\n"; + content += " echo 'Initializing Convex...'\n"; + content += ` ${pm.run("convex dev --once")}\n`; + content += "fi\n"; + content += "echo '✅ Convex is set up'\n\n"; + + // Run backend env setup if script exists + content += "# Set up backend environment variables\n"; + content += "if [ -f setup-backend-env.sh ]; then\n"; + content += " echo 'Setting up backend environment variables...'\n"; + content += " chmod +x setup-backend-env.sh\n"; + content += " ./setup-backend-env.sh\n"; + content += "else\n"; + content += " echo '⚠️ Warning: setup-backend-env.sh not found.'\n"; + content += + " echo 'Please download it from the Vly dashboard to set up backend environment variables.'\n"; + content += "fi\n\n"; + + content += "echo ''\n"; + content += "echo '🎉 Setup complete!'\n"; + content += "echo ''\n"; + content += "echo 'To start the development server, run:'\n"; + content += "echo ' pnpm dev'\n"; + content += "echo ''\n"; + content += "echo 'Or run frontend and backend separately:'\n"; + content += "echo ' pnpm dev:frontend # In one terminal'\n"; + content += "echo ' pnpm dev:backend # In another terminal'\n"; + + return { + content, + fileName: "setup.sh", + }; + }, +}); + +/** + * Generate copy-pasteable backend env commands + */ +export const generateBackendEnvCommands = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.string(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + + let envVars; + try { + if (!hasEnvironmentVariables(codebase)) { + throw new Error("Codebase does not support environment variables"); + } + envVars = await codebase.getEnvVars(); + } catch (error) { + console.error("Error getting env vars from sandbox:", error); + envVars = { frontend: {}, backend: {} }; + } + + // Always generate a valid bash script that can be executed + let commands = "#!/bin/bash\n\n"; + commands += "# Convex Backend Environment Variables Setup\n"; + commands += "# Generated from Vly for Git Sync\n\n"; + + if (Object.keys(envVars.backend).length === 0) { + // Return a helpful script even when no vars are found + commands += + "echo '⚠️ No backend environment variables found in your Vly project.'\n"; + commands += "echo ''\n"; + commands += "echo 'To set environment variables:'\n"; + commands += "echo '1. Go to your Vly dashboard'\n"; + commands += "echo '2. Navigate to the API Keys section'\n"; + commands += "echo '3. Add your environment variables'\n"; + commands += "echo '4. Re-download this script'\n"; + commands += "echo ''\n"; + commands += "echo 'Or set them manually using:'\n"; + commands += `echo ' ${pm.run('convex env set KEY_NAME -- "value"')}'\\n`; + commands += "echo ''\n"; + commands += "echo 'Example:'\n"; + commands += `echo ' ${pm.run('convex env set OPENAI_API_KEY -- "sk-..."')}'\\n`; + } else { + commands += "echo 'Setting up Convex backend environment variables...'\n"; + commands += "echo ''\n\n"; + + for (const [key, value] of Object.entries(envVars.backend)) { + // Properly escape the value for shell + const escapedValue = value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$"); + + commands += `echo "Setting ${key}..."\n`; + commands += `${pm.run(`convex env set "${key}" -- "${escapedValue}"`)}\n`; + } + + commands += "\necho ''\n"; + commands += + "echo '✅ All backend environment variables have been set!'\n"; + commands += "echo 'You can now run: pnpm dev:backend'\n"; + } + + return commands; + }, +}); + +/** + * Generate an enhanced one-click setup script for non-technical users + */ +export const generateOneclickSetupScript = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.object({ + content: v.string(), + fileName: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + const pmName = pm.name === "bun" ? "Bun" : "pnpm"; + const pmInstallCmd = pm.install(); + + // Generate a super user-friendly setup script + let content = "#!/bin/bash\n\n"; + content += "# 🚀 Vly Project One-Click Setup\n"; + content += `# Project: ${project.name}\n`; + content += + "# This script will automatically set up everything you need!\n\n"; + + content += "set -e # Stop on any error\n\n"; + + // Add colors for better UX + content += "# Colors for pretty output\n"; + content += "GREEN='\\033[0;32m'\n"; + content += "YELLOW='\\033[1;33m'\n"; + content += "RED='\\033[0;31m'\n"; + content += "NC='\\033[0m' # No Color\n\n"; + + content += + 'echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"\n'; + content += + 'echo -e "${GREEN}║ Welcome to Vly Project Setup! 🎉 ║${NC}"\n'; + content += + 'echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"\n'; + content += 'echo ""\n\n'; + + // Step 1: Check and install Node.js if needed + content += 'echo -e "${YELLOW}Step 1: Checking Node.js...${NC}"\n'; + content += "if ! command -v node &> /dev/null; then\n"; + content += ' echo -e "${RED}Node.js not found. Installing...${NC}"\n'; + content += " # Detect OS and install Node.js\n"; + content += ' if [[ "$OSTYPE" == "darwin"* ]]; then\n'; + content += " # macOS\n"; + content += " if command -v brew &> /dev/null; then\n"; + content += " brew install node\n"; + content += " else\n"; + content += + ' echo "Please install Homebrew first: https://brew.sh"\n'; + content += " echo 'Then run: brew install node'\n"; + content += " exit 1\n"; + content += " fi\n"; + content += ' elif [[ "$OSTYPE" == "linux-gnu"* ]]; then\n'; + content += " # Linux\n"; + content += + " curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -\n"; + content += " sudo apt-get install -y nodejs\n"; + content += " else\n"; + content += + ' echo "Please install Node.js manually from: https://nodejs.org"\n'; + content += " exit 1\n"; + content += " fi\n"; + content += "else\n"; + content += ' echo -e "${GREEN}✓ Node.js is installed${NC}"\n'; + content += "fi\n\n"; + + // Step 2: Package manager setup + content += `echo -e "\${YELLOW}Step 2: Setting up ${pmName}...\${NC}"\n`; + if (pm.name === "pnpm") { + content += "if ! command -v pnpm &> /dev/null; then\n"; + content += " corepack enable\n"; + content += " corepack prepare pnpm@latest --activate\n"; + content += "fi\n"; + } else { + content += "if ! command -v bun &> /dev/null; then\n"; + content += ' echo -e "${RED}Bun not found. Installing...${NC}"\n'; + content += " curl -fsSL https://bun.sh/install | bash\n"; + content += ' export PATH="$HOME/.bun/bin:$PATH"\n'; + content += "fi\n"; + } + content += `echo -e "\${GREEN}✓ ${pmName} is ready\${NC}"\n\n`; + + // Step 3: Install dependencies + content += + 'echo -e "${YELLOW}Step 3: Installing project dependencies...${NC}"\n'; + content += `${pmInstallCmd}\n`; + content += 'echo -e "${GREEN}✓ Dependencies installed${NC}"\n\n'; + + // Step 4: Set up environment files + content += + 'echo -e "${YELLOW}Step 4: Setting up environment variables...${NC}"\n'; + content += "if [ ! -f .env.local ]; then\n"; + content += ' echo -e "${RED}⚠️ .env.local file not found!${NC}"\n'; + content += ' echo "Please download it from Vly dashboard:"\n'; + content += ' echo "1. Go to your project on Vly"\n'; + content += ' echo "2. Click on Git Sync → Developer Setup"\n'; + content += ' echo "3. Download .env.local"\n'; + content += + ' echo "4. Place it in this folder and run this script again"\n'; + content += " exit 1\n"; + content += "else\n"; + content += + ' echo -e "${GREEN}✓ Frontend environment variables found${NC}"\n'; + content += "fi\n\n"; + + // Step 5: Initialize Convex + content += 'echo -e "${YELLOW}Step 5: Setting up Convex backend...${NC}"\n'; + content += `${pm.run("convex dev --once --skip-cli-update")}\n`; + content += 'echo -e "${GREEN}✓ Convex is initialized${NC}"\n\n'; + + // Step 6: Set backend environment variables + content += + 'echo -e "${YELLOW}Step 6: Setting backend environment variables...${NC}"\n'; + content += "if [ -f setup-backend-env.sh ]; then\n"; + content += " chmod +x setup-backend-env.sh\n"; + content += " ./setup-backend-env.sh\n"; + content += "else\n"; + content += + ' echo -e "${YELLOW}Backend env script not found. You may need to set them manually.${NC}"\n'; + content += "fi\n\n"; + + // Success message with next steps + content += 'echo ""\n'; + content += + 'echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"\n'; + content += + 'echo -e "${GREEN}║ Setup Complete! 🎉🎉🎉 ║${NC}"\n'; + content += + 'echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"\n'; + content += 'echo ""\n'; + content += 'echo "Your project is ready to run!"\n'; + content += 'echo ""\n'; + content += 'echo "Start the development server with:"\n'; + content += 'echo -e " ${GREEN}pnpm dev${NC}"\n'; + content += 'echo ""\n'; + content += 'echo "This will start both:"\n'; + content += 'echo " • Frontend at http://localhost:3000"\n'; + content += 'echo " • Backend (Convex) in watch mode"\n'; + content += 'echo ""\n'; + content += 'echo "Happy coding! 🚀"\n'; + + return { + content, + fileName: "oneclick-setup.sh", + }; + }, +}); + +/** + * Generate README instructions for Git Sync setup + */ +export const generateReadmeInstructions = action({ + args: { + semanticIdentifier: v.string(), + }, + returns: v.string(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!project) { + throw new Error("Project not found"); + } + + // Get package manager from project (default to bun for new projects) + const packageManager = project.packageManager ?? "bun"; + const pm = getPackageManager(packageManager); + const _pmName = pm.name; + const pmInstallCmd = pm.install(); + const pmDevCmd = pm.dev(); + + let readme = "# Git Sync Setup Instructions\n\n"; + readme += "## Quick Start\n\n"; + readme += "1. **Clone the repository:**\n"; + readme += " ```bash\n"; + readme += " git clone \n"; + readme += " cd \n"; + readme += " ```\n\n"; + + readme += "2. **Download environment files from Vly:**\n"; + readme += " - Go to your project on Vly\n"; + readme += " - Navigate to the Git Sync settings\n"; + readme += " - Download the following files:\n"; + readme += " - `.env.local` (Frontend environment variables)\n"; + readme += + " - `setup-backend-env.sh` (Backend environment setup script)\n"; + readme += " - `setup.sh` (Complete setup script)\n"; + readme += " - Place these files in your project root\n\n"; + + readme += "3. **Run the setup script:**\n"; + readme += " ```bash\n"; + readme += " chmod +x setup.sh\n"; + readme += " ./setup.sh\n"; + readme += " ```\n\n"; + + readme += "4. **Start developing:**\n"; + readme += " ```bash\n"; + readme += ` ${pmDevCmd}\n`; + readme += " ```\n\n"; + + readme += "## Manual Setup (Alternative)\n\n"; + readme += "If you prefer to set up manually:\n\n"; + + readme += "### 1. Install dependencies\n"; + readme += "```bash\n"; + if (pm.name === "pnpm") { + readme += "# Enable pnpm\n"; + readme += "corepack enable\n"; + readme += "corepack prepare pnpm@latest --activate\n\n"; + } else { + readme += "# Install bun (if not already installed)\n"; + readme += "curl -fsSL https://bun.sh/install | bash\n\n"; + } + readme += "# Install packages\n"; + readme += `${pmInstallCmd}\n`; + readme += "```\n\n"; + + readme += "### 2. Set up environment variables\n\n"; + readme += "#### Frontend (.env.local)\n"; + readme += + "Place the downloaded `.env.local` file in your project root.\n\n"; + + readme += "#### Backend (Convex)\n"; + readme += "Run the backend environment setup script:\n"; + readme += "```bash\n"; + readme += "chmod +x setup-backend-env.sh\n"; + readme += "./setup-backend-env.sh\n"; + readme += "```\n\n"; + + readme += "Or set variables manually one by one:\n"; + readme += "```bash\n"; + readme += `${pm.run('convex env set KEY_NAME -- "value"')}\n`; + readme += "```\n\n"; + + readme += "### 3. Initialize Convex\n"; + readme += "```bash\n"; + readme += `${pm.run("convex dev --once")}\n`; + readme += "```\n\n"; + + readme += "### 4. Start the development server\n"; + readme += "```bash\n"; + readme += "pnpm dev\n"; + readme += "```\n\n"; + + readme += "## Troubleshooting\n\n"; + readme += + "- **pnpm not found:** Make sure you have Node.js 16+ installed and run `corepack enable`\n"; + readme += + "- **Convex errors:** Ensure all backend environment variables are set correctly\n"; + readme += "- **Missing .env.local:** Download it from the Vly dashboard\n"; + readme += + "- **Permission denied:** Make scripts executable with `chmod +x .sh`\n\n"; + + readme += "## Syncing Changes\n\n"; + readme += "Your changes will automatically sync with Vly when you:\n"; + readme += "- Push to the connected GitHub repository\n"; + readme += + "- The sync happens automatically on every push to the main branch\n\n"; + + readme += "## Need Help?\n\n"; + readme += "- Check the project dashboard on Vly for sync status\n"; + readme += "- View detailed logs in the Git Sync section\n"; + readme += "- Contact support if you encounter any issues\n"; + + return readme; + }, +}); diff --git a/freebuff/web/convex/github/git/repository.ts b/freebuff/web/convex/github/git/repository.ts new file mode 100644 index 0000000000..d7cc3b8aaf --- /dev/null +++ b/freebuff/web/convex/github/git/repository.ts @@ -0,0 +1,171 @@ +"use node"; + +import { GITHUB_APP_CONFIG } from "../config"; +import { logTokenUsage, validateTokenRequirements } from "../tokens"; +import { parsePrivateKey } from "../utils/octokit"; + +/** + * Create a GitHub repository if it doesn't exist (internal helper) + */ +export async function createGitHubRepositoryInternal( + owner: string, + repo: string, + accessToken: string, + installationId?: number, + ctx?: any, + projectId?: string, +): Promise { + const { Octokit } = await import("octokit"); + + // Set up Octokit for repository checking using centralized token service + let checkOctokit: any; + let checkTokenType: "installation" | "oauth"; + + if (ctx && projectId) { + // Use centralized token service when Convex context is available + const tokenResult = await ctx.runAction( + "github/tokens/service:getGitHubToken", + { + operation: { + type: "repository_check", + projectId, + accessToken, + installationId, + }, + operationName: "repository_check_setup", + }, + ); + + if (tokenResult.tokenType === "installation" && installationId) { + const { App } = await import("octokit"); + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY!; + const app = new App({ + appId: GITHUB_APP_CONFIG.APP_ID, + privateKey: parsePrivateKey(privateKey), + }); + checkOctokit = await app.getInstallationOctokit(installationId); + checkTokenType = "installation"; + } else { + checkOctokit = new Octokit({ auth: tokenResult.token }); + checkTokenType = tokenResult.tokenType; + } + } else { + // Fallback to direct token selection when no Convex context + const validation = validateTokenRequirements( + "repository_check", + !!installationId, + !!accessToken, + true, + ); + + if (validation.shouldUseInstallation && installationId) { + try { + const { App } = await import("octokit"); + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY!; + const app = new App({ + appId: GITHUB_APP_CONFIG.APP_ID, + privateKey: parsePrivateKey(privateKey), + }); + checkOctokit = await app.getInstallationOctokit(installationId); + checkTokenType = "installation"; + + logTokenUsage({ + operation: "repository_check_setup", + tokenType: "installation", + success: true, + installationId, + }); + } catch (error: any) { + logTokenUsage({ + operation: "repository_check_setup", + tokenType: "installation", + success: false, + installationId, + error: error.message, + }); + + checkOctokit = new Octokit({ auth: accessToken }); + checkTokenType = "oauth"; + + logTokenUsage({ + operation: "repository_check_setup_fallback", + tokenType: "oauth", + success: true, + }); + } + } else { + checkOctokit = new Octokit({ auth: accessToken }); + checkTokenType = "oauth"; + + logTokenUsage({ + operation: "repository_check_setup", + tokenType: "oauth", + success: true, + }); + } + } + + try { + // Try to get the repository first + await checkOctokit.rest.repos.get({ + owner, + repo, + }); + + logTokenUsage({ + operation: "repository_exists_check", + tokenType: checkTokenType, + success: true, + installationId: + checkTokenType === "installation" ? installationId : undefined, + }); + } catch (error: any) { + if (error.status === 404) { + // Repository doesn't exist, create it using OAuth token + // (installation tokens don't have repo creation permissions) + logTokenUsage({ + operation: "repository_exists_check", + tokenType: checkTokenType, + success: true, // 404 is expected, not a failure + installationId: + checkTokenType === "installation" ? installationId : undefined, + }); + + try { + const createOctokit = new Octokit({ auth: accessToken }); + await createOctokit.rest.repos.createForAuthenticatedUser({ + name: repo, + private: true, + auto_init: false, + }); + + logTokenUsage({ + operation: "create_repository", + tokenType: "oauth", + success: true, + }); + } catch (createError: any) { + logTokenUsage({ + operation: "create_repository", + tokenType: "oauth", + success: false, + error: createError.message, + }); + throw createError; + } + } else { + logTokenUsage({ + operation: "repository_exists_check", + tokenType: checkTokenType, + success: false, + installationId: + checkTokenType === "installation" ? installationId : undefined, + error: error.message, + }); + throw error; + } + } + + // Return clean HTTPS URL (no embedded credentials) + return `https://github.com/${owner}/${repo}.git`; +} diff --git a/freebuff/web/convex/github/git/secureCredentials.ts b/freebuff/web/convex/github/git/secureCredentials.ts new file mode 100644 index 0000000000..191ef56fe6 --- /dev/null +++ b/freebuff/web/convex/github/git/secureCredentials.ts @@ -0,0 +1,189 @@ +/** + * Secure Git Credential Helper Utility + * + * This module provides secure credential handling for Git operations in provider environments. + * Instead of embedding tokens in Git URLs (which exposes them in logs and process lists), + * this utility uses in-memory git credential helpers that never write sensitive data to files. + * + * Security Features: + * - No tokens in command line arguments or URLs + * - No credential files written to sandbox filesystem + * - In-memory only credential handling + * - Automatic cleanup after operations + * - Proper error handling to prevent credential leaks + */ + +export interface SecureGitClient { + commands: { + run(command: string): Promise; + }; +} + +/** + * Set up secure in-memory git credential helper + * This configures git to use a shell function that provides credentials + * without writing them to any files on the filesystem. + */ +export async function setupSecureGitCredentials( + client: SecureGitClient, + token: string, +): Promise { + // Configure git to use in-memory credential helper + // The helper is a shell function that echoes credentials when git needs them + const credentialHelper = `'!f() { + echo "username=x-access-token"; + echo "password=${token}"; + }; f'`; + + await client.commands.run( + `git config --global credential.helper ${credentialHelper}`, + ); +} + +/** + * Execute a git command with secure credentials and timeout handling + * Sets up credentials, runs the command with timeout, then cleans up + */ +export async function executeSecureGitCommand( + client: SecureGitClient, + command: string, + token: string, + timeoutMs: number = 60000, // 1 minute default timeout +): Promise { + let cleanupPromise: Promise | null = null; + + try { + // Setup secure credentials + await setupSecureGitCredentials(client, token); + + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error(`Git command timed out after ${timeoutMs}ms: ${command}`), + ); + }, timeoutMs); + }); + + // Execute the git command with timeout + const commandPromise = client.commands.run(command); + const result = await Promise.race([commandPromise, timeoutPromise]); + + return result; + } catch (error) { + // Ensure cleanup happens even on timeout/error + if (!cleanupPromise) { + cleanupPromise = cleanupGitCredentials(client); + } + + // Wait for cleanup before rethrowing + try { + await cleanupPromise; + } catch (cleanupError) { + console.warn( + "Warning: Failed to cleanup credentials after error:", + cleanupError, + ); + } + + throw error; + } finally { + // Normal cleanup path + if (!cleanupPromise) { + await cleanupGitCredentials(client); + } + } +} + +/** + * Clean up git credential configuration + * Removes the credential helper to prevent any lingering access + */ +export async function cleanupGitCredentials( + client: SecureGitClient, +): Promise { + try { + await client.commands.run(`git config --global --unset credential.helper`); + } catch (error) { + // Non-fatal error - log but don't throw + console.warn("Warning: Could not cleanup git credentials:", error); + } +} + +/** + * Create a secure git clone command + * Returns a clean HTTPS URL without embedded credentials + */ +export function createSecureCloneUrl( + repoOwner: string, + repoName: string, +): string { + return `https://github.com/${repoOwner}/${repoName}.git`; +} + +/** + * Execute secure git clone operation + */ +export async function secureGitClone( + client: SecureGitClient, + repoOwner: string, + repoName: string, + token: string, + directory?: string, +): Promise { + const cloneUrl = createSecureCloneUrl(repoOwner, repoName); + const cloneCommand = directory + ? `git clone ${cloneUrl} ${directory}` + : `git clone ${cloneUrl}`; + + return executeSecureGitCommand(client, cloneCommand, token); +} + +/** + * Execute secure git pull operation + */ +export async function secureGitPull( + client: SecureGitClient, + token: string, + directory?: string, +): Promise { + const pullCommand = directory ? `cd ${directory} && git pull` : `git pull`; + + return executeSecureGitCommand(client, pullCommand, token); +} + +/** + * Execute secure git push operation + */ +export async function secureGitPush( + client: SecureGitClient, + token: string, + directory?: string, + branch: string = "main", +): Promise { + const pushCommand = directory + ? `cd ${directory} && git push origin ${branch}` + : `git push origin ${branch}`; + + return executeSecureGitCommand(client, pushCommand, token); +} + +/** + * Execute secure git remote set-url operation + * Updates the remote URL to use HTTPS without embedded credentials + */ +export async function secureGitSetRemoteUrl( + client: SecureGitClient, + repoOwner: string, + repoName: string, + token: string, + directory?: string, + remoteName: string = "origin", +): Promise { + const remoteUrl = createSecureCloneUrl(repoOwner, repoName); + const setUrlCommand = directory + ? `cd ${directory} && git remote set-url ${remoteName} ${remoteUrl}` + : `git remote set-url ${remoteName} ${remoteUrl}`; + + return executeSecureGitCommand(client, setUrlCommand, token); +} diff --git a/freebuff/web/convex/github/index.ts b/freebuff/web/convex/github/index.ts new file mode 100644 index 0000000000..7d2ca5a52e --- /dev/null +++ b/freebuff/web/convex/github/index.ts @@ -0,0 +1,28 @@ +// Export all GitHub authentication functions from organized structure +export * from "./auth/oauth"; +export * from "./auth/connections"; +export * from "./auth/installation"; +// Export remaining auth functions that aren't organized yet +export { + handleGitHubOAuthCallback, + refreshGitHubToken, + verifyOAuthStateInternal, + storeGitHubConnectionInternal, + getProjectDetails, + getProjectOwner, + getSyncStatesByRepo, + getSyncStateByProject, +} from "./auth"; + +// Export all GitHub repository functions +export * from "./repositories"; + +// Export all GitHub sync functions +export * from "./sync"; +// Legacy sync/core functions removed - migrated to secure credentials system + +// Export all GitHub sync status functions (backward compatibility) +export * from "./sync/status"; + +// Export all GitHub deployment functions +export * from "./deployments"; diff --git a/freebuff/web/convex/github/manualSync.ts b/freebuff/web/convex/github/manualSync.ts new file mode 100644 index 0000000000..b1f1ce3b6e --- /dev/null +++ b/freebuff/web/convex/github/manualSync.ts @@ -0,0 +1,178 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { action } from "../_generated/server"; +import { getAuthUser } from "../users"; + +/** + * Manual Git Sync Action + * + * Allows godmode users to manually trigger a sync + * between the project and its GitHub repository. + */ +export const triggerManualSync = action({ + args: { + projectId: v.id("project"), + syncDirection: v.optional( + v.union(v.literal("github_to_project"), v.literal("project_to_github")), + ), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + syncStatus: v.optional(v.string()), + error: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + syncStatus?: string; + error?: string; + }> => { + console.log("Triggering manual sync"); + const { projectId, syncDirection = "project_to_github" } = args; + + try { + // Get the current user + const user = await getAuthUser(ctx); + if (!user) { + return { + success: false, + message: "User not authenticated", + }; + } + + // Check if user is godmode + if (user.role !== "god") { + return { + success: false, + message: "Only godmode users can trigger manual sync", + }; + } + + // Get project details + const project = await ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (!project) { + return { + success: false, + message: "Project not found", + }; + } + + // Get GitHub connection details + const projectContext = await ctx.runAction( + internal.github.services.projectService.getProjectConnection, + { + projectId, + }, + ); + + if (!projectContext.success || !projectContext.connection) { + return { + success: false, + message: "GitHub connection not found for this project", + error: projectContext.error, + }; + } + + const connection = projectContext.connection; + + if (!connection.repository_name || !connection.repository_owner) { + return { + success: false, + message: "Repository details not found", + }; + } + + // Get GitHub access token + const githubAuth = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { + userId: user._id, + }, + ); + + if (!githubAuth?.access_token) { + return { + success: false, + message: "GitHub authentication not found. Please reconnect GitHub.", + }; + } + + console.log( + `[ManualSync] Triggering ${syncDirection} sync for project ${projectId} to ${connection.repository_owner}/${connection.repository_name}`, + ); + + // Fetch GitHub token once for this sync flow + console.log( + `[ManualSync] Fetching GitHub token for project ${projectId}`, + ); + const tokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: syncDirection, + projectId, + accessToken: githubAuth.access_token, + installationId: connection.installation_id, + }, + operationName: "manual_sync", + }, + ); + + // Call the appropriate executor service directly (it handles status updates internally) + const syncResult = + syncDirection === "github_to_project" + ? await ctx.runAction( + internal.github.sync.services.syncExecutorService + .executeGitHubToProjectSync, + { + sandboxId: project.sandbox_id, + projectId, + repoOwner: connection.repository_owner, + repoName: connection.repository_name, + accessToken: githubAuth.access_token, + installationId: connection.installation_id, + githubToken: tokenResult.token, + githubTokenType: tokenResult.tokenType, + packageManager: project.packageManager, + }, + ) + : await ctx.runAction( + internal.github.sync.services.syncExecutorService + .executeProjectToGitHubSync, + { + sandboxId: project.sandbox_id, + projectId, + repoOwner: connection.repository_owner, + repoName: connection.repository_name, + accessToken: githubAuth.access_token, + installationId: connection.installation_id, + githubToken: tokenResult.token, + githubTokenType: tokenResult.tokenType, + packageManager: project.packageManager, + }, + ); + + return { + success: syncResult.success, + message: syncResult.message, + syncStatus: syncResult.status, + }; + } catch (error) { + console.error("[ManualSync] Error:", error); + return { + success: false, + message: "Failed to trigger manual sync", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/octokit.ts b/freebuff/web/convex/github/octokit.ts new file mode 100644 index 0000000000..56252a3c51 --- /dev/null +++ b/freebuff/web/convex/github/octokit.ts @@ -0,0 +1,186 @@ +"use node"; + +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { action } from "../_generated/server"; + +/** + * Handle GitHub App installation callback (Step 2: App installation) + */ +export const handleGitHubCallback = action({ + args: { + installation_id: v.string(), + setup_action: v.optional(v.string()), + state: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + returnUrl: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + returnUrl?: string; + }> => { + const user = await getAuthUser(ctx); + + // For GitHub App installation, we get the installation_id directly + const installationId = parseInt(args.installation_id); + + if (isNaN(installationId)) { + throw new Error("Invalid installation ID"); + } + + let stateInfo: + | { + user_id: any; + state_id: any; + return_url?: string; + } + | null = null; + + if (args.state) { + // Verify state and resolve the owning user. + // For installation callbacks, we allow user resolution via state so the + // flow remains robust even if auth cookies are unavailable on redirect. + stateInfo = await ctx.runQuery(internal.github.auth.verifyOAuthStateInternal, { + state: args.state, + userId: user?._id, + isInstallationCallback: true, + }); + + if (!stateInfo) { + throw new Error("Invalid or expired OAuth state"); + } + } else { + // Fallback flow: GitHub can return installation callback links without + // our state. In this case we require an authenticated user. + if (!user) { + throw new Error("Unauthorized"); + } + } + + const targetUserId = stateInfo?.user_id ?? user?._id; + if (!targetUserId) { + throw new Error("Unable to resolve user for GitHub installation callback"); + } + + try { + // Get installation access token using centralized service + const { createOctokitInstance } = await import( + "./services/octokitService" + ); + + console.log("installationId", installationId); + + const octokit = await createOctokitInstance(installationId); + + const tokenResponse = + await octokit.rest.apps.createInstallationAccessToken({ + installation_id: installationId, + }); + + // Get installation details to understand the installation type + const installationResponse = await octokit.rest.apps.getInstallation({ + installation_id: installationId, + }); + const installationData = installationResponse.data; + + // Define proper types for installation account data + interface InstallationAccount { + type: string; + id: number; + login?: string; + name?: string; + } + + // Type assertion with proper interface + const accountData = installationData.account as InstallationAccount; + + console.log( + "GitHub App installation completed for:", + installationData.target_type, + ); + + // Find existing GitHub connection for this user + const existingConnection = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { + userId: targetUserId, + }, + ); + + if (!existingConnection) { + throw new Error( + "No GitHub connection found. Please complete the OAuth flow first.", + ); + } + + // Update the existing connection with installation ID and new token + await ctx.runMutation( + internal.github.auth.updateGitHubConnectionWithInstallation, + { + connectionId: existingConnection._id, + installationId: installationId, + installationToken: tokenResponse.data.token, + tokenExpiresAt: tokenResponse.data.expires_at + ? new Date(tokenResponse.data.expires_at).getTime() + : undefined, + }, + ); + + // Mark OAuth state as used to prevent replay attacks + if (stateInfo?.state_id) { + await ctx.runMutation(internal.github.auth.markOAuthStateAsUsed, { + stateId: stateInfo.state_id, + }); + } + + console.log("GitHub App installation completed successfully"); + + return { + success: true, + message: "GitHub App installed successfully", + returnUrl: stateInfo?.return_url, + }; + } catch (error) { + console.error("GitHub App installation error:", error); + + // Handle specific GitHub API errors + if (error instanceof Error) { + if (error.message.includes("401")) { + return { + success: false, + message: "GitHub authentication failed. Please try again.", + returnUrl: stateInfo?.return_url, + }; + } else if (error.message.includes("403")) { + return { + success: false, + message: + "Insufficient permissions. Please check your GitHub App installation.", + returnUrl: stateInfo?.return_url, + }; + } else if (error.message.includes("404")) { + return { + success: false, + message: + "GitHub App installation not found. Please reinstall the app.", + returnUrl: stateInfo?.return_url, + }; + } + } + + return { + success: false, + message: `GitHub App installation failed: ${error instanceof Error ? error.message : "Unknown error"}`, + returnUrl: stateInfo?.return_url, + }; + } + }, +}); diff --git a/freebuff/web/convex/github/repoCacheStore.ts b/freebuff/web/convex/github/repoCacheStore.ts new file mode 100644 index 0000000000..57026fc728 --- /dev/null +++ b/freebuff/web/convex/github/repoCacheStore.ts @@ -0,0 +1,87 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { internalMutation, query } from "../_generated/server"; + +const installationValidator = v.object({ + installation_id: v.number(), + account_login: v.string(), + account_type: v.optional(v.string()), + contents_permission: v.optional(v.string()), + can_write: v.boolean(), + manage_url: v.string(), +}); + +const repoValidator = v.object({ + name: v.string(), + full_name: v.string(), + owner: v.string(), + private: v.boolean(), + description: v.union(v.string(), v.null()), + html_url: v.string(), + default_branch: v.string(), + permission_push: v.boolean(), + installation_id: v.number(), + pushed_at: v.union(v.string(), v.null()), +}); + +/** + * Instant (DB-only) read of the user's cached connectable repos. The connect + * dialog subscribes to this so it renders immediately on open without hitting + * GitHub. Returns null when nothing has been cached yet. + */ +export const getCachedConnectableRepositories = query({ + args: {}, + returns: v.union( + v.object({ + installations: v.array(installationValidator), + repos: v.array(repoValidator), + updated_at: v.number(), + }), + v.null(), + ), + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) return null; + const cache = await ctx.db + .query("github_repo_cache") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .first(); + if (!cache) return null; + return { + installations: cache.installations, + repos: cache.repos, + updated_at: cache.updated_at, + }; + }, +}); + +/** + * Upsert the user's repo cache. Called by the refresh action after fetching + * from GitHub. One doc per user keeps writes cheap and the dialog reactive. + */ +export const setRepoCache = internalMutation({ + args: { + userId: v.id("users"), + installations: v.array(installationValidator), + repos: v.array(repoValidator), + }, + returns: v.null(), + handler: async (ctx, args) => { + const existing = await ctx.db + .query("github_repo_cache") + .withIndex("by_user", (q) => q.eq("user_id", args.userId)) + .first(); + const data = { + user_id: args.userId, + installations: args.installations, + repos: args.repos, + updated_at: Date.now(), + }; + if (existing) { + await ctx.db.patch(existing._id, data); + } else { + await ctx.db.insert("github_repo_cache", data); + } + return null; + }, +}); diff --git a/freebuff/web/convex/github/repositories.ts b/freebuff/web/convex/github/repositories.ts new file mode 100644 index 0000000000..944a60e3a3 --- /dev/null +++ b/freebuff/web/convex/github/repositories.ts @@ -0,0 +1,731 @@ +import { getAuthUser } from "!/users"; +import { v } from "convex/values"; +import { internal } from "../_generated/api"; +import { action, internalQuery, mutation, query } from "../_generated/server"; + +/** + * Internal query to get user and GitHub connection for actions + */ +export const getUserAndConnection = internalQuery({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (!user) return null; + + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .first(); + + if (!connection) return null; + + return { + user: { + _id: user._id, + name: user.name || undefined, + email: user.email, + clerk_id: user.clerk_id, + }, + connection: { + access_token: connection.access_token, + refresh_token: connection.refresh_token, + github_username: connection.github_username, + github_user_id: connection.github_user_id, + installation_id: connection.installation_id, + installation_token: connection.installation_token, + installation_token_expires_at: connection.installation_token_expires_at, + }, + }; + }, +}); + +/** + * List user's GitHub repositories + */ +export const listUserRepositories = action({ + args: {}, + returns: v.array( + v.object({ + name: v.string(), + full_name: v.string(), + private: v.boolean(), + description: v.union(v.string(), v.null()), + html_url: v.string(), + default_branch: v.string(), + }), + ), + handler: async (ctx) => { + const authUser = await getAuthUser(ctx); + if (!authUser) { + throw new Error("Not authenticated"); + } + + const userAndConnection: { + user: { + _id: any; + name?: string; + email: string; + clerk_id: string; + }; + connection: { + access_token: string; + refresh_token?: string; + github_username: string; + github_user_id: string; + installation_id?: number; + installation_token?: string; + installation_token_expires_at?: number; + }; + } | null = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + + if (!userAndConnection) { + throw new Error("GitHub account not connected"); + } + + // Use GitHub App installation token to fetch repositories + if (!userAndConnection.connection.installation_id) { + throw new Error( + "GitHub App installation not found. Please reinstall the GitHub App.", + ); + } + + // Get a fresh installation token using centralized service + const { createOctokitInstance } = await import("./services/octokitService"); + const octokit: any = await createOctokitInstance( + userAndConnection.connection.installation_id, + ); + + // Fetch repositories using the installation token + const response: any = await octokit.rest.repos.listForAuthenticatedUser({ + per_page: 100, + sort: "updated", + }); + + return response.data.map((repo: any) => ({ + name: repo.name, + full_name: repo.full_name, + private: repo.private, + description: repo.description, + html_url: repo.html_url, + default_branch: repo.default_branch, + })); + }, +}); + +/** + * Create a new GitHub repository + */ +export const createGitHubRepository = action({ + args: { + name: v.string(), + description: v.optional(v.string()), + private: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + repo_url: v.optional(v.string()), + error: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + repo_url?: string; + error?: string; + }> => { + const authUser = await getAuthUser(ctx); + if (!authUser) { + return { + success: false, + error: "Not authenticated", + }; + } + + const userAndConnection: { + user: { + _id: any; + name?: string; + email: string; + clerk_id: string; + }; + connection: { + access_token: string; + refresh_token?: string; + github_username: string; + github_user_id: string; + installation_id?: number; + installation_token?: string; + installation_token_expires_at?: number; + }; + } | null = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + + if (!userAndConnection) { + return { + success: false, + error: "GitHub account not connected", + }; + } + + try { + // Get installation ID from user's connection + if (!userAndConnection.connection.installation_id) { + return { + success: false, + error: + "GitHub App installation not found. Please reinstall the GitHub App.", + }; + } + + // Use Octokit with installation token using centralized service + const { createOctokitInstance } = await import( + "./services/octokitService" + ); + const octokit: any = await createOctokitInstance( + userAndConnection.connection.installation_id, + ); + + const response: any = await octokit.rest.repos.createForAuthenticatedUser( + { + name: args.name, + description: args.description, + private: args.private ?? true, + auto_init: false, + }, + ); + + return { + success: true, + repo_url: response.data.html_url, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +/** + * Get repository details + */ +export const getRepositoryDetails = action({ + args: { + owner: v.string(), + repo: v.string(), + }, + returns: v.union( + v.object({ + name: v.string(), + full_name: v.string(), + private: v.boolean(), + description: v.union(v.string(), v.null()), + html_url: v.string(), + default_branch: v.string(), + updated_at: v.string(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const authUser = await getAuthUser(ctx); + if (!authUser) return null; + + const userAndConnection = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + + if (!userAndConnection) return null; + + try { + const response: Response = await fetch( + `https://api.github.com/repos/${args.owner}/${args.repo}`, + { + headers: { + Authorization: `Bearer ${userAndConnection.connection.access_token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (!response.ok) return null; + + const repoData: any = await response.json(); + + return { + name: repoData.name, + full_name: repoData.full_name, + private: repoData.private, + description: repoData.description, + html_url: repoData.html_url, + default_branch: repoData.default_branch, + updated_at: repoData.updated_at, + }; + } catch { + return null; + } + }, +}); + +/** + * Set up sync for a project with a GitHub repository + */ +export const setupProjectSync = mutation({ + args: { + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + syncDirection: v.literal("bidirectional"), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Unauthorized"); + + // Verify user has access to the project + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if (!projectMember) { + return { + success: false, + message: "You don't have access to this project", + }; + } + + // Check if sync is already set up + const existingSync = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (existingSync) { + // Update existing sync + await ctx.db.patch(existingSync._id, { + github_repo_name: args.repoName, + github_repo_owner: args.repoOwner, + sync_direction: args.syncDirection, + last_sync_time: Date.now(), + sync_status: "pending", + error_message: undefined, + }); + } else { + // Create new sync state + await ctx.db.insert("github_sync_state", { + project_id: args.projectId, + github_repo_name: args.repoName, + github_repo_owner: args.repoOwner, + last_sync_time: Date.now(), + sync_direction: args.syncDirection, + sync_status: "pending", + }); + } + + // Update project with GitHub URL + const project = await ctx.db.get(args.projectId); + if (project) { + await ctx.db.patch(args.projectId, { + github_url: `https://github.com/${args.repoOwner}/${args.repoName}`, + }); + } + + // Trigger initial sync + await ctx.scheduler.runAfter( + 0, + internal.github.sync.services.initialSync.performInitialSync, + { + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + }, + ); + + return { + success: true, + message: "GitHub sync setup successfully", + }; + }, +}); + +/** + * Get sync status for a project + */ +export const getProjectSyncStatus = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + // Verify user has access to the project + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if (!projectMember) return null; + + const syncState = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (!syncState) return null; + + return { + _creationTime: syncState._creationTime, + repo_name: syncState.github_repo_name, + repo_owner: syncState.github_repo_owner, + sync_direction: syncState.sync_direction, + sync_status: syncState.sync_status, + last_sync_time: syncState.last_sync_time, + error_message: syncState.error_message, + }; + }, +}); + +/** + * Remove GitHub sync for a project + */ +export const removeProjectSync = mutation({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + }), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Unauthorized"); + + // Verify user has access to the project + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if (!projectMember) { + return { + success: false, + message: "You don't have access to this project", + }; + } + + // Remove sync state + const syncState = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (syncState) { + await ctx.db.delete(syncState._id); + } + + // Remove GitHub URL from project + await ctx.db.patch(args.projectId, { + github_url: undefined, + }); + + return { + success: true, + message: "GitHub sync removed successfully", + }; + }, +}); + +/** + * Check if a repository name is available + */ +export const validateRepositoryName = action({ + args: { + name: v.string(), + }, + returns: v.object({ + available: v.boolean(), + message: v.string(), + }), + handler: async (ctx, args) => { + const authUser = await getAuthUser(ctx); + if (!authUser) { + return { + available: false, + message: "Not authenticated", + }; + } + + const userAndConnection = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + + if (!userAndConnection) { + return { + available: false, + message: "GitHub account not connected", + }; + } + + // Validate repository name format + const nameRegex = /^[a-zA-Z0-9._-]+$/; + if (!nameRegex.test(args.name)) { + return { + available: false, + message: "Repository name contains invalid characters", + }; + } + + if (args.name.length < 1 || args.name.length > 100) { + return { + available: false, + message: "Repository name must be between 1 and 100 characters", + }; + } + + try { + // Check if repository already exists + if (!userAndConnection.connection.access_token) { + return { + available: false, + message: + "No GitHub OAuth token found. Please reconnect your GitHub account.", + }; + } + + const { Octokit } = await import("octokit"); + const octokit = new Octokit({ + auth: userAndConnection.connection.access_token, + }); + + try { + // Try to get the repository - if it exists, this will succeed + await octokit.rest.repos.get({ + owner: userAndConnection.connection.github_username, + repo: args.name, + }); + + // If we reach here, the repository exists + return { + available: false, + message: "Repository name already exists", + }; + } catch (error: any) { + // If we get a 404, the repository doesn't exist (which is good) + if (error.status === 404) { + return { + available: true, + message: "Repository name is available", + }; + } + + // For other errors, we can't determine availability + return { + available: false, + message: "Unable to check repository availability", + }; + } + } catch { + return { + available: false, + message: "Failed to validate repository name", + }; + } + }, +}); + +/** + * Create a new GitHub repository for a project using GitHub App + */ +export const createRepository = action({ + args: { + name: v.string(), + description: v.string(), + private: v.boolean(), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + owner: v.optional(v.string()), + name: v.optional(v.string()), + }), + handler: async (ctx, args) => { + console.log("createRepository action called with args:", args); + + const authUser = await getAuthUser(ctx); + if (!authUser) { + console.error("User not authenticated"); + return { + success: false, + message: "Not authenticated", + }; + } + + // Feature access is enforced client-side via useFeatureAccess hook. + // Server-side autumn.check() was incorrectly blocking paying users + // due to Autumn API sync issues, so the hard gate was removed here. + + const userAndConnection = await ctx.runQuery( + internal.github.repositories.getUserAndConnection, + { userId: authUser._id }, + ); + + if (!userAndConnection) { + console.error("No GitHub connection found for user"); + return { + success: false, + message: "GitHub account not connected", + }; + } + + console.log("GitHub connection found:", { + github_username: userAndConnection.connection.github_username, + has_access_token: !!userAndConnection.connection.access_token, + installation_id: userAndConnection.connection.installation_id, + }); + + try { + console.log("Making GitHub API request to create repository..."); + + // Use GitHub App installation token to create repository + if (!userAndConnection.connection.installation_id) { + return { + success: false, + message: + "GitHub App installation not found. Please reinstall the GitHub App.", + }; + } + + // Use OAuth token for repository creation (createForAuthenticatedUser requires user OAuth tokens) + console.log("Using OAuth token for repository creation"); + + if (!userAndConnection.connection.access_token) { + return { + success: false, + message: + "No GitHub OAuth token found. Please reconnect your GitHub account.", + }; + } + + const { Octokit } = await import("octokit"); + + // Try to create repository with current token + let octokit = new Octokit({ + auth: userAndConnection.connection.access_token, + }); + + let response: any; + try { + response = await octokit.rest.repos.createForAuthenticatedUser({ + name: args.name, + description: args.description, + private: args.private, + auto_init: false, + }); + } catch (error: any) { + // If we get a 401, try refreshing the token + if ( + error.status === 401 && + userAndConnection.connection.refresh_token + ) { + console.log("Token expired, attempting to refresh..."); + + // Get the connection ID to refresh the token + const connection = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { userId: userAndConnection.user._id }, + ); + + if (connection) { + const refreshResult = await ctx.runAction( + internal.github.auth.refreshGitHubToken, + { connectionId: connection._id }, + ); + + if (refreshResult) { + console.log( + "Token refreshed successfully, retrying repository creation...", + ); + + // Retry with the new token + octokit = new Octokit({ + auth: refreshResult.access_token, + }); + + response = await octokit.rest.repos.createForAuthenticatedUser({ + name: args.name, + description: args.description, + private: args.private, + auto_init: false, + }); + } else { + throw error; // Re-throw the original error if refresh failed + } + } else { + throw error; // Re-throw the original error if no connection found + } + } else { + throw error; // Re-throw the original error if it's not a 401 or no refresh token + } + } + + console.log("GitHub API success response:", response.data); + + return { + success: true, + message: "Repository created successfully", + owner: response.data.owner.login, + name: response.data.name, + }; + } catch (error) { + console.error("Exception in createRepository:", error); + + // Handle specific GitHub API errors + if (error instanceof Error) { + if (error.message.includes("401")) { + return { + success: false, + message: + "GitHub OAuth token has expired. Please reconnect your GitHub account.", + }; + } else if (error.message.includes("403")) { + return { + success: false, + message: + "Insufficient permissions. Please ensure your GitHub account has permission to create repositories.", + }; + } else if (error.message.includes("422")) { + return { + success: false, + message: `Repository creation failed: ${error.message}`, + }; + } + } + + return { + success: false, + message: `Failed to create repository: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + }, +}); diff --git a/freebuff/web/convex/github/services/deploymentService.ts b/freebuff/web/convex/github/services/deploymentService.ts new file mode 100644 index 0000000000..72ebb35dac --- /dev/null +++ b/freebuff/web/convex/github/services/deploymentService.ts @@ -0,0 +1,326 @@ +"use node"; + +import { internalAction } from "../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { initializeCodebase } from "../../../codebase-utils/codebase/initializeCodebase"; +import { + isVersionControlled, + hasExtendedGitOperations, +} from "../../../codebase-utils/codebase/Codebase"; + +/** + * Deployment Service + * + * Service for production deployment preparation and version management. + * Handles committing changes, creating version tags, and pushing to GitHub. + * + * All operations use the provider-agnostic Codebase interface for + * efficient single-initialization workflows. + */ + +export interface DeploymentResult { + success: boolean; + committed: boolean; + tagged: boolean; + commitHash?: string; + tagName?: string; + error?: string; +} + +/** + * Generate a semantic version number based on deployment + * This function creates a version number in the format: YYYY.MM.DD.HHMM + */ +export function generateDeploymentVersion(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hour = String(now.getHours()).padStart(2, "0"); + const minute = String(now.getMinutes()).padStart(2, "0"); + + return `${year}.${month}.${day}.${hour}${minute}`; +} + +/** + * Prepare for production deployment by committing changes, creating tags, and pushing + */ +export const prepareProductionDeployment = internalAction({ + args: { + projectId: v.id("project"), + sandboxId: v.string(), + deploymentMessage: v.optional(v.string()), + repoOwner: v.string(), + repoName: v.string(), + accessToken: v.string(), + installationId: v.optional(v.number()), + }, + returns: v.object({ + success: v.boolean(), + committed: v.boolean(), + tagged: v.boolean(), + commitHash: v.optional(v.string()), + tagName: v.optional(v.string()), + error: v.optional(v.string()), + }), + handler: async (ctx, args): Promise => { + console.log(`[DeploymentService] Preparing for production deployment`); + + try { + // Initialize codebase once for all operations + const codebase = await initializeCodebase(args.sandboxId); + + if ( + !isVersionControlled(codebase) || + !hasExtendedGitOperations(codebase) + ) { + return { + success: false, + committed: false, + tagged: false, + error: "Codebase does not support git operations", + }; + } + + const message = + args.deploymentMessage || "Production deployment preparation"; + + // Step 1: Commit all uncommitted changes + console.log("[DeploymentService] Checking for uncommitted changes"); + const status = await codebase.getStatus(); + const hasChanges = + status.staged.length > 0 || + status.unstaged.length > 0 || + status.untracked.length > 0; + + let committed = false; + let commitHash: string | undefined; + + if (hasChanges) { + console.log("[DeploymentService] Committing changes"); + await codebase.addAll(); + const commit = await codebase.commit(message); + commitHash = commit.hash; + committed = true; + console.log(`[DeploymentService] Changes committed: ${commitHash}`); + } else { + // Get current commit hash even if no new commit + commitHash = await codebase.getCommitHash("HEAD"); + console.log("[DeploymentService] No uncommitted changes"); + } + + // Step 2: Generate version and create tag + console.log("[DeploymentService] Creating version tag"); + const version = generateDeploymentVersion(); + const tagName = `v${version}`; + const tagMessage = `Production deployment ${version}`; + + await codebase.createTag(tagName, tagMessage); + console.log(`[DeploymentService] Tag created: ${tagName}`); + + // Step 3: Push all changes to remote using codebase interface + console.log("[DeploymentService] Pushing changes to GitHub"); + + // Get token for push operation + const pushTokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: "deployment", + projectId: args.projectId, + accessToken: args.accessToken, + installationId: args.installationId, + }, + operationName: "deployment_push", + }, + ); + + // Get current branch and push directly using codebase interface + const branch = await codebase.getCurrentBranch(); + + try { + await codebase.push( + "github", + branch, + false, // Don't force push + pushTokenResult.token, + args.repoOwner, + args.repoName, + ); + console.log("[DeploymentService] Changes pushed successfully"); + } catch (pushError: any) { + return { + success: false, + committed, + tagged: true, + commitHash, + tagName, + error: `Failed to push changes: ${pushError.message || pushError}`, + }; + } + + // Step 4: Push the tag with authentication + console.log("[DeploymentService] Pushing tag to GitHub"); + await codebase.pushTag( + "github", + tagName, + pushTokenResult.token, + args.repoOwner, + args.repoName, + ); + console.log("[DeploymentService] Tag pushed successfully"); + + console.log( + "[DeploymentService] Production deployment preparation completed", + ); + return { + success: true, + committed, + tagged: true, + commitHash, + tagName, + }; + } catch (error: any) { + console.error( + "[DeploymentService] Error preparing for production deployment:", + error, + ); + return { + success: false, + committed: false, + tagged: false, + error: + error.message || "Unknown error preparing for production deployment", + }; + } + }, +}); + +/** + * Check if working directory is clean (no uncommitted changes) + */ +export const checkWorkingDirectoryStatus = internalAction({ + args: { + sandboxId: v.string(), + }, + returns: v.object({ + isClean: v.boolean(), + fileCount: v.number(), + files: v.array(v.string()), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + try { + const codebase = await initializeCodebase(args.sandboxId); + + if ( + !isVersionControlled(codebase) || + !hasExtendedGitOperations(codebase) + ) { + return { + isClean: false, + fileCount: 0, + files: [], + error: "Codebase does not support git operations", + }; + } + + const status = await codebase.getStatus(); + const files = [...status.staged, ...status.unstaged, ...status.untracked]; + const isClean = files.length === 0; + + return { + isClean, + fileCount: files.length, + files, + }; + } catch (error: any) { + console.error( + "[DeploymentService] Error checking working directory status:", + error, + ); + return { + isClean: false, + fileCount: 0, + files: [], + error: error.message || "Unknown error checking working directory", + }; + } + }, +}); + +/** + * Emergency rollback to restore development environment to clean state + * Removes all merge markers and uncommitted changes + */ +export const rollbackToCleanState = internalAction({ + args: { + sandboxId: v.string(), + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + try { + console.log("[DeploymentService] Rolling back to clean state"); + + const codebase = await initializeCodebase(args.sandboxId); + + if ( + !isVersionControlled(codebase) || + !hasExtendedGitOperations(codebase) + ) { + return { + success: false, + error: "Codebase does not support git operations", + }; + } + + // Abort any ongoing merge + try { + await codebase.runCommand("git merge --abort", 10000); + console.log("[DeploymentService] Aborted ongoing merge"); + } catch { + console.log("[DeploymentService] No ongoing merge to abort"); + } + + // Reset to last commit (removes all uncommitted changes) + await codebase.resetHard("HEAD"); + console.log("[DeploymentService] Reset to last commit"); + + // Clean working directory (removes untracked files) + await codebase.runCommand("git clean -fd", 15000); + console.log("[DeploymentService] Cleaned working directory"); + + // Verify clean state + const status = await codebase.getStatus(); + const files = [...status.staged, ...status.unstaged, ...status.untracked]; + + if (files.length === 0) { + console.log( + "[DeploymentService] Successfully rolled back to clean state", + ); + return { success: true }; + } else { + console.warn( + "[DeploymentService] Warning: Some files still show as modified after rollback", + ); + return { + success: false, + error: `${files.length} files still modified after rollback`, + }; + } + } catch (error: any) { + console.error( + "[DeploymentService] Failed to rollback to clean state:", + error, + ); + return { + success: false, + error: error.message || "Rollback failed", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/services/octokitService.ts b/freebuff/web/convex/github/services/octokitService.ts new file mode 100644 index 0000000000..7aefb3dd5f --- /dev/null +++ b/freebuff/web/convex/github/services/octokitService.ts @@ -0,0 +1,105 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { internalAction } from "../../_generated/server"; +import { App } from "octokit"; +import { GITHUB_APP_CONFIG } from "../config"; +import { parsePrivateKey } from "../utils/octokit"; + +/** + * Centralized Octokit Service + * Eliminates duplication of Octokit instance creation across deployment operations + */ + +/** + * Get project GitHub configuration for Octokit creation + * Returns the necessary data to create Octokit instances locally + */ +export const getProjectGitHubConfig = internalAction({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + success: v.boolean(), + installationId: v.optional(v.number()), + syncState: v.optional( + v.object({ + github_repo_owner: v.string(), + github_repo_name: v.string(), + }), + ), + error: v.optional(v.string()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + installationId?: number; + syncState?: { github_repo_owner: string; github_repo_name: string }; + error?: string; + }> => { + try { + // Get GitHub context using centralized service + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: true, + requireSyncState: true, + }, + ); + + if ( + !projectContext.success || + !projectContext.syncState || + !projectContext.connection + ) { + return { + success: false, + error: projectContext.error || "GitHub context not available", + }; + } + + const syncState = projectContext.syncState; + const connection = projectContext.connection; + + if (!connection.installation_id) { + return { + success: false, + error: "GitHub App installation not found", + }; + } + + return { + success: true, + installationId: connection.installation_id, + syncState: { + github_repo_owner: syncState.github_repo_owner, + github_repo_name: syncState.github_repo_name, + }, + }; + } catch (error) { + console.error("Failed to get project GitHub config:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +/** + * Helper function for direct Octokit creation (non-Convex contexts) + * Provides the same setup pattern for use outside of actions + */ +export async function createOctokitInstance(installationId: number) { + const privateKey = parsePrivateKey(process.env.GITHUB_APP_PRIVATE_KEY!); + const app = new App({ + appId: GITHUB_APP_CONFIG.APP_ID, + privateKey: privateKey, + }); + + return await app.getInstallationOctokit(installationId); +} diff --git a/freebuff/web/convex/github/services/projectService.ts b/freebuff/web/convex/github/services/projectService.ts new file mode 100644 index 0000000000..f765629d6b --- /dev/null +++ b/freebuff/web/convex/github/services/projectService.ts @@ -0,0 +1,370 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { internalAction } from "../../_generated/server"; + +/** + * GitHub Project Service + * Centralizes the common pattern of getting GitHub connection, sync state, and project context + * Eliminates 15+ duplicate patterns across the codebase + */ + +/** + * Get complete GitHub context for a project + * Returns the raw data from queries to avoid type conversion issues + */ +export const getProjectGitHubContext = internalAction({ + args: { + projectId: v.id("project"), + requireConnection: v.optional(v.boolean()), + requireSyncState: v.optional(v.boolean()), + includeProjectDetails: v.optional(v.boolean()), + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + projectMember: v.optional(v.any()), + connection: v.optional(v.any()), + syncState: v.optional(v.any()), + project: v.optional(v.any()), + }), + handler: async (ctx, args) => { + const { + projectId, + requireConnection = true, + requireSyncState = true, + includeProjectDetails = false, + } = args; + + try { + // Step 1: Get project owner/member + let projectMember: any; + try { + projectMember = await ctx.runQuery( + internal.github.auth.getProjectOwner, + { projectId }, + ); + + if (!projectMember) { + return { + success: false, + error: "Project owner not found", + }; + } + } catch (error) { + return { + success: false, + error: `Failed to get project owner: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + + // Step 2: Get GitHub connection + let connection: any; + if (requireConnection || !requireConnection) { + try { + connection = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { userId: projectMember.user }, + ); + + if (!connection && requireConnection) { + return { + success: false, + error: "GitHub connection not found", + projectMember, + }; + } + } catch (error) { + if (requireConnection) { + return { + success: false, + error: `Failed to get GitHub connection: ${error instanceof Error ? error.message : "Unknown error"}`, + projectMember, + }; + } + console.warn("GitHub connection not available:", error); + } + } + + // Step 3: Get sync state + let syncState: any; + if (requireSyncState || !requireSyncState) { + try { + syncState = await ctx.runQuery( + internal.github.auth.getSyncStateByProject, + { projectId }, + ); + + if (!syncState && requireSyncState) { + return { + success: false, + error: "GitHub sync state not found", + projectMember, + connection, + }; + } + } catch (error) { + if (requireSyncState) { + return { + success: false, + error: `Failed to get sync state: ${error instanceof Error ? error.message : "Unknown error"}`, + projectMember, + connection, + }; + } + console.warn("Sync state not available:", error); + } + } + + // Step 4: Get project details if requested + let project: any; + if (includeProjectDetails) { + try { + project = await ctx.runQuery(internal.github.auth.getProjectDetails, { + projectId, + }); + } catch (error) { + console.warn("Project details not available:", error); + } + } + + return { + success: true, + projectMember, + connection, + syncState, + project, + }; + } catch (error) { + return { + success: false, + error: `Unexpected error getting GitHub context: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } + }, +}); + +/** + * Get minimal GitHub context for operations that only need connection + */ +export const getProjectConnection = internalAction({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + connection: v.optional(v.any()), + projectMember: v.optional(v.any()), + }), + handler: async (ctx, args) => { + try { + console.log( + "[ProjectService] Getting project connection for project:", + args.projectId, + ); + + let projectMember: any; + try { + projectMember = await ctx.runQuery( + internal.github.auth.getProjectOwner, + { projectId: args.projectId }, + ); + console.log( + "[ProjectService] Project owner query result:", + projectMember ? "found" : "not found", + ); + } catch (ownerError) { + console.error( + "[ProjectService] Error getting project owner:", + ownerError, + ); + return { + success: false, + error: `Failed to get project owner: ${ownerError instanceof Error ? ownerError.message : String(ownerError)}`, + }; + } + + if (!projectMember) { + console.log( + "[ProjectService] No project owner found for project:", + args.projectId, + ); + return { + success: false, + error: "Project owner not found", + }; + } + + console.log( + "[ProjectService] Getting GitHub connection for user:", + projectMember.user, + ); + let connection: any; + try { + connection = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { userId: projectMember.user }, + ); + console.log( + "[ProjectService] GitHub connection query result:", + connection ? "found" : "not found", + ); + } catch (connError) { + console.error( + "[ProjectService] Error getting GitHub connection:", + connError, + ); + return { + success: false, + error: `Failed to get GitHub connection: ${connError instanceof Error ? connError.message : String(connError)}`, + projectMember, + }; + } + + if (!connection) { + console.log( + "[ProjectService] No GitHub connection found for user:", + projectMember.user, + ); + return { + success: false, + error: "GitHub connection not found", + projectMember, + }; + } + + console.log("[ProjectService] Successfully retrieved project connection"); + return { + success: true, + connection, + projectMember, + }; + } catch (error) { + console.error( + "[ProjectService] Unexpected error in getProjectConnection:", + error, + ); + return { + success: false, + error: `Failed to get project connection: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }, +}); + +/** + * Helper function for use within other actions (not a separate action) + * For when you need the context within the same action to avoid extra action calls + */ +export async function getGitHubContextInline( + ctx: any, + projectId: string, + options: { + requireConnection?: boolean; + requireSyncState?: boolean; + includeProjectDetails?: boolean; + } = {}, +): Promise<{ + success: boolean; + error?: string; + projectMember?: any; + connection?: any; + syncState?: any; + project?: any; +}> { + const { + requireConnection = true, + requireSyncState = true, + includeProjectDetails = false, + } = options; + + try { + const projectMember = await ctx.runQuery( + internal.github.auth.getProjectOwner, + { projectId }, + ); + + if (!projectMember) { + return { + success: false, + error: "Project owner not found", + }; + } + + let connection; + try { + connection = await ctx.runQuery( + internal.github.auth.getGitHubConnectionWithTokensInternal, + { userId: projectMember.user }, + ); + + if (!connection && requireConnection) { + return { + success: false, + error: "GitHub connection not found", + projectMember, + }; + } + } catch (error) { + if (requireConnection) { + return { + success: false, + error: `Failed to get GitHub connection: ${error instanceof Error ? error.message : "Unknown error"}`, + projectMember, + }; + } + } + + let syncState; + try { + syncState = await ctx.runQuery( + internal.github.auth.getSyncStateByProject, + { projectId }, + ); + + if (!syncState && requireSyncState) { + return { + success: false, + error: "GitHub sync state not found", + projectMember, + connection, + }; + } + } catch (error) { + if (requireSyncState) { + return { + success: false, + error: `Failed to get sync state: ${error instanceof Error ? error.message : "Unknown error"}`, + projectMember, + connection, + }; + } + } + + let project; + if (includeProjectDetails) { + try { + project = await ctx.runQuery(internal.github.auth.getProjectDetails, { + projectId, + }); + } catch (error) { + console.warn("Project details not available:", error); + } + } + + return { + success: true, + projectMember, + connection, + syncState, + project, + }; + } catch (error) { + return { + success: false, + error: `Unexpected error getting GitHub context: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} diff --git a/freebuff/web/convex/github/sync/cronPostSyncValidator.ts b/freebuff/web/convex/github/sync/cronPostSyncValidator.ts new file mode 100644 index 0000000000..a80f5c4ac0 --- /dev/null +++ b/freebuff/web/convex/github/sync/cronPostSyncValidator.ts @@ -0,0 +1,193 @@ +/** + * Post-sync cron validator + * Validates and fixes cron intervals after git pull operations + * to prevent high-frequency polling from external code pushes. + */ + +import { internal } from "../../_generated/api"; +import { validateAndFixCronIntervals } from "../../coding_agent/agent/process/cronValidator"; + +interface ValidateAndFixResult { + hadViolations: boolean; + adjustments: Array<{ + lineNumber: number; + originalInterval: string; + adjustedInterval: string; + }>; +} + +/** + * Wrapper function that validates crons with logging. + * Simplifies calling code by handling the validation result logging. + */ +export async function runCronValidationWithLogging( + ctx: any, + codebase: any, + projectId: string, + token: string, + repoOwner: string, + repoName: string, + logPrefix: string = "CronValidator", +): Promise { + console.log(`[${logPrefix}] Validating cron intervals...`); + + const result = await validateAndFixCronsAfterSync( + ctx, + codebase, + projectId, + token, + repoOwner, + repoName, + ); + + if (result.hadViolations) { + console.log( + `[${logPrefix}] Fixed ${result.adjustments.length} cron interval violation(s) and pushed back to GitHub`, + ); + } +} + +/** + * Validates crons.ts after git sync and auto-fixes violations. + * If violations are found, commits and pushes the fixes back to GitHub. + */ +export async function validateAndFixCronsAfterSync( + ctx: any, + codebase: any, + projectId: string, + token: string, + repoOwner: string, + repoName: string, +): Promise { + const result: ValidateAndFixResult = { + hadViolations: false, + adjustments: [], + }; + + try { + // Find all files ending with crons.ts + const allFiles = await codebase.getAllFilePaths(); + const cronsFiles = allFiles.filter((file: string) => + file.endsWith("crons.ts"), + ); + + if (cronsFiles.length === 0) { + console.log( + "[CronPostSyncValidator] No crons.ts file found, skipping validation", + ); + return result; + } + + console.log( + `[CronPostSyncValidator] Found ${cronsFiles.length} crons.ts file(s): ${cronsFiles.join(", ")}`, + ); + + const totalAdjustments: Array<{ + filePath: string; + lineNumber: number; + originalInterval: string; + adjustedInterval: string; + }> = []; + + // Validate each crons.ts file + for (const cronsPath of cronsFiles) { + let cronsContent: string; + + try { + cronsContent = await codebase.readFile(cronsPath); + } catch (error) { + console.log( + `[CronPostSyncValidator] Could not read ${cronsPath}, skipping`, + ); + continue; + } + + // Validate and fix cron intervals + const validation = validateAndFixCronIntervals(cronsContent); + + if (validation.adjustments.length === 0) { + console.log(`[CronPostSyncValidator] No violations in ${cronsPath}`); + continue; + } + + // Violations found - write adjusted content + console.log( + `[CronPostSyncValidator] Found ${validation.adjustments.length} violation(s) in ${cronsPath}, applying fixes...`, + ); + + await codebase.writeFile(cronsPath, validation.content); + + // Track adjustments with file path + totalAdjustments.push( + ...validation.adjustments.map((adj) => ({ + filePath: cronsPath, + ...adj, + })), + ); + + // Log to Axiom + console.log("[CRON_INTERVAL_ADJUSTMENT]", { + projectId, + filePath: cronsPath, + source: "git_sync", + adjustments: validation.adjustments, + }); + } + + if (totalAdjustments.length === 0) { + console.log("[CronPostSyncValidator] No cron interval violations found"); + return result; + } + + // Commit all fixes + const commitMessage = `chore: adjust cron intervals to meet minimum threshold (5 min) + +Auto-adjusted cron intervals that were below the 5-minute minimum: +${totalAdjustments.map((adj) => `- ${adj.filePath}:${adj.lineNumber}: ${adj.originalInterval} → ${adj.adjustedInterval}`).join("\n")} + +This change prevents excessive infrastructure costs from high-frequency polling.`; + + const commitResult = await codebase.commit(commitMessage); + + // Push back to GitHub + console.log("[CronPostSyncValidator] Pushing cron fixes back to GitHub..."); + await codebase.push( + "github", + "main", + false, // Don't force push + token, + repoOwner, + repoName, + ); + + // Sync commit to GitHub via checkpoint system (like regular checkpoints do) + console.log( + "[CronPostSyncValidator] Syncing commit to GitHub via checkpoint system...", + ); + await ctx.scheduler.runAfter( + 0, + internal.codesandbox.versionControl.syncCommitToGitHub, + { + projectId, + commitHash: commitResult.hash, + }, + ); + + result.hadViolations = true; + result.adjustments = totalAdjustments.map((adj) => ({ + lineNumber: adj.lineNumber, + originalInterval: adj.originalInterval, + adjustedInterval: adj.adjustedInterval, + })); + + console.log( + `[CronPostSyncValidator] Successfully fixed and pushed ${totalAdjustments.length} cron interval(s) across ${cronsFiles.length} file(s)`, + ); + + return result; + } catch (error) { + console.error("[CronPostSyncValidator] Error during validation:", error); + // Don't throw - we don't want to fail the entire sync if validation fails + return result; + } +} diff --git a/freebuff/web/convex/github/sync/index.ts b/freebuff/web/convex/github/sync/index.ts new file mode 100644 index 0000000000..4c8be0d3e1 --- /dev/null +++ b/freebuff/web/convex/github/sync/index.ts @@ -0,0 +1,28 @@ +/** + * GitHub Sync Module - Simplified Direct Services + * + * Sync operations are handled by executor services that are called directly + * from entry points (versionControl, webhooks, manualSync). + */ + +// Export types +export type { SyncOperation, SyncResult } from "./types"; + +// Export rollback functionality +export { rollbackToBackup, listAvailableBackups } from "./rollback"; + +// Export conflict resolution (consolidated from resolution.ts and unified_resolution.ts) +export { + resolveDivergence, + getResolutionOptions, + resolveConflicts, +} from "./resolution"; + +// Export internal resolution functions +export { resolveDivergenceInternal } from "./resolution"; + +// Export sync status management +export * from "./status"; + +// Export usage query +export { getGitHubSyncUsage } from "./usage"; diff --git a/freebuff/web/convex/github/sync/resolution.ts b/freebuff/web/convex/github/sync/resolution.ts new file mode 100644 index 0000000000..f4fb7f7d74 --- /dev/null +++ b/freebuff/web/convex/github/sync/resolution.ts @@ -0,0 +1,462 @@ +"use node"; + +import { action, internalAction } from "../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; + +/** + * Conflict Resolution for GitHub Sync + * + * Handles resolution of diverged repositories and merge conflicts. + * Provides both specific and unified resolution approaches. + * + * Consolidated from resolution.ts and unified_resolution.ts + */ + +/** + * Internal action for resolving divergence + */ +export const resolveDivergenceInternal = internalAction({ + args: { + projectId: v.id("project"), + strategy: v.union( + v.literal("use_github_version"), + v.literal("use_local_version"), + v.literal("use_backup_version"), + ), + backupBranch: v.optional(v.string()), // Required for use_backup_version + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + newStatus: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + newStatus: string; + }> => { + try { + console.log( + `[ResolveDivergence] Resolving divergence for project ${args.projectId} using strategy: ${args.strategy}`, + ); + + // Get project details + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + + if (!project) { + return { + success: false, + message: "Project not found", + newStatus: "error", + }; + } + + // Get GitHub context using centralized service + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: true, + requireSyncState: true, + }, + ); + + if ( + !projectContext.success || + !projectContext.syncState || + !projectContext.connection + ) { + return { + success: false, + message: projectContext.error || "GitHub context not available", + newStatus: "error", + }; + } + + const syncState = projectContext.syncState; + const connection = projectContext.connection; + + // Handle backup restoration separately (not yet implemented) + if (args.strategy === "use_backup_version") { + return { + success: false, + message: "Backup restoration not yet implemented", + newStatus: "error", + }; + } + + // Update sync status to pending during resolution + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "pending", + lastSyncTime: Date.now(), + errorMessage: `Resolving with strategy: ${args.strategy}`, + }); + + // Fetch GitHub token once for this resolution flow + console.log( + `[ResolveDivergence] Fetching GitHub token for project ${args.projectId}`, + ); + const tokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: + args.strategy === "use_github_version" + ? "github_to_project" + : "project_to_github", + projectId: args.projectId, + accessToken: connection.access_token, + installationId: connection.installation_id, + }, + operationName: "resolution", + }, + ); + + // Execute the resolution using the simple resolution executor service + const result = await ctx.runAction( + internal.github.sync.services.resolutionExecutorService + .executeResolution, + { + projectId: args.projectId, + strategy: args.strategy, + repoOwner: syncState.github_repo_owner, + repoName: syncState.github_repo_name, + sandboxId: project.sandbox_id, + githubToken: tokenResult.token, + githubTokenType: tokenResult.tokenType, + installationId: connection.installation_id, + }, + ); + + // Update sync status based on result + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: result.success ? "synced" : "error", + lastSyncTime: Date.now(), + errorMessage: result.success ? undefined : result.message, + }); + + if (result.success) { + await ctx.runMutation(internal.project.setStateDone, { + projectId: args.projectId, + }); + + return { + success: true, + message: result.message, + newStatus: "synced", + }; + } else { + return { + success: false, + message: result.message || "Failed to resolve divergence", + newStatus: "error", + }; + } + } catch (error) { + console.error("Error in resolveDivergence:", error); + + // Update sync status on error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "error", + lastSyncTime: Date.now(), + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + newStatus: "error", + }; + } + }, +}); + +/** + * Public wrapper for resolving divergence + */ +export const resolveDivergence = action({ + args: { + projectId: v.id("project"), + strategy: v.union( + v.literal("use_github_version"), + v.literal("use_local_version"), + v.literal("use_backup_version"), + ), + backupBranch: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + newStatus: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + message: string; + newStatus: string; + }> => { + return await ctx.runAction( + internal.github.sync.resolution.resolveDivergenceInternal, + args, + ); + }, +}); + +/** + * Get conflict information for a project + */ +export const getConflictInfo = action({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + hasConflicts: v.boolean(), + conflictType: v.optional(v.string()), + conflictDetails: v.optional(v.any()), + }), + handler: async ( + ctx, + args, + ): Promise<{ + hasConflicts: boolean; + conflictType?: string; + conflictDetails?: any; + }> => { + try { + // Get sync state to check for conflicts + const syncState: any = await ctx.runQuery( + internal.github.auth.getSyncStateByProject, + { + projectId: args.projectId, + }, + ); + + if (!syncState) { + return { + hasConflicts: false, + }; + } + + const hasConflicts: boolean = syncState.sync_status === "conflict"; + + return { + hasConflicts, + conflictType: hasConflicts ? "divergence" : undefined, + conflictDetails: hasConflicts + ? { + divergence: { + isDivergent: true, + ahead: 0, + behind: 0, + status: "diverged", + canFastForward: false, + }, + } + : undefined, + }; + } catch (error) { + console.error("Error getting conflict info:", error); + return { + hasConflicts: false, + }; + } + }, +}); + +/** + * Get available resolution options for a project + */ +export const getResolutionOptions = action({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + canUseGitHub: v.boolean(), + canUseLocal: v.boolean(), + canUseBackup: v.boolean(), + availableBackups: v.array(v.string()), + recommendedStrategy: v.string(), + }), + handler: async (ctx, args) => { + try { + // For now, return default values since backup functionality might not be fully implemented + const backups = { + backups: [], + }; + + return { + canUseGitHub: true, // Always available + canUseLocal: true, // Always available + canUseBackup: backups.backups.length > 0, + availableBackups: backups.backups.map((b: any) => b.branchName), + recommendedStrategy: "use_local_version", // Default recommendation + }; + } catch (error) { + console.error("Error getting resolution options:", error); + return { + canUseGitHub: true, + canUseLocal: true, + canUseBackup: false, + availableBackups: [], + recommendedStrategy: "use_local_version", + }; + } + }, +}); + +/** + * Unified conflict resolution that detects conflict type and routes to appropriate handler + */ +export const resolveConflicts = action({ + args: { + projectId: v.id("project"), + resolutionType: v.union( + v.literal("divergence"), // Repository divergence + v.literal("auto_detect"), // Auto-detect based on conflict data + ), + // For divergence conflicts + divergenceStrategy: v.optional( + v.union( + v.literal("use_github_version"), + v.literal("use_local_version"), + v.literal("use_backup_version"), + ), + ), + backupBranch: v.optional(v.string()), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + resolvedType: v.string(), + }), + handler: async ( + ctx, + args, + ): Promise<{ success: boolean; message: string; resolvedType: string }> => { + try { + console.log( + `[UnifiedResolution] Resolving conflicts for project ${args.projectId}`, + ); + console.log(`[UnifiedResolution] Args:`, args); + + // Get GitHub context using centralized service + console.log(`[UnifiedResolution] Getting GitHub context...`); + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: false, + requireSyncState: true, + }, + ); + const syncState = projectContext.success + ? projectContext.syncState + : null; + console.log(`[UnifiedResolution] Sync state:`, syncState); + + if ( + !syncState || + (syncState.sync_status !== "conflict" && + syncState.sync_status !== "error") + ) { + return { + success: false, + message: "No conflicts found for this project", + resolvedType: "none", + }; + } + + // Check if this is a repository divergence error (CodeSandbox constructor issue indicates divergence) + const isCodeSandboxError = syncState.error_message?.includes( + "browser_default is not a constructor", + ); + const hasRepositoryDivergence = + syncState.sync_status === "error" && isCodeSandboxError; + + // If we detect a CodeSandbox constructor error, update the status to "conflict" so the UI can handle it + if (hasRepositoryDivergence) { + console.log( + `[UnifiedResolution] Converting error status to conflict for CodeSandbox constructor issue`, + ); + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "conflict", + lastSyncTime: Date.now(), + errorMessage: + "Repository divergence detected - manual resolution required", + }); + } + + // Auto-detect conflict type based on sync state + let detectedType = "unknown"; + if ( + hasRepositoryDivergence || + syncState.sync_status === "conflict" || + args.resolutionType === "divergence" + ) { + detectedType = "divergence"; + } + + console.log( + `[UnifiedResolution] Detected conflict type: ${detectedType}`, + ); + + // Handle divergence conflicts + if (detectedType === "divergence") { + const strategy = args.divergenceStrategy || "use_local_version"; + console.log( + `[UnifiedResolution] Resolving divergence with strategy: ${strategy}`, + ); + + // Delegate to the dedicated resolveDivergence internal action + const divergenceResult = await ctx.runAction( + internal.github.sync.resolution.resolveDivergenceInternal, + { + projectId: args.projectId, + strategy: strategy, + backupBranch: args.backupBranch, + }, + ); + + console.log( + `[UnifiedResolution] Divergence resolution result:`, + divergenceResult, + ); + + return { + success: divergenceResult.success, + message: divergenceResult.message, + resolvedType: "divergence", + }; + } + + // For other conflict types, return unhandled for now + return { + success: false, + message: `Conflict type '${detectedType}' not yet supported by unified resolution`, + resolvedType: detectedType, + }; + } catch (error) { + console.error("[UnifiedResolution] Error resolving conflicts:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Unknown error", + resolvedType: "error", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/sync/rollback.ts b/freebuff/web/convex/github/sync/rollback.ts new file mode 100644 index 0000000000..5a521ad16b --- /dev/null +++ b/freebuff/web/convex/github/sync/rollback.ts @@ -0,0 +1,262 @@ +"use node"; + +import { action } from "../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { type BackupInfo } from "../../../codebase-utils/codebase/ExtendedGitOperations"; + +/** + * Rollback functionality for the atomic sync engine + * + * Provides users with the ability to rollback to backup states + * when conflicts occur or sync operations fail. + */ + +/** + * Rollback to a backup state (local or GitHub branch) + */ +export const rollbackToBackup = action({ + args: { + projectId: v.id("project"), + backupSource: v.union(v.literal("local"), v.literal("github")), + backupId: v.optional(v.string()), // For local backups + githubBranch: v.optional(v.string()), // For GitHub branch backups + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + restoredFrom: v.string(), + }), + handler: async (ctx, args) => { + try { + console.log(`[Rollback] Starting rollback for project ${args.projectId}`); + + // Get project details + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + + if (!project) { + return { + success: false, + message: "Project not found", + restoredFrom: "", + }; + } + + let restoredFrom = ""; + + if (args.backupSource === "local" && args.backupId) { + // Restore from local backup using backupService + console.log(`[Rollback] Restoring from local backup: ${args.backupId}`); + const backupInfo: BackupInfo = { + branchName: args.backupId, + timestamp: "", + originalBranch: "main", + hasUncommittedWork: false, + }; + await ctx.runAction( + internal.github.sync.services.backupService.restoreFromBackup, + { + sandboxId: project.sandbox_id, + backupInfo, + packageManager: project.packageManager, + }, + ); + restoredFrom = `Local backup: ${args.backupId}`; + } else if (args.backupSource === "github" && args.githubBranch) { + // Restore from GitHub backup branch + console.log( + `[Rollback] Restoring from GitHub branch: ${args.githubBranch}`, + ); + + // Use the backupService's restore functionality with GitHub branch + const backupInfo: BackupInfo = { + branchName: args.githubBranch, + timestamp: "", + originalBranch: "main", + hasUncommittedWork: false, + githubBackupRef: args.githubBranch, + }; + + await ctx.runAction( + internal.github.sync.services.backupService.restoreFromBackup, + { + sandboxId: project.sandbox_id, + backupInfo, + packageManager: project.packageManager, + }, + ); + + restoredFrom = `GitHub branch: ${args.githubBranch}`; + } else { + return { + success: false, + message: "Invalid rollback parameters provided", + restoredFrom: "", + }; + } + + // Update sync status to indicate rollback + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "synced", + lastSyncTime: Date.now(), + errorMessage: undefined, + }); + + console.log(`[Rollback] Successfully restored from ${restoredFrom}`); + + return { + success: true, + message: `Successfully restored project state from ${restoredFrom}`, + restoredFrom, + }; + } catch (error: any) { + console.error("[Rollback] Failed to rollback:", error); + return { + success: false, + message: error.message || "Unknown error during rollback", + restoredFrom: "", + }; + } + }, +}); + +/** + * List available backups for a project + */ +export const listAvailableBackups = action({ + args: { + projectId: v.id("project"), + }, + returns: v.object({ + success: v.boolean(), + localBackups: v.array( + v.object({ + id: v.string(), + description: v.string(), + timestamp: v.string(), + }), + ), + githubBackups: v.array( + v.object({ + branch: v.string(), + description: v.string(), + timestamp: v.string(), + }), + ), + message: v.string(), + }), + handler: async (ctx, args) => { + try { + console.log(`[Rollback] Listing backups for project ${args.projectId}`); + + // Get project details + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + + if (!project) { + return { + success: false, + localBackups: [], + githubBackups: [], + message: "Project not found", + }; + } + + // For now, let's use a simpler approach to avoid CodeSandbox connection issues + console.log(`[Rollback] Using simplified backup listing approach`); + + // Get sync state to find GitHub repository info + const syncState = await ctx.runQuery( + internal.github.auth.getSyncStateByProject, + { + projectId: args.projectId, + }, + ); + + // List local backups (placeholder - would need CodeSandbox access) + const localBackups: any[] = []; + console.log( + "[Rollback] Local backups not accessible without CodeSandbox connection", + ); + + // List GitHub backup branches using GitHub API instead of git commands + const githubBackups: any[] = []; + + if (syncState) { + try { + // Get GitHub connection for API access + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectConnection, + { + projectId: args.projectId, + }, + ); + + if (projectContext.success && projectContext.connection) { + const connection = projectContext.connection; + + { + // Use GitHub API to list backup branches + const response = await fetch( + `https://api.github.com/repos/${syncState.github_repo_owner}/${syncState.github_repo_name}/branches`, + { + headers: { + Authorization: `token ${connection.access_token}`, + Accept: "application/vnd.github.v3+json", + }, + }, + ); + + if (response.ok) { + const branches = await response.json(); + const backupBranches = branches.filter((branch: any) => + branch.name.startsWith("backup-"), + ); + + for (const branch of backupBranches) { + githubBackups.push({ + branch: branch.name, + description: branch.name + .replace(/backup-|[-]/g, " ") + .trim(), + timestamp: branch.name.split("-").slice(-2).join("-"), + }); + } + } + } + } + } catch (error) { + console.log( + "[Rollback] Could not list GitHub backup branches:", + error, + ); + } + } + + return { + success: true, + localBackups, + githubBackups, + message: `Found ${localBackups.length} local backups and ${githubBackups.length} GitHub backup branches`, + }; + } catch (error: any) { + console.error("[Rollback] Failed to list backups:", error); + return { + success: false, + localBackups: [], + githubBackups: [], + message: error.message || "Unknown error listing backups", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/sync/services/backupService.ts b/freebuff/web/convex/github/sync/services/backupService.ts new file mode 100644 index 0000000000..5c572773f2 --- /dev/null +++ b/freebuff/web/convex/github/sync/services/backupService.ts @@ -0,0 +1,230 @@ +"use node"; + +import { internalAction } from "../../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../../_generated/api"; +import { getGitCodebase } from "../../../../codebase-utils/codebase/codebaseHelper"; +import type { BackupResult } from "../types"; +import { getProjectPackageManager } from "../../../../codebase-utils/packageManager"; + +/** + * Backup Service + * + * Service for managing backup operations during sync processes. + * Handles checking for recent backups and creating new backup branches + * with proper GitHub integration. + * + * All operations use the provider-agnostic Codebase interface. + */ + +/** + * Restore from a backup branch + */ +export const restoreFromBackup = internalAction({ + args: { + sandboxId: v.string(), + backupInfo: v.object({ + branchName: v.string(), + timestamp: v.string(), + commitHash: v.optional(v.string()), + originalBranch: v.string(), + hasUncommittedWork: v.boolean(), + githubBackupRef: v.optional(v.string()), + }), + packageManager: v.optional(v.union(v.literal("pnpm"), v.literal("bun"))), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + // Initialize codebase + const codebase = await getGitCodebase(args.sandboxId, args.packageManager); + + await codebase.restoreFromBackup(args.backupInfo); + + // Reinstall dependencies after restore to fix potential node_modules corruption + console.log( + "[BackupService] Reinstalling dependencies after backup restore...", + ); + await codebase.installDependencies(); + }, +}); + +/** + * Clean up old backup branches (local only, GitHub backups are preserved) + */ +export const cleanupOldBackups = internalAction({ + args: { + sandboxId: v.string(), + keepCount: v.optional(v.number()), + packageManager: v.optional(v.union(v.literal("pnpm"), v.literal("bun"))), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + // Initialize codebase + const codebase = await getGitCodebase(args.sandboxId, args.packageManager); + await codebase.cleanupOldBackups(args.keepCount); + }, +}); + +/** + * Check if we have a recent backup to avoid creating duplicates + */ +export const checkForRecentBackup = internalAction({ + args: { + sandboxId: v.string(), + projectDir: v.string(), + operationType: v.string(), + packageManager: v.optional(v.union(v.literal("pnpm"), v.literal("bun"))), + }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args): Promise => { + try { + // Initialize codebase + const codebase = await getGitCodebase( + args.sandboxId, + args.packageManager, + ); + + // Look for backup branches created in the last 10 minutes + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000); + const timeString = tenMinutesAgo + .toISOString() + .slice(0, 16) + .replace(/[:.]/g, "-"); + + // List recent backup branches + const branches = await codebase.listBranches(); + const remoteBranches = branches.filter( + (b) => b.startsWith("remotes/") || b.startsWith("origin/"), + ); + const backupBranches = remoteBranches + .filter((b) => b.includes(`backup-${args.operationType}`)) + .slice(0, 5); + + if (backupBranches.length > 0) { + // Check if any branch is recent enough + for (const branch of backupBranches) { + const branchName = branch.trim().replace(/^(remotes\/)?origin\//, ""); + if (branchName.includes(timeString.slice(0, 13))) { + // Match up to hour/minute + console.log("[BackupService] Found recent backup:", branchName); + return branchName; + } + } + } + + return null; + } catch (error) { + console.log("[BackupService] Could not check for recent backups:", error); + return null; + } + }, +}); + +/** + * Create backup and push it to GitHub as a branch + * This creates a safety net that's visible to the user + */ +export const createBackupWithGitHubBranch = internalAction({ + args: { + sandboxId: v.string(), + projectDir: v.string(), + operation: v.object({ + type: v.string(), + projectId: v.id("project"), + accessToken: v.string(), + installationId: v.optional(v.number()), + repoOwner: v.string(), + repoName: v.string(), + options: v.optional( + v.object({ + createBackup: v.optional(v.boolean()), + conflictResolution: v.optional( + v.union(v.literal("manual"), v.literal("block")), + ), + }), + ), + }), + divergenceType: v.string(), + }, + returns: v.object({ + localBackupId: v.string(), + githubBackupBranch: v.string(), + canRollback: v.boolean(), + }), + handler: async (ctx, args): Promise => { + // Get project to retrieve package manager + const project = await ctx.runQuery(internal.github.auth.getProjectDetails, { + projectId: args.operation.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const packageManager = await getProjectPackageManager(ctx, project); + + // Initialize codebase + const codebase = await getGitCodebase(args.sandboxId, packageManager); + + console.log( + `[BackupService] Creating backup for ${args.divergenceType} operation`, + ); + + // Create local backup using codebase interface + // This handles uncommitted changes properly and returns to original branch cleanly + const backupInfo = await codebase.createBackupBranch( + `${args.operation.type}-sync-${args.divergenceType}`, + ); + + console.log( + `[BackupService] Local backup branch created: ${backupInfo.branchName}`, + ); + + // Push the backup branch to GitHub for visibility and redundancy + try { + // Get token for push operation + const pushTokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: args.operation.type, + projectId: args.operation.projectId, + accessToken: args.operation.accessToken, + installationId: args.operation.installationId, + }, + operationName: "backup_push", + }, + ); + + console.log( + `[BackupService] Pushing backup branch ${backupInfo.branchName} to GitHub`, + ); + + // Push the backup branch that was created by createBackupBranch + await codebase.push( + "github", + backupInfo.branchName, + false, // Don't force push backup branches + pushTokenResult.token, + args.operation.repoOwner, + args.operation.repoName, + ); + + console.log( + `[BackupService] Successfully pushed backup branch to GitHub: ${backupInfo.branchName}`, + ); + } catch (error) { + console.error( + "[BackupService] Failed to push backup branch to GitHub:", + error, + ); + // Continue - local backup still exists even if GitHub push fails + } + + return { + localBackupId: backupInfo.branchName, + githubBackupBranch: backupInfo.branchName, + canRollback: true, + }; + }, +}); diff --git a/freebuff/web/convex/github/sync/services/initialSync.ts b/freebuff/web/convex/github/sync/services/initialSync.ts new file mode 100644 index 0000000000..717f2461d4 --- /dev/null +++ b/freebuff/web/convex/github/sync/services/initialSync.ts @@ -0,0 +1,627 @@ +"use node"; + +import { internalAction } from "../../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../../_generated/api"; +import { getGitCodebase } from "../../../../codebase-utils/codebase/codebaseHelper"; +import { createGitHubRepositoryInternal } from "../../git/repository"; +import { getProjectPackageManager } from "../../../../codebase-utils/packageManager"; + +/** + * Initial Sync Service + * + * This handles: + * - Git repository initialization + * - User configuration + * - Remote setup + * - Initial commit creation + * - Push to GitHub + * - Sync status updates + */ + +export interface InitialSyncResult { + success: boolean; + status: "synced" | "error"; + message: string; + operations: string[]; + errors?: string[]; +} + +/** + * Perform initial sync for a project + * Public API for triggering initial sync (called from repositories.ts and webhookService.ts) + */ +export const performInitialSync = internalAction({ + args: { + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + try { + console.log("Starting initial sync for project:", args.projectId); + + // Get project details + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + + if (!project) { + throw new Error("Project not found"); + } + + // Get GitHub connection for the project owner with retry logic + // (webhooks can sometimes arrive before the connection is fully synced) + let projectContext: any; + const retries = 3; + let lastError = ""; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + console.log( + `[InitialSync] Attempting to get project connection (attempt ${attempt}/${retries})`, + ); + + projectContext = await ctx.runAction( + internal.github.services.projectService.getProjectConnection, + { + projectId: args.projectId, + }, + ); + + if (projectContext.success && projectContext.connection) { + console.log( + "[InitialSync] Successfully retrieved project connection", + ); + break; + } + + lastError = projectContext.error || "GitHub context not available"; + console.log(`[InitialSync] Attempt ${attempt} failed: ${lastError}`); + + if (attempt < retries) { + // Wait before retrying (exponential backoff) + const waitTime = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s + console.log(`[InitialSync] Waiting ${waitTime}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + } + } catch (connError) { + lastError = + connError instanceof Error ? connError.message : String(connError); + console.error( + `[InitialSync] Connection attempt ${attempt} threw error:`, + lastError, + ); + + if (attempt < retries) { + const waitTime = Math.pow(2, attempt) * 1000; + console.log(`[InitialSync] Waiting ${waitTime}ms before retry...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + } + } + } + + if (!projectContext?.success || !projectContext?.connection) { + throw new Error( + `Failed to get GitHub connection after ${retries} attempts: ${lastError}`, + ); + } + + const connection = projectContext.connection; + + // Call the initial sync service directly + const syncResult = await ctx.runAction( + internal.github.sync.services.initialSync.executeInitialSyncService, + { + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + accessToken: connection.access_token, + installationId: connection.installation_id, + }, + ); + + if (!syncResult.success) { + throw new Error(`Initial sync failed: ${syncResult.message}`); + } + + // Set up webhook for the repository + await ctx.runAction( + internal.github.sync.services.webhookService.setupRepositoryWebhook, + { + repoOwner: args.repoOwner, + repoName: args.repoName, + projectId: args.projectId, + }, + ); + + // Update sync status to synced + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "synced", + lastSyncTime: Date.now(), + }); + + console.log( + "Initial sync completed successfully for project:", + args.projectId, + ); + } catch (error) { + console.error("Initial sync failed:", error); + + // Update sync status with error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "error", + lastSyncTime: Date.now(), + errorMessage: error instanceof Error ? error.message : "Unknown error", + }); + } + + return null; + }, +}); + +/** + * Execute initial sync service - internal implementation + */ +export const executeInitialSyncService = internalAction({ + args: { + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + accessToken: v.string(), + installationId: v.optional(v.number()), + }, + returns: v.object({ + success: v.boolean(), + status: v.string(), + message: v.string(), + operations: v.array(v.string()), + errors: v.optional(v.array(v.string())), + }), + handler: async (ctx, args): Promise => { + const operations: string[] = []; + const errors: string[] = []; + + console.log( + `[InitialSyncService] Starting initial sync for project ${args.projectId}`, + ); + + try { + // Step 1: Get project details + operations.push("Getting project details"); + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { projectId: args.projectId }, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const sandboxId = project.sandbox_id; + operations.push(`Connecting to sandbox ${sandboxId}`); + + // WebContainer projects do not support server-side Codebase git operations. + // Reuse the WebContainer sync executor path (pending tool calls + isomorphic-git). + if (sandboxId.startsWith("webcontainer:")) { + operations.push("Detected WebContainer project"); + operations.push("Preparing authenticated GitHub token"); + + const tokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: "initial", + projectId: args.projectId, + accessToken: args.accessToken, + installationId: args.installationId, + }, + operationName: "initial_sync_webcontainer_push", + }, + ); + + operations.push("Running WebContainer initial push"); + const syncResult = await ctx.runAction( + internal.github.sync.services.syncExecutorService + .executeProjectToGitHubSync, + { + sandboxId, + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + accessToken: args.accessToken, + installationId: args.installationId, + githubToken: tokenResult.token, + githubTokenType: tokenResult.tokenType, + packageManager: project.packageManager, + }, + ); + + if (!syncResult.success) { + throw new Error(syncResult.message); + } + + operations.push("WebContainer initial push completed"); + return { + success: true, + status: "synced", + message: "Initial sync completed successfully", + operations, + errors: errors.length > 0 ? errors : undefined, + }; + } + + // Step 2: Get package manager + const packageManager = await getProjectPackageManager(ctx, project); + + // Step 3: Initialize codebase and verify git support + const codebase = await getGitCodebase(sandboxId, packageManager); + + operations.push("Codebase initialized with git support"); + + // Step 2.5: Ensure GitHub repository exists + operations.push("Checking GitHub repository"); + try { + await createGitHubRepositoryInternal( + args.repoOwner, + args.repoName, + args.accessToken, + args.installationId, + ctx, + args.projectId, + ); + operations.push("GitHub repository verified/created"); + } catch (error) { + throw new Error( + `GitHub repository setup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Step 3: Initialize git repository + operations.push("Initializing git repository"); + try { + await codebase.initRepository(); + operations.push("Git repository initialized successfully"); + } catch (error) { + // Git repo might already exist, check if it's already initialized + try { + const statusResult = await codebase.runCommand("git status", 5000); + if (statusResult.exitCode !== 0) { + throw new Error( + `Failed to initialize git: ${error instanceof Error ? error.message : String(error)}`, + ); + } + operations.push("Git repository already initialized"); + } catch (statusError) { + const errorMsg = `Git initialization failed: ${error instanceof Error ? error.message : String(error)}`; + errors.push(errorMsg); + console.warn(`[InitialSyncDomain] ${errorMsg}`); + } + } + + // Step 4: Configure git user + operations.push("Configuring git user"); + try { + await codebase.configureUser("Freebuff Agent", "agent@mail.freebuff.app"); + operations.push("Git user configured successfully"); + } catch (error) { + const errorMsg = `Git user configuration failed: ${error instanceof Error ? error.message : String(error)}`; + errors.push(errorMsg); + console.warn(`[InitialSyncDomain] ${errorMsg} (non-critical)`); + } + + // Step 5: Set up remote URL (with repository change detection) + operations.push("Setting up GitHub remote"); + const expectedUrl = `https://github.com/${args.repoOwner}/${args.repoName}.git`; + + try { + // First, check if remote exists and what URL it has + let existingRemote = ""; + try { + existingRemote = await codebase.getRemoteUrl("github"); + } catch (error) { + // Remote doesn't exist, which is fine + } + + if (existingRemote) { + // Mask tokens for logging + const maskedExisting = existingRemote.replace( + /https:\/\/[^@]+@/, + "https://***@", + ); + const maskedExpected = expectedUrl.replace( + /https:\/\/[^@]+@/, + "https://***@", + ); + console.log( + `[InitialSyncService] Existing remote found: ${maskedExisting}`, + ); + + // Compare just the repo part, not the token (tokens might change) + const existingRepo = existingRemote.replace(/https:\/\/[^@]+@/, ""); + const expectedRepo = expectedUrl.replace(/https:\/\/[^@]+@/, ""); + + if (existingRepo !== expectedRepo) { + // Different repository - remove old remote to clear tracking branches + console.log( + `[InitialSyncService] Remote repository mismatch, updating to: ${maskedExpected}`, + ); + + await codebase.removeRemote("github"); + await codebase.addRemote("github", expectedUrl); + + operations.push("GitHub remote updated (repository changed)"); + } else { + // Same repository - just update the URL (preserves tracking branches) + console.log( + `[InitialSyncService] Repository URL is correct, updating authentication`, + ); + + await codebase.setRemoteUrl("github", expectedUrl); + + operations.push("GitHub remote authentication updated"); + } + } else { + // No remote exists - add it + const maskedUrl = expectedUrl.replace( + /https:\/\/[^@]+@/, + "https://***@", + ); + console.log(`[InitialSyncService] Adding new remote: ${maskedUrl}`); + + await codebase.addRemote("github", expectedUrl); + + operations.push("GitHub remote added successfully"); + } + } catch (error) { + throw new Error( + `Remote setup failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Step 6: Ensure we're on main branch + operations.push("Ensuring correct branch"); + try { + const branchResult = await codebase.runCommand( + "git branch --show-current", + 5000, + ); + const currentBranch = + branchResult.exitCode === 0 ? branchResult.output.trim() : ""; + + if (currentBranch !== "main") { + // Try to checkout main branch + const checkoutResult = await codebase.runCommand( + "git checkout -B main", + 10000, + ); + if (checkoutResult.exitCode !== 0) { + throw new Error( + `Failed to create/checkout main branch: ${checkoutResult.output}`, + ); + } + operations.push("Switched to main branch"); + } else { + operations.push("Already on main branch"); + } + } catch (error) { + const errorMsg = `Branch setup failed: ${error instanceof Error ? error.message : String(error)}`; + errors.push(errorMsg); + console.warn(`[InitialSyncDomain] ${errorMsg}`); + } + + // Step 7: Check for existing commits + operations.push("Checking repository state"); + const logResult = await codebase.runCommand( + "git rev-list --count HEAD 2>/dev/null || echo '0'", + 5000, + ); + const commitCount = parseInt(logResult.output.trim()) || 0; + const hasCommits = commitCount > 0; + + operations.push(`Repository has ${commitCount} existing commits`); + + // Step 8: Handle files and commits + if (!hasCommits) { + operations.push("Creating initial commit"); + + // Check for files in the project + const fileCountResult = await codebase.runCommand( + 'find . -type f -not -path "./.git/*" -not -name ".DS_Store" | wc -l', + 10000, + ); + const fileCount = parseInt(fileCountResult.output.trim()) || 0; + + if (fileCount > 0) { + // Add all files and create initial commit + const addResult = await codebase.runCommand("git add -A", 15000); + if (addResult.exitCode !== 0) { + throw new Error(`Failed to add files: ${addResult.output}`); + } + + const commitResult = await codebase.runCommand( + 'git commit -m "Initial commit from vly.ai"', + 15000, + ); + if (commitResult.exitCode !== 0) { + throw new Error(`Failed to create commit: ${commitResult.output}`); + } + + operations.push(`Initial commit created with ${fileCount} files`); + } else { + // Create empty initial commit + const commitResult = await codebase.runCommand( + 'git commit --allow-empty -m "Initial commit from vly.ai"', + 10000, + ); + if (commitResult.exitCode !== 0) { + throw new Error( + `Failed to create empty commit: ${commitResult.output}`, + ); + } + + operations.push("Empty initial commit created"); + } + } else { + // Check for uncommitted changes + const status = await codebase.getStatus(); + const hasUncommittedFiles = + status.staged.length > 0 || + status.unstaged.length > 0 || + status.untracked.length > 0; + if (hasUncommittedFiles) { + operations.push("Committing uncommitted changes"); + + const addResult = await codebase.runCommand("git add -A", 15000); + if (addResult.exitCode !== 0) { + throw new Error(`Failed to add files: ${addResult.output}`); + } + + // Check if anything was actually staged after git add + const statusAfterAdd = await codebase.getStatus(); + const hasStagedFiles = statusAfterAdd.staged.length > 0; + + if (hasStagedFiles) { + const commitResult = await codebase.runCommand( + 'git commit -m "Sync uncommitted changes from vly.ai"', + 15000, + ); + if (commitResult.exitCode !== 0) { + // Check if this is a "nothing to commit" case, which is actually fine + if (commitResult.output.includes("nothing to commit")) { + console.log( + "[InitialSyncService] Git reports nothing to commit (working tree clean)", + ); + operations.push( + "No actual changes to commit (working tree clean)", + ); + } else { + throw new Error( + `Failed to commit changes: ${commitResult.output}`, + ); + } + } else { + operations.push("Uncommitted changes committed"); + } + } else { + operations.push("No changes staged after git add, skipping commit"); + } + } else { + operations.push("No uncommitted changes to commit"); + } + } + + // Step 9: Push to GitHub using codebase interface + operations.push("Pushing to GitHub"); + + // Get token for push operation + const pushTokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: "initial", + projectId: args.projectId, + accessToken: args.accessToken, + installationId: args.installationId, + }, + operationName: "initial_sync_push", + }, + ); + + // Push directly using codebase interface + await codebase.push( + "github", + "main", + false, // Don't force push + pushTokenResult.token, + args.repoOwner, + args.repoName, + ); + + operations.push("Successfully pushed to GitHub"); + + // Step 10: Update sync status + operations.push("Updating sync status"); + + // Get current GitHub context to see if sync state exists + const projectContext = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: args.projectId, + requireConnection: false, + requireSyncState: false, + }, + ); + + if (projectContext.success && projectContext.syncState) { + // Update existing sync state + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "synced", + lastSyncTime: Date.now(), + errorMessage: undefined, + }); + operations.push("Sync status updated"); + } else { + // Create new sync state record + await ctx.runMutation(internal.github.sync.status.createSyncState, { + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + syncDirection: "bidirectional", + status: "synced", + }); + operations.push("Sync state created"); + } + + console.log( + `[InitialSyncDomain] Initial sync completed successfully for project ${args.projectId}`, + ); + + return { + success: true, + status: "synced", + message: "Initial sync completed successfully", + operations, + errors: errors.length > 0 ? errors : undefined, + }; + } catch (error: any) { + const errorMessage = error.message || "Unknown error occurred"; + console.error( + `[InitialSyncDomain] Initial sync failed for project ${args.projectId}:`, + error, + ); + + // Update sync status with error + try { + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "error", + lastSyncTime: Date.now(), + errorMessage, + }); + operations.push("Error status updated"); + } catch (statusError) { + console.error( + "[InitialSyncDomain] Failed to update error status:", + statusError, + ); + errors.push("Failed to update error status"); + } + + return { + success: false, + status: "error", + message: errorMessage, + operations, + errors: [...errors, errorMessage], + }; + } + }, +}); diff --git a/freebuff/web/convex/github/sync/services/resolutionExecutorService.ts b/freebuff/web/convex/github/sync/services/resolutionExecutorService.ts new file mode 100644 index 0000000000..0d26450dd6 --- /dev/null +++ b/freebuff/web/convex/github/sync/services/resolutionExecutorService.ts @@ -0,0 +1,348 @@ +"use node"; + +import { internalAction } from "../../../_generated/server"; +import { v } from "convex/values"; +import { + getGitCodebase, + type GitCodebase, +} from "../../../../codebase-utils/codebase/codebaseHelper"; +import { hasDevServer } from "../../../../codebase-utils/codebase/Codebase"; +import { logTokenUsage } from "../../tokens"; +import type { ActionCtx } from "../../../_generated/server"; +import type { Id } from "../../../_generated/dataModel"; +import { internal } from "../../../_generated/api"; +import { getProjectPackageManager } from "../../../../codebase-utils/packageManager"; + +/** + * Resolution Executor Service + * + * Simple, direct resolution of diverged repository states. + * When user explicitly chooses a resolution strategy, execute it directly + * without complex preflight checks or conditional logic. + * + * Always creates backup branches for safety. + */ + +type ResolutionContext = { + ctx: ActionCtx; + codebase: GitCodebase; + projectId: Id<"project">; + repoOwner: string; + repoName: string; + installationId: number | undefined; + githubToken: string; + githubTokenType: "installation" | "oauth"; +}; + +/** + * Create and push a backup branch to GitHub + */ +async function createAndPushBackupBranch( + context: ResolutionContext, + branchName: string, + fromRef?: string, +) { + console.log( + `[ResolutionExecutor] Creating backup branch: ${branchName}${fromRef ? ` from ${fromRef}` : ""}`, + ); + await context.codebase.createBranch(branchName, fromRef); + + console.log("[ResolutionExecutor] Pushing backup branch to GitHub"); + + await context.codebase.push( + "github", + branchName, + false, + context.githubToken, + context.repoOwner, + context.repoName, + ); + + logTokenUsage({ + operation: "resolution_backup_push", + tokenType: context.githubTokenType, + success: true, + installationId: + context.githubTokenType === "installation" + ? context.installationId + : undefined, + }); + + console.log(`[ResolutionExecutor] Backup branch pushed: ${branchName}`); + return branchName; +} + +/** + * Fetch latest refs from GitHub + */ +async function fetchFromGitHub(context: ResolutionContext) { + console.log("[ResolutionExecutor] Fetching latest from GitHub"); + + await context.codebase.fetch( + "github", + context.githubToken, + context.repoOwner, + context.repoName, + ); + + logTokenUsage({ + operation: "resolution_fetch", + tokenType: context.githubTokenType, + success: true, + installationId: + context.githubTokenType === "installation" + ? context.installationId + : undefined, + }); +} + +/** + * Reset local repository to GitHub's version with verification + */ +async function resetToGitHubVersion(codebase: GitCodebase) { + const githubCommit = await codebase.getCommitHash("github/main"); + console.log( + `[ResolutionExecutor] Resetting to GitHub version: ${githubCommit}`, + ); + + await codebase.resetHard("github/main"); + + // Verify the reset worked + const newCommit = await codebase.getCommitHash("HEAD"); + console.log(`[ResolutionExecutor] Reset complete - now at ${newCommit}`); + + if (newCommit !== githubCommit) { + throw new Error( + `Reset verification failed - expected ${githubCommit}, got ${newCommit}`, + ); + } +} + +/** + * Update upstream branch tracking (best effort, non-critical) + */ +async function updateUpstreamTracking(codebase: GitCodebase) { + try { + await codebase.setUpstreamBranch("github", "main", "main"); + console.log( + "[ResolutionExecutor] Updated upstream tracking to github/main", + ); + } catch (upstreamError) { + console.log( + "[ResolutionExecutor] Could not set upstream tracking (non-critical):", + upstreamError, + ); + } +} + +/** + * Strategy: Use GitHub's version, backup local work + */ +async function executeUseGitHubVersion( + context: ResolutionContext, + timestamp: number, +) { + console.log( + "[ResolutionExecutor] Using GitHub version - backing up local work", + ); + + const backupBranchName = `backup-local-${timestamp}`; + + // Create and push local backup + await createAndPushBackupBranch(context, backupBranchName); + + // Checkout main and fetch latest + console.log("[ResolutionExecutor] Checking out main branch"); + await context.codebase.checkoutBranch("main"); + await fetchFromGitHub(context); + + // Reset to GitHub's version + await resetToGitHubVersion(context.codebase); + + // Install dependencies to fix any node_modules issues + console.log("[ResolutionExecutor] Installing dependencies"); + await context.codebase.installDependencies(); + + // Restart dev server to pick up newly installed dependencies + if (hasDevServer(context.codebase)) { + console.log("[ResolutionExecutor] Restarting dev server"); + await context.codebase.restartDevServer(); + } + + // Validate and fix cron intervals after resolution + const { runCronValidationWithLogging } = await import( + "../cronPostSyncValidator" + ); + await runCronValidationWithLogging( + context.ctx, + context.codebase, + context.projectId, + context.githubToken, + context.repoOwner, + context.repoName, + "ResolutionExecutor (GitHub)", + ); + + // Update upstream tracking + await updateUpstreamTracking(context.codebase); + + return { + success: true, + message: `Successfully reset to GitHub version. Your local work is safely backed up in branch '${backupBranchName}'.`, + backupBranch: backupBranchName, + }; +} + +/** + * Strategy: Use local version, backup GitHub work + */ +async function executeUseLocalVersion( + context: ResolutionContext, + timestamp: number, +) { + console.log( + "[ResolutionExecutor] Using local version - backing up GitHub work", + ); + + const backupBranchName = `backup-github-${timestamp}`; + + // Fetch latest GitHub refs + await fetchFromGitHub(context); + + // Create and push backup of GitHub's current state + await createAndPushBackupBranch( + context, + backupBranchName, + "github/main", // Create backup from GitHub's version + ); + + // Validate and fix cron intervals before pushing + const { runCronValidationWithLogging } = await import( + "../cronPostSyncValidator" + ); + await runCronValidationWithLogging( + context.ctx, + context.codebase, + context.projectId, + context.githubToken, + context.repoOwner, + context.repoName, + "ResolutionExecutor (Local)", + ); + + // Force push local version to GitHub (will include any cron fixes) + console.log("[ResolutionExecutor] Force pushing local version to GitHub"); + + await context.codebase.push( + "github", + "main", + true, // force push + context.githubToken, + context.repoOwner, + context.repoName, + ); + + logTokenUsage({ + operation: "resolution_force_push", + tokenType: context.githubTokenType, + success: true, + installationId: + context.githubTokenType === "installation" + ? context.installationId + : undefined, + }); + + console.log("[ResolutionExecutor] Successfully force pushed local version"); + + return { + success: true, + message: `Successfully pushed local version to GitHub. GitHub's previous state is safely backed up in branch '${backupBranchName}'.`, + backupBranch: backupBranchName, + }; +} + +/** + * Execute divergence resolution based on user's chosen strategy + */ +export const executeResolution = internalAction({ + args: { + projectId: v.id("project"), + strategy: v.union( + v.literal("use_github_version"), + v.literal("use_local_version"), + ), + repoOwner: v.string(), + repoName: v.string(), + sandboxId: v.string(), + githubToken: v.string(), + githubTokenType: v.union(v.literal("installation"), v.literal("oauth")), + installationId: v.optional(v.number()), + }, + returns: v.object({ + success: v.boolean(), + message: v.string(), + backupBranch: v.string(), + }), + handler: async (ctx, args) => { + console.log( + `[ResolutionExecutor] Executing resolution strategy: ${args.strategy}`, + ); + + // Get project to retrieve package manager + const project = await ctx.runQuery(internal.github.auth.getProjectDetails, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const packageManager = await getProjectPackageManager(ctx, project); + + const timestamp = Date.now(); + const codebase = await getGitCodebase(args.sandboxId, packageManager); + + // // Clean up any existing conflicted state first + // console.log("[ResolutionExecutor] Cleaning up any conflicted git state"); + // try { + // await codebase.cleanupConflictedState(); + // } catch (cleanupError) { + // console.log( + // "[ResolutionExecutor] Cleanup reported issues (non-critical):", + // cleanupError, + // ); + // } + + // Build context object for helper functions + const context: ResolutionContext = { + ctx, + codebase, + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + githubToken: args.githubToken, + githubTokenType: args.githubTokenType, + installationId: args.installationId, + }; + + try { + if (args.strategy === "use_github_version") { + return await executeUseGitHubVersion(context, timestamp); + } else if (args.strategy === "use_local_version") { + return await executeUseLocalVersion(context, timestamp); + } + + throw new Error(`Unknown strategy: ${args.strategy}`); + } catch (error: any) { + console.error( + `[ResolutionExecutor] Resolution failed for strategy ${args.strategy}:`, + error, + ); + return { + success: false, + message: `Resolution failed: ${error.message || error}`, + backupBranch: "", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/sync/services/syncExecutorService.ts b/freebuff/web/convex/github/sync/services/syncExecutorService.ts new file mode 100644 index 0000000000..7427512ce6 --- /dev/null +++ b/freebuff/web/convex/github/sync/services/syncExecutorService.ts @@ -0,0 +1,1100 @@ +"use node"; + +import { internalAction } from "../../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../../_generated/api"; +import { logTokenUsage } from "../../tokens"; +import { getGitCodebase } from "../../../../codebase-utils/codebase/codebaseHelper"; +import type { SyncResult } from "../types"; +import { runCronValidationWithLogging } from "../cronPostSyncValidator"; +import { getProjectPackageManager } from "../../../../codebase-utils/packageManager"; +import * as fs from "node:fs"; +import { promises as fsPromises } from "node:fs"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import git from "isomorphic-git"; +import http from "isomorphic-git/http/node"; + +/** + * Sync Executor Service + * + * Service for executing different types of sync operations: + * - GitHub to Project sync + * - Project to GitHub sync + * + * This consolidates the core sync execution logic that was previously + * scattered throughout the engine.ts file. + */ + +const WEBCONTAINER_TOOL_TIMEOUT_MS = 120_000; +const WEBCONTAINER_READ_BATCH_SIZE = 100; + +type PendingToolCall = { + _id: string; + status: "pending" | "done" | "error"; + output?: unknown; + error?: string; +}; + +/** + * Keep agent unblocked even when sync hits conflicts. + */ +async function keepAgentActive( + ctx: any, + projectId: string, + reason: string, +): Promise { + try { + await ctx.runMutation(internal.project.setStateDone, { + projectId, + }); + console.log(`[SyncExecutorService] Agent kept active: ${reason}`); + } catch (stateError) { + console.error( + "[SyncExecutorService] Failed to keep agent active:", + stateError, + ); + } +} + +function getGitHubReauthMessageIfNeeded( + rawErrorMessage: string, +): string | undefined { + const message = rawErrorMessage.toLowerCase(); + const isAuthFailure = + message.includes("bad credentials") || + message.includes("401") || + message.includes("unauthorized") || + message.includes("token expired") || + message.includes("oauth token") || + message.includes("expired token") || + message.includes("installation token") || + message.includes("authentication failed") || + message.includes("resource not accessible by integration"); + + if (!isAuthFailure) { + return undefined; + } + + return "GitHub authentication has expired or is invalid. Please reconnect GitHub and try syncing again."; +} + +export { getGitHubReauthMessageIfNeeded }; + +async function waitForWebContainerToolCall( + ctx: any, + callId: string, + timeoutMs: number, +): Promise { + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + const call = (await ctx.runQuery( + internal.codesandbox.pendingToolCalls.getToolCallById, + { callId }, + )) as PendingToolCall | null; + + if (!call) { + throw new Error("WebContainer tool call disappeared before completion."); + } + + if (call.status === "done") { + return call.output; + } + + if (call.status === "error") { + throw new Error(call.error ?? "WebContainer tool execution failed."); + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await ctx.runMutation(internal.codesandbox.pendingToolCalls.failToolCall, { + callId, + error: "Timed out waiting for WebContainer tool execution.", + }); + + throw new Error("Timed out waiting for WebContainer tool execution."); +} + +async function runWebContainerTool( + ctx: any, + args: { + projectId: string; + runId: string; + toolName: string; + input: unknown; + timeoutMs?: number; + }, +): Promise { + const callId = await ctx.runMutation( + internal.codesandbox.pendingToolCalls.enqueueToolCall, + { + runId: args.runId, + projectId: args.projectId, + toolName: args.toolName, + input: args.input, + }, + ); + + return await waitForWebContainerToolCall( + ctx, + callId, + args.timeoutMs ?? WEBCONTAINER_TOOL_TIMEOUT_MS, + ); +} + +function normalizeSyncPath(filePath: string): string | null { + const normalized = filePath.replace(/\\/g, "/").replace(/^\.?\//, ""); + if (!normalized) return null; + if (normalized.startsWith("../") || normalized.includes("/../")) return null; + if (normalized === ".git" || normalized.startsWith(".git/")) return null; + return normalized; +} + +async function listWebContainerFiles( + ctx: any, + projectId: string, + runId: string, +): Promise { + const raw = (await runWebContainerTool(ctx, { + projectId, + runId, + toolName: "list_directory", + input: { path: "." }, + })) as { files?: unknown; truncated?: unknown }; + + const files = Array.isArray(raw?.files) + ? raw.files.filter((value): value is string => typeof value === "string") + : []; + + if (typeof raw?.truncated === "number" && raw.truncated > 0) { + throw new Error( + `WebContainer returned only a partial file listing (${raw.truncated} files truncated).`, + ); + } + + const normalized = files + .map((file) => normalizeSyncPath(file)) + .filter((file): file is string => !!file); + + return Array.from(new Set(normalized)).sort(); +} + +async function readWebContainerFiles( + ctx: any, + projectId: string, + runId: string, + filePaths: string[], +): Promise> { + const fileMap = new Map(); + + for (let i = 0; i < filePaths.length; i += WEBCONTAINER_READ_BATCH_SIZE) { + const batch = filePaths.slice(i, i + WEBCONTAINER_READ_BATCH_SIZE); + const rawResult = (await runWebContainerTool(ctx, { + projectId, + runId, + toolName: "read_files", + input: { filePaths: batch }, + })) as Record | null; + + if (!rawResult || typeof rawResult !== "object") { + continue; + } + + for (const filePath of batch) { + const value = rawResult[filePath]; + if (typeof value === "string") { + fileMap.set(filePath, value); + } + } + } + + return fileMap; +} + +async function cloneOrInitRepo( + dir: string, + repoUrl: string, + token: string, +): Promise { + const auth = () => ({ username: "x-access-token", password: token }); + + try { + await git.clone({ + fs, + http, + dir, + url: repoUrl, + singleBranch: true, + depth: 1, + ref: "main", + onAuth: auth, + }); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const normalized = message.toLowerCase(); + const isEmptyRepoError = + normalized.includes("remote branch main not found") || + normalized.includes("could not find remote ref") || + normalized.includes("empty repository"); + + if (!isEmptyRepoError) { + throw error; + } + } + + await git.init({ fs, dir, defaultBranch: "main" }); + await git.addRemote({ fs, dir, remote: "origin", url: repoUrl }); +} + +async function syncWebContainerProjectToGitHub( + ctx: any, + args: { + projectId: string; + repoOwner: string; + repoName: string; + githubToken: string; + githubTokenType: "installation" | "oauth"; + installationId?: number; + }, +): Promise { + const runId = `github-sync-webcontainer:${args.projectId}:${randomUUID()}`; + const repoUrl = `https://github.com/${args.repoOwner}/${args.repoName}.git`; + const tempDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), "freebuff-wc-sync-"), + ); + + try { + const files = await listWebContainerFiles(ctx, args.projectId, runId); + const fileContents = await readWebContainerFiles( + ctx, + args.projectId, + runId, + files, + ); + + await cloneOrInitRepo(tempDir, repoUrl, args.githubToken); + + const trackedFiles = await git.listFiles({ fs, dir: tempDir }); + const desiredFiles = new Set(fileContents.keys()); + const filesToDelete = trackedFiles.filter((file) => !desiredFiles.has(file)); + + for (const file of filesToDelete) { + await fsPromises.rm(path.join(tempDir, file), { force: true }); + } + + for (const [file, contents] of fileContents.entries()) { + const absolutePath = path.join(tempDir, file); + await fsPromises.mkdir(path.dirname(absolutePath), { recursive: true }); + await fsPromises.writeFile(absolutePath, contents, "utf8"); + } + + for (const file of filesToDelete) { + await git.remove({ fs, dir: tempDir, filepath: file }).catch(() => {}); + } + + for (const file of fileContents.keys()) { + await git.add({ fs, dir: tempDir, filepath: file }); + } + + const statusMatrix = await git.statusMatrix({ fs, dir: tempDir }); + const hasChanges = statusMatrix.some((row) => row[1] !== row[3]); + + if (!hasChanges) { + return { + success: true, + operation: "project_to_github", + projectId: args.projectId, + status: "synced", + message: "Already up to date with GitHub.", + }; + } + + await git.commit({ + fs, + dir: tempDir, + message: "Sync from Freebuff WebContainer", + author: { + name: "Freebuff WebContainer", + email: "agent@mail.freebuff.app", + }, + }); + + await git.push({ + fs, + http, + dir: tempDir, + remote: "origin", + ref: "main", + onAuth: () => ({ username: "x-access-token", password: args.githubToken }), + }); + + logTokenUsage({ + operation: "sync_executor_webcontainer_push", + tokenType: args.githubTokenType, + success: true, + installationId: + args.githubTokenType === "installation" ? args.installationId : undefined, + }); + + return { + success: true, + operation: "project_to_github", + projectId: args.projectId, + status: "synced", + message: "WebContainer project pushed to GitHub successfully.", + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const normalizedError = errorMessage.toLowerCase(); + const isConflictLikeError = + normalizedError.includes("non-fast-forward") || + normalizedError.includes("fetch first") || + normalizedError.includes("failed to push some refs") || + normalizedError.includes("[rejected]"); + + if (isConflictLikeError) { + return { + success: false, + operation: "project_to_github", + projectId: args.projectId, + status: "conflict", + message: `Push was rejected because GitHub has newer commits. ${errorMessage}`, + conflicts: { + files: [], + resolutionOptions: [ + "use_github_version", + "use_local_version", + "retry_push", + ], + }, + }; + } + + const reauthMessage = getGitHubReauthMessageIfNeeded(errorMessage); + return { + success: false, + operation: "project_to_github", + projectId: args.projectId, + status: "error", + message: reauthMessage + ? `${reauthMessage} (${errorMessage})` + : `WebContainer GitHub push failed: ${errorMessage}`, + }; + } finally { + await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Handle conflict scenario with unified logic + */ +async function handleConflict( + ctx: any, + projectId: string, + message: string, + resolutionOptions: string[], + terminationReason: string, +): Promise { + await keepAgentActive(ctx, projectId, terminationReason); + + return { + success: false, + operation: "sync", + projectId, + status: "conflict", + message, + conflicts: { + files: [], + resolutionOptions, + }, + }; +} + +/** + * GitHub to Project Sync Helper Functions + */ + +/** + * Perform GitHub pull with conflict handling + */ +async function performGitHubPull( + ctx: any, + codebase: any, + args: { + projectDir: string; + token: string; + tokenType: "installation" | "oauth"; + operation: { + type: string; + projectId: string; + installationId?: number; + repoOwner: string; + repoName: string; + }; + }, +): Promise { + const result: SyncResult = { + success: false, + operation: args.operation.type, + projectId: args.operation.projectId, + status: "pending", + message: "Performing GitHub pull", + }; + + console.log( + `[SyncExecutorService] Pulling changes from github/main remote in ${args.projectDir} with secure authentication`, + ); + + // Use codebase pull directly - wrap in try-catch as safety net + try { + await codebase.pull( + "github", + "main", + args.token, + args.operation.repoOwner, + args.operation.repoName, + true, // noRebase + ); + + // Log token usage + logTokenUsage({ + operation: "sync_executor_github_pull", + tokenType: args.tokenType, + success: true, + installationId: + args.tokenType === "installation" + ? args.operation.installationId + : undefined, + }); + + console.log("[SyncExecutorService] Successfully pulled from GitHub"); + + // Reinstall dependencies after pull to fix potential node_modules corruption + console.log( + "[SyncExecutorService] Reinstalling dependencies after pull...", + ); + await codebase.installDependencies(); + + // Validate and fix cron intervals after sync + await runCronValidationWithLogging( + ctx, + codebase, + args.operation.projectId, + args.token, + args.operation.repoOwner, + args.operation.repoName, + "SyncExecutorService", + ); + + // Check if actual changes were pulled by comparing commit hashes + const hasActualChanges = true; // We pulled successfully, assume changes + + result.success = true; + result.status = "synced"; + result.message = hasActualChanges + ? "GitHub to project sync completed successfully" + : "Already up to date with GitHub"; + + return result; + } catch (pullError: any) { + const errorMessage = pullError.message || String(pullError); + const isInstallFailure = + errorMessage.includes("install failed") || + errorMessage.includes("EEXIST") || + errorMessage.includes("Failed to install dependencies"); + + if (isInstallFailure) { + console.error( + "[SyncExecutorService] Pull succeeded but dependency installation failed:", + pullError, + ); + + // Don't abort merge for installation failures - the pull itself succeeded + result.success = false; + result.status = "error"; + result.message = `GitHub sync completed but dependency installation failed: ${errorMessage}. The code changes were pulled successfully.`; + return result; + } + + const normalizedError = errorMessage.toLowerCase(); + const isMergeConflict = + normalizedError.includes("merge conflict") || + normalizedError.includes("automatic merge failed") || + normalizedError.includes("could not apply") || + normalizedError.includes("conflict ("); + + if (!isMergeConflict) { + console.error("[SyncExecutorService] Pull failed:", pullError); + result.success = false; + result.status = "error"; + const reauthMessage = getGitHubReauthMessageIfNeeded(errorMessage); + result.message = reauthMessage + ? `${reauthMessage} (${errorMessage})` + : `GitHub pull failed: ${errorMessage}`; + return result; + } + + console.error( + "[SyncExecutorService] Pull failed with merge conflict:", + pullError, + ); + + // Abort the merge to clean up the conflicted state + try { + await codebase.abortMerge(); + console.log( + "[SyncExecutorService] Aborted merge after conflict detection", + ); + } catch (abortError) { + console.error("[SyncExecutorService] Failed to abort merge:", abortError); + } + + // Return conflict status + return await handleConflict( + ctx, + args.operation.projectId, + "Merge conflicts detected during pull. User intervention required.", + ["use_github_version", "use_local_version", "rollback_to_backup"], + "merge conflict during pull", + ); + } +} + +/** + * Project to GitHub Sync Helper Functions + */ + +/** + * Perform fast-forward push to GitHub + */ +async function performFastForwardPush( + ctx: any, + codebase: any, + args: { + token: string; + tokenType: "installation" | "oauth"; + operation: { + type: string; + projectId: string; + installationId?: number; + repoOwner: string; + repoName: string; + }; + }, +): Promise { + console.log( + "[SyncExecutorService] Can fast-forward, executing regular push to GitHub", + ); + + try { + // Ensure remote is set up with secure authentication before pushing + console.log( + "[SyncExecutorService] Setting up secure authenticated remote for push", + ); + + try { + // Set up authenticated remote URL + await codebase.setRemoteUrl( + "github", + "", // url not used when token is provided + args.token, + args.operation.repoOwner, + args.operation.repoName, + ); + console.log( + "[SyncExecutorService] Remote URL updated with secure authentication", + ); + } catch (remoteError) { + console.log("[SyncExecutorService] Remote might not exist, adding it"); + const cleanUrl = `https://github.com/${args.operation.repoOwner}/${args.operation.repoName}.git`; + await codebase.addRemote("github", cleanUrl); + } + + // Use codebase push directly + await codebase.push( + "github", + "main", + false, // Don't force push for regular syncs + args.token, + args.operation.repoOwner, + args.operation.repoName, + ); + + // Log token usage + logTokenUsage({ + operation: "sync_executor_main_push", + tokenType: args.tokenType, + success: true, + installationId: + args.tokenType === "installation" + ? args.operation.installationId + : undefined, + }); + + console.log("[SyncExecutorService] Push completed successfully"); + return null; // Success, no error result + } catch (pushError: any) { + console.error("[SyncExecutorService] Push failed:", pushError); + const pushErrorMessage = pushError?.message || String(pushError); + const normalizedPushError = pushErrorMessage.toLowerCase(); + const isConflictLikePushError = + normalizedPushError.includes("non-fast-forward") || + normalizedPushError.includes("fetch first") || + normalizedPushError.includes("failed to push some refs") || + normalizedPushError.includes("[rejected]"); + + if (!isConflictLikePushError) { + const reauthMessage = getGitHubReauthMessageIfNeeded(pushErrorMessage); + return { + success: false, + operation: args.operation.type, + projectId: args.operation.projectId, + status: "error", + message: reauthMessage + ? `${reauthMessage} (${pushErrorMessage})` + : `Push failed: ${pushErrorMessage}`, + }; + } + + // If push fails, surface as conflict for user resolution + return { + success: false, + operation: args.operation.type, + projectId: args.operation.projectId, + status: "conflict", + message: `Push was rejected because GitHub has newer commits. ${pushErrorMessage}`, + conflicts: { + files: [], + resolutionOptions: [ + "use_github_version", + "use_local_version", + "retry_push", + ], + }, + }; + } +} + +/** + * Execute GitHub to project sync + * Simplified version that calls preflight internally + */ +export const executeGitHubToProjectSync = internalAction({ + args: { + sandboxId: v.string(), + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + accessToken: v.string(), + installationId: v.optional(v.number()), + githubToken: v.string(), + githubTokenType: v.union(v.literal("installation"), v.literal("oauth")), + packageManager: v.optional(v.union(v.literal("pnpm"), v.literal("bun"))), + }, + returns: v.object({ + success: v.boolean(), + operation: v.string(), + projectId: v.string(), + status: v.string(), + message: v.string(), + backup: v.optional( + v.object({ + localBackupId: v.string(), + githubBackupBranch: v.string(), + canRollback: v.boolean(), + }), + ), + conflicts: v.optional( + v.object({ + files: v.array(v.any()), + resolutionOptions: v.array(v.string()), + }), + ), + }), + handler: async (ctx, args): Promise => { + console.log("[SyncExecutorService] Executing GitHub to project sync"); + + const result: SyncResult = { + success: false, + operation: "github_to_project", + projectId: args.projectId, + status: "pending", + message: "GitHub to project sync started", + }; + + try { + // Step 1: Update sync status to pending + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "pending", + lastSyncTime: Date.now(), + }); + + // Step 2: Resolve package manager (use provided or detect for legacy projects) + let packageManager = args.packageManager; + if (!packageManager) { + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + if (project) { + packageManager = await getProjectPackageManager(ctx, project); + } + } + + // Step 3: Initialize codebase + const codebase = await getGitCodebase(args.sandboxId, packageManager); + + // Step 3: Check for potential conflicts + console.log("[SyncExecutorService] Checking for potential conflicts"); + const conflictCheck = await codebase.detectPotentialConflicts( + args.githubToken, + args.repoOwner, + args.repoName, + "github", + ); + + if (conflictCheck.hasConflicts) { + console.log( + "[SyncExecutorService] Conflicts detected - requiring user intervention", + ); + + result.status = "conflict"; + result.message = + "Merge conflicts detected. Please resolve conflicts manually."; + result.conflicts = { + files: [], + resolutionOptions: [ + "use_github_version", + "use_local_version", + "rollback_to_backup", + ], + }; + + await keepAgentActive( + ctx, + args.projectId, + "conflicts detected - user intervention required", + ); + + // Update sync status + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "conflict", + lastSyncTime: Date.now(), + errorMessage: result.message, + }); + + return result; + } + + // Step 5: Auto-commit any untracked files to prevent collision errors + console.log( + "[SyncExecutorService] Checking for uncommitted changes before pull", + ); + await codebase.commitIfDirty(); + + // Step 6: Safe to proceed with pull + const operation = { + type: "github_to_project", + projectId: args.projectId, + installationId: args.installationId, + repoOwner: args.repoOwner, + repoName: args.repoName, + }; + + const pullResult = await performGitHubPull(ctx, codebase, { + projectDir: "codebase", + token: args.githubToken, + tokenType: args.githubTokenType, + operation, + }); + + // Update sync status based on pull result + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: pullResult.status as any, + lastSyncTime: Date.now(), + errorMessage: pullResult.success ? undefined : pullResult.message, + }); + + if (pullResult.success && pullResult.status === "synced") { + await ctx.runMutation(internal.project.setStateDone, { + projectId: args.projectId, + }); + } + + // Mark external change if successful + if ( + pullResult.success && + pullResult.status === "synced" && + !pullResult.message.includes("Already up to date") + ) { + try { + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + if (project?.active_thread) { + await ctx.runMutation(internal.thread.markExternalChange, { + threadId: project.active_thread, + }); + console.log( + "[SyncExecutorService] Marked external change to hide old undo buttons", + ); + } + } catch (error) { + console.log( + "[SyncExecutorService] Failed to mark external change (non-critical):", + error, + ); + } + } + + return pullResult; + } catch (error: any) { + console.error("[SyncExecutorService] Sync failed:", error); + + result.success = false; + result.status = "error"; + result.message = error.message || "Unknown error occurred"; + + // Update sync status with error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "error", + lastSyncTime: Date.now(), + errorMessage: result.message, + }); + + return result; + } + }, +}); + +/** + * Execute project to GitHub sync + * Simplified version that calls preflight internally + */ +export const executeProjectToGitHubSync = internalAction({ + args: { + sandboxId: v.string(), + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + accessToken: v.string(), + installationId: v.optional(v.number()), + githubToken: v.string(), + githubTokenType: v.union(v.literal("installation"), v.literal("oauth")), + packageManager: v.optional(v.union(v.literal("pnpm"), v.literal("bun"))), + }, + returns: v.object({ + success: v.boolean(), + operation: v.string(), + projectId: v.string(), + status: v.string(), + message: v.string(), + backup: v.optional( + v.object({ + localBackupId: v.string(), + githubBackupBranch: v.string(), + canRollback: v.boolean(), + }), + ), + conflicts: v.optional( + v.object({ + files: v.array(v.any()), + resolutionOptions: v.array(v.string()), + }), + ), + }), + handler: async (ctx, args): Promise => { + console.log("[SyncExecutorService] Executing project to GitHub sync"); + + const result: SyncResult = { + success: false, + operation: "project_to_github", + projectId: args.projectId, + status: "pending", + message: "Project to GitHub sync started", + }; + + try { + // Step 1: Update sync status to pending + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "pending", + lastSyncTime: Date.now(), + }); + + if (args.sandboxId.startsWith("webcontainer:")) { + const webContainerResult = await syncWebContainerProjectToGitHub(ctx, { + projectId: args.projectId, + repoOwner: args.repoOwner, + repoName: args.repoName, + githubToken: args.githubToken, + githubTokenType: args.githubTokenType, + installationId: args.installationId, + }); + + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: webContainerResult.status as any, + lastSyncTime: Date.now(), + errorMessage: webContainerResult.success + ? undefined + : webContainerResult.message, + }); + + if (webContainerResult.status === "conflict") { + await keepAgentActive( + ctx, + args.projectId, + "webcontainer push conflict - user intervention required", + ); + } + + if (webContainerResult.success && webContainerResult.status === "synced") { + await ctx.runMutation(internal.project.setStateDone, { + projectId: args.projectId, + }); + } + + return webContainerResult; + } + + // Step 2: Resolve package manager (use provided or detect for legacy projects) + let packageManager = args.packageManager; + if (!packageManager) { + const project = await ctx.runQuery( + internal.github.auth.getProjectDetails, + { + projectId: args.projectId, + }, + ); + if (project) { + packageManager = await getProjectPackageManager(ctx, project); + } + } + + // Step 3: Initialize codebase + const codebase = await getGitCodebase(args.sandboxId, packageManager); + + // Step 3: Check for potential conflicts + console.log("[SyncExecutorService] Checking for potential conflicts"); + const conflictCheck = await codebase.detectPotentialConflicts( + args.githubToken, + args.repoOwner, + args.repoName, + "github", + ); + + if (conflictCheck.hasConflicts) { + console.log( + "[SyncExecutorService] Conflicts detected - requiring user intervention", + ); + + result.status = "conflict"; + result.message = + "GitHub has newer commits that conflict with local changes. Please resolve conflicts manually."; + result.conflicts = { + files: [], + resolutionOptions: [ + "use_local_version", + "use_github_version", + "rollback_to_backup", + ], + }; + + await keepAgentActive( + ctx, + args.projectId, + "conflicts detected - user intervention required", + ); + + // Update sync status + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "conflict", + lastSyncTime: Date.now(), + errorMessage: result.message, + }); + + return result; + } + + // Step 5: Auto-commit any uncommitted changes before push + console.log( + "[SyncExecutorService] Checking for uncommitted changes before push", + ); + await codebase.commitIfDirty(); + + // Step 6: Safe to proceed with push + const operation = { + type: "project_to_github", + projectId: args.projectId, + installationId: args.installationId, + repoOwner: args.repoOwner, + repoName: args.repoName, + }; + + const pushResult = await performFastForwardPush(ctx, codebase, { + token: args.githubToken, + tokenType: args.githubTokenType, + operation, + }); + + if (pushResult) { + // Update sync status with error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: pushResult.status as any, + lastSyncTime: Date.now(), + errorMessage: pushResult.message, + }); + return pushResult; // Error occurred + } + + result.success = true; + result.status = "synced"; + result.message = "Project to GitHub sync completed successfully"; + + // Update sync status to synced + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "synced", + lastSyncTime: Date.now(), + }); + + await ctx.runMutation(internal.project.setStateDone, { + projectId: args.projectId, + }); + + return result; + } catch (error: any) { + console.error("[SyncExecutorService] Sync failed:", error); + + result.success = false; + result.status = "error"; + result.message = error.message || "Unknown error occurred"; + + // Update sync status with error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: args.projectId, + status: "error", + lastSyncTime: Date.now(), + errorMessage: result.message, + }); + + return result; + } + }, +}); diff --git a/freebuff/web/convex/github/sync/services/webhookService.ts b/freebuff/web/convex/github/sync/services/webhookService.ts new file mode 100644 index 0000000000..f5863819c7 --- /dev/null +++ b/freebuff/web/convex/github/sync/services/webhookService.ts @@ -0,0 +1,747 @@ +"use node"; + +import { internalAction } from "../../../_generated/server"; +import { v } from "convex/values"; +import { internal } from "../../../_generated/api"; +import { getGitHubReauthMessageIfNeeded } from "./syncExecutorService"; +/** + * Webhook Service + * + * Consolidates all webhook handling logic into a single service layer. + * This replaces the inline helper functions in sync/actions.ts and reduces + * the number of action calls by using the Codebase interface directly. + */ + +/** + * Handle GitHub webhook for real-time sync + * Routes webhook events to the appropriate service handlers + * Public API entry point (called from http.ts) + */ +export const handleGitHubWebhook = internalAction({ + args: { + payload: v.string(), + signature: v.string(), + githubEvent: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + try { + console.log("Processing GitHub webhook with event " + args.githubEvent); + + // Parse webhook payload + const payload = JSON.parse(args.payload); + + // Log the full payload for debugging (first 500 chars) + console.log("Webhook payload received:", { + payloadLength: args.payload.length, + payloadPreview: args.payload.substring(0, 500), + signature: args.signature.substring(0, 20) + "...", + }); + + // Route to appropriate service handler + if (payload.ref && payload.ref.startsWith("refs/heads/")) { + // Push events → webhook service + await ctx.runAction( + internal.github.sync.services.webhookService.handlePushWebhook, + { payload }, + ); + } else if (payload.installation) { + // All installation events → webhook service + await ctx.runAction( + internal.github.sync.services.webhookService + .handleInstallationWebhook, + { payload }, + ); + } else { + console.log("Unhandled webhook event:", { + action: payload.action, + hasRef: !!payload.ref, + hasInstallation: !!payload.installation, + }); + } + + console.log("Webhook processed successfully"); + } catch (error) { + console.error("Failed to process webhook:", error); + } + + return null; + }, +}); + +/** + * Set up webhook for repository + * Public API entry point (called from initialSync.ts) + */ +export const setupRepositoryWebhook = internalAction({ + args: { + repoOwner: v.string(), + repoName: v.string(), + projectId: v.id("project"), + }, + returns: v.null(), + handler: async (ctx, args) => { + try { + console.log( + "Setting up webhook for repository:", + args.repoOwner, + args.repoName, + ); + + // Construct webhook URL using Convex site URL + const convexSiteUrl = process.env.CONVEX_SITE_URL; + if (!convexSiteUrl) { + console.error("CONVEX_SITE_URL environment variable is not set"); + throw new Error( + "CONVEX_SITE_URL environment variable is required for webhook setup", + ); + } + const webhookUrl = `${convexSiteUrl}/github/webhook`; + + const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET; + + if (!webhookSecret) { + throw new Error( + "GITHUB_WEBHOOK_SECRET environment variable is required", + ); + } + + // Get project GitHub context using centralized service + const projectContext = await ctx.runAction( + internal.github.services.projectService.getProjectConnection, + { + projectId: args.projectId, + }, + ); + + if (!projectContext.success || !projectContext.connection) { + throw new Error(projectContext.error || "GitHub connection not found"); + } + + const connection = projectContext.connection; + if (!connection.installation_id) { + throw new Error("GitHub App installation not found"); + } + + // Use Octokit with installation token + const { createOctokitInstance } = await import( + "../../services/octokitService" + ); + const octokit = await createOctokitInstance(connection.installation_id); + + // Check if webhook already exists + let webhookExists = false; + try { + const existingWebhooks = await octokit.rest.repos.listWebhooks({ + owner: args.repoOwner, + repo: args.repoName, + }); + + webhookExists = existingWebhooks.data.some( + (webhook) => webhook.config.url === webhookUrl, + ); + } catch (error) { + console.log("Could not check existing webhooks:", error); + } + + if (webhookExists) { + console.log("Webhook already exists for this repository"); + } else { + // Create webhook using Octokit + const response = await octokit.rest.repos.createWebhook({ + owner: args.repoOwner, + repo: args.repoName, + name: "web", + active: true, + events: ["push"], + config: { + url: webhookUrl, + content_type: "json", + secret: webhookSecret, + insecure_ssl: "0", // Use HTTPS only + }, + }); + + console.log("Webhook created successfully:", { + webhookId: response.data.id, + webhookUrl: webhookUrl, + repoOwner: args.repoOwner, + repoName: args.repoName, + }); + } + } catch (error: any) { + console.error("Failed to set up webhook:", error); + + // Log more details about the error for debugging + if (error.response?.data) { + console.error("GitHub API error details:", { + status: error.response.status, + message: error.response.data.message, + errors: error.response.data.errors, + }); + } + + // Don't throw error here as webhook setup is not critical for sync + } + + return null; + }, +}); + +interface PushEventPayload { + ref: string; + repository: { + owner: { login: string }; + name: string; + }; + installation?: { + id: number; + }; + commits?: Array<{ + author?: { email?: string }; + committer?: { email?: string }; + }>; +} + +interface InstallationEventPayload { + action: string; + installation: { + id: number; + account?: { + id: number; + login: string; + type: string; + }; + }; + repositories_added?: Array<{ full_name: string }>; + repositories_removed?: Array<{ full_name: string }>; +} + +/** + * Handle push webhook events + * Consolidates conflict checking and sync scheduling into a single operation + */ +export const handlePushWebhook = internalAction({ + args: { + payload: v.any(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const payload = args.payload as PushEventPayload; + const branch = payload.ref.replace("refs/heads/", ""); + + // Ignore backup branches to prevent webhook loops + if (branch.startsWith("backup-")) { + console.log( + `[WebhookService] Ignoring webhook for backup branch: ${branch}`, + ); + return null; + } + + // Only process webhooks for the main branch + if (branch !== "main") { + console.log( + `[WebhookService] Ignoring webhook for non-main branch: ${branch}`, + ); + return null; + } + + // // Check if push was made by the Freebuff agent to prevent sync loop + // if (payload.commits && Array.isArray(payload.commits)) { + // const allCommitsFromVly = payload.commits.every( + // (commit: any) => + // commit.author?.email === "agent@mail.freebuff.app" || + // commit.committer?.email === "agent@mail.freebuff.app", + // ); + + // if (allCommitsFromVly && payload.commits.length > 0) { + // console.log( + // `[WebhookService] Ignoring webhook - all ${payload.commits.length} commit(s) authored by vly.ai (preventing sync loop)`, + // ); + // return null; + // } + // } + + // Validate repository data + if (!payload.repository?.owner?.login || !payload.repository?.name) { + console.error( + "[WebhookService] Invalid repository data in webhook payload", + ); + return null; + } + + const repoOwner = payload.repository.owner.login; + const repoName = payload.repository.name; + + // Find projects synced with this repository + const syncStates = await ctx.runQuery( + internal.github.auth.getSyncStatesByRepo, + { + repoOwner, + repoName, + }, + ); + + // Security check: Ensure the repository is actually synced + if (!syncStates || syncStates.length === 0) { + console.warn( + "[WebhookService] Webhook received for unsynced repository:", + { + owner: repoOwner, + name: repoName, + installation: payload.installation?.id, + }, + ); + return null; + } + + console.log( + `[WebhookService] Found ${syncStates.length} sync states for webhook`, + ); + + // Process each synced project + for (const syncState of syncStates) { + try { + // Get project connection and details (includes retry logic) + const projectContext: any = await ctx.runAction( + internal.github.services.projectService.getProjectGitHubContext, + { + projectId: syncState.project_id, + requireConnection: true, + requireSyncState: false, + includeProjectDetails: true, + }, + ); + + if (!projectContext.success || !projectContext.connection) { + console.warn( + `[WebhookService] Could not get connection for project ${syncState.project_id}`, + ); + continue; + } + + if (!projectContext.project?.sandbox_id) { + console.warn( + `[WebhookService] Project ${syncState.project_id} does not have a sandbox_id`, + ); + continue; + } + + const connection = projectContext.connection; + const project = projectContext.project; + + // Security check: Verify installation ID matches + if (payload.installation?.id && connection.installation_id) { + if (payload.installation.id !== connection.installation_id) { + console.warn("[WebhookService] Installation ID mismatch:", { + webhook_installation: payload.installation.id, + connection_installation: connection.installation_id, + project: syncState.project_id, + }); + continue; + } + } + + // Fetch GitHub token once for this sync flow + console.log( + `[WebhookService] Fetching GitHub token for project ${syncState.project_id}`, + ); + const tokenResult = await ctx.runAction( + internal.github.tokens.service.getGitHubToken, + { + operation: { + type: "github_to_project", + projectId: syncState.project_id, + accessToken: connection.access_token, + installationId: connection.installation_id, + }, + operationName: "webhook_sync", + }, + ); + + console.log( + `[WebhookService] Scheduling GitHub-to-project sync for project ${syncState.project_id}`, + ); + + // Schedule executor service directly (it handles status updates internally) + await ctx.scheduler.runAfter( + 1000, + internal.github.sync.services.syncExecutorService + .executeGitHubToProjectSync, + { + sandboxId: project.sandbox_id, + projectId: syncState.project_id, + repoOwner, + repoName, + accessToken: connection.access_token, + installationId: connection.installation_id, + githubToken: tokenResult.token, + githubTokenType: tokenResult.tokenType, + packageManager: project.packageManager, + }, + ); + + console.log( + `[WebhookService] Scheduled sync for project ${syncState.project_id}`, + ); + } catch (error) { + console.error( + `[WebhookService] Failed to process webhook for project ${syncState.project_id}:`, + error, + ); + + const rawErrorMessage = + error instanceof Error ? error.message : "Unknown error"; + const reauthMessage = getGitHubReauthMessageIfNeeded(rawErrorMessage); + + // Update sync status with error + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: syncState.project_id, + status: "error", + lastSyncTime: Date.now(), + errorMessage: reauthMessage + ? `${reauthMessage} (${rawErrorMessage})` + : `Failed to process webhook: ${rawErrorMessage}`, + }); + } + } + + return null; + }, +}); + +/** + * Handle installation webhook events + * Consolidates all installation-related webhooks into a single handler + */ +export const handleInstallationWebhook = internalAction({ + args: { + payload: v.any(), + }, + returns: v.null(), + handler: async (ctx, args): Promise => { + const payload = args.payload as InstallationEventPayload; + + // Validate installation data exists + if (!payload.installation) { + console.error( + "[WebhookService] Installation webhook received without installation data", + ); + return null; + } + + const action = payload.action; + const installationId = payload.installation.id; + + // Account information may not be present for all event types (e.g., installation_repositories) + const accountId = payload.installation.account?.id; + const accountLogin = payload.installation.account?.login; + const accountType = payload.installation.account?.type; + + console.log(`[WebhookService] Processing installation.${action} event:`, { + installationId, + accountId, + accountLogin, + accountType, + }); + + switch (action) { + case "created": + if (accountId && accountLogin && accountType) { + await handleInstallationCreated( + ctx, + installationId, + accountId, + accountLogin, + accountType, + ); + } else { + console.warn( + "[WebhookService] Installation created event missing account data", + ); + } + break; + + case "deleted": + await handleInstallationDeleted(ctx, installationId); + break; + + case "suspend": + await handleInstallationSuspended(ctx, installationId); + break; + + case "unsuspend": + if (accountId) { + await handleInstallationUnsuspended(ctx, installationId, accountId); + } else { + console.warn( + "[WebhookService] Installation unsuspend event missing account ID", + ); + } + break; + + case "added": + if (payload.repositories_added) { + await handleRepositoriesAdded(ctx, payload.repositories_added); + } + break; + + case "removed": + if (payload.repositories_removed) { + await handleRepositoriesRemoved(ctx, payload.repositories_removed); + } + break; + + default: + console.log( + `[WebhookService] Unhandled installation action: ${action}`, + ); + } + + return null; + }, +}); + +/** + * Helper: Handle installation.created event + */ +async function handleInstallationCreated( + ctx: any, + installationId: number, + accountId: number, + accountLogin: string, + accountType: string, +): Promise { + console.log("[WebhookService] Handling installation.created"); + + if (accountType === "User") { + // User installation - find connections by GitHub user ID + const connections = await ctx.runQuery( + internal.github.auth.getGitHubConnectionsByGitHubUserId, + { + githubUserId: accountId.toString(), + }, + ); + + console.log( + `[WebhookService] Found ${connections.length} user connections for installation`, + ); + + if (connections.length > 0) { + // Update all connections with the new installation ID + for (const connection of connections) { + await ctx.runMutation(internal.github.auth.updateInstallationId, { + connectionId: connection._id, + installationId: installationId, + }); + } + + console.log( + `[WebhookService] Updated installation ID for ${connections.length} connections`, + ); + } else { + console.log( + "[WebhookService] No existing connections - installation will be linked when user completes OAuth", + ); + } + } else if (accountType === "Organization") { + console.log("[WebhookService] Organization installation detected:", { + installationId, + accountId, + accountLogin, + }); + // TODO: Handle organization installations + } +} + +/** + * Helper: Handle installation.deleted event + */ +async function handleInstallationDeleted( + ctx: any, + installationId: number, +): Promise { + console.log("[WebhookService] Handling installation.deleted"); + + const connections = await ctx.runQuery( + internal.github.auth.getGitHubConnectionsByInstallationId, + { + installationId: installationId, + }, + ); + + console.log( + `[WebhookService] Found ${connections.length} connections to remove installation ID from`, + ); + + for (const connection of connections) { + await ctx.runMutation(internal.github.auth.removeInstallationId, { + connectionId: connection._id, + }); + } + + console.log( + `[WebhookService] Removed installation ID from ${connections.length} connections`, + ); +} + +/** + * Helper: Handle installation.suspend event + */ +async function handleInstallationSuspended( + ctx: any, + installationId: number, +): Promise { + console.log("[WebhookService] Handling installation.suspend"); + + const connections = await ctx.runQuery( + internal.github.auth.getGitHubConnectionsByInstallationId, + { + installationId: installationId, + }, + ); + + console.log( + `[WebhookService] Found ${connections.length} connections to suspend`, + ); + + for (const connection of connections) { + await ctx.runMutation(internal.github.auth.removeInstallationId, { + connectionId: connection._id, + }); + } + + console.log(`[WebhookService] Suspended ${connections.length} connections`); +} + +/** + * Helper: Handle installation.unsuspend event + */ +async function handleInstallationUnsuspended( + ctx: any, + installationId: number, + accountId: number, +): Promise { + console.log("[WebhookService] Handling installation.unsuspend"); + + const connections = await ctx.runQuery( + internal.github.auth.getGitHubConnectionsByGitHubUserIdWithoutInstallation, + { + githubUserId: accountId.toString(), + }, + ); + + console.log( + `[WebhookService] Found ${connections.length} connections to unsuspend`, + ); + + for (const connection of connections) { + await ctx.runMutation(internal.github.auth.updateInstallationId, { + connectionId: connection._id, + installationId: installationId, + }); + } + + console.log(`[WebhookService] Unsuspended ${connections.length} connections`); +} + +/** + * Helper: Handle installation_repositories.added event + */ +async function handleRepositoriesAdded( + ctx: any, + repositoriesAdded: Array<{ full_name: string }>, +): Promise { + console.log( + `[WebhookService] Handling ${repositoriesAdded.length} repositories added`, + ); + + for (const repo of repositoriesAdded) { + const [repoOwner, repoName] = repo.full_name.split("/"); + + console.log( + `[WebhookService] Processing added repository: ${repo.full_name}`, + ); + + try { + const syncStates = await ctx.runQuery( + internal.github.auth.getSyncStatesByRepo, + { + repoOwner, + repoName, + }, + ); + + console.log( + `[WebhookService] Found ${syncStates?.length || 0} sync states for ${repo.full_name}`, + ); + + for (const syncState of syncStates || []) { + console.log( + `[WebhookService] Triggering initial sync for project ${syncState.project_id}`, + ); + + await ctx.scheduler.runAfter( + 2000, + internal.github.sync.services.initialSync.performInitialSync, + { + projectId: syncState.project_id, + repoOwner, + repoName, + }, + ); + } + } catch (error) { + console.error( + `[WebhookService] Failed to process added repository ${repo.full_name}:`, + error, + ); + } + } +} + +/** + * Helper: Handle installation_repositories.removed event + */ +async function handleRepositoriesRemoved( + ctx: any, + repositoriesRemoved: Array<{ full_name: string }>, +): Promise { + console.log( + `[WebhookService] Handling ${repositoriesRemoved.length} repositories removed`, + ); + + for (const repo of repositoriesRemoved) { + const [repoOwner, repoName] = repo.full_name.split("/"); + + console.log(`[WebhookService] Repository removed: ${repo.full_name}`); + + try { + const syncStates = await ctx.runQuery( + internal.github.auth.getSyncStatesByRepo, + { + repoOwner, + repoName, + }, + ); + + for (const syncState of syncStates || []) { + console.log( + `[WebhookService] Updating sync state for project ${syncState.project_id}`, + ); + + await ctx.runMutation(internal.github.sync.status.updateSyncStatus, { + projectId: syncState.project_id, + status: "error", + lastSyncTime: Date.now(), + errorMessage: `Repository ${repo.full_name} was removed from GitHub App installation`, + }); + } + } catch (error) { + console.error( + `[WebhookService] Failed to process removed repository ${repo.full_name}:`, + error, + ); + } + } +} diff --git a/freebuff/web/convex/github/sync/status.ts b/freebuff/web/convex/github/sync/status.ts new file mode 100644 index 0000000000..49203b1c1f --- /dev/null +++ b/freebuff/web/convex/github/sync/status.ts @@ -0,0 +1,152 @@ +import { internalMutation, internalQuery } from "../../_generated/server"; +import { v } from "convex/values"; + +/** + * Sync status management - consolidated from syncStatus.ts, syncStatusQueries.ts, and syncStatusMutations.ts + */ + +/** + * Update sync status + */ +export const updateSyncStatus = internalMutation({ + args: { + projectId: v.id("project"), + status: v.union( + v.literal("synced"), + v.literal("pending"), + v.literal("error"), + v.literal("conflict"), + ), + lastSyncTime: v.number(), + errorMessage: v.optional(v.string()), + lastCommitHash: v.optional(v.string()), + conflictResolvedAt: v.optional(v.number()), + conflictResolvedBy: v.optional(v.id("users")), + conflictFiles: v.optional( + v.array( + v.object({ + file_path: v.string(), + github_content: v.string(), + vly_content: v.string(), + diff_view: v.string(), + }), + ), + ), + }, + returns: v.null(), + handler: async (ctx, args) => { + const syncState = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (syncState) { + const updateData: any = { + sync_status: args.status, + last_sync_time: args.lastSyncTime, + error_message: args.errorMessage, + ...(args.lastCommitHash && { last_commit_hash: args.lastCommitHash }), + }; + + // Add conflict-related fields if provided + if (args.conflictResolvedAt !== undefined) { + updateData.conflict_resolved_at = args.conflictResolvedAt; + } + if (args.conflictResolvedBy !== undefined) { + updateData.conflict_resolved_by = args.conflictResolvedBy; + } + + await ctx.db.patch(syncState._id, updateData); + + console.log("Sync status updated:", { + projectId: args.projectId, + status: args.status, + lastSyncTime: new Date(args.lastSyncTime).toISOString(), + errorMessage: args.errorMessage, + lastCommitHash: args.lastCommitHash, + conflictFilesCount: args.conflictFiles?.length || 0, + }); + } else { + console.log("No sync state found for project:", args.projectId); + } + + return null; + }, +}); + +/** + * Create new sync state record + */ +export const createSyncState = internalMutation({ + args: { + projectId: v.id("project"), + repoOwner: v.string(), + repoName: v.string(), + syncDirection: v.literal("bidirectional"), + status: v.union( + v.literal("synced"), + v.literal("pending"), + v.literal("error"), + v.literal("conflict"), + ), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Check if sync state already exists + const existingSync = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (existingSync) { + // Update existing sync state + await ctx.db.patch(existingSync._id, { + github_repo_name: args.repoName, + github_repo_owner: args.repoOwner, + sync_direction: args.syncDirection, + sync_status: args.status, + last_sync_time: Date.now(), + error_message: undefined, + }); + } else { + // Create new sync state + await ctx.db.insert("github_sync_state", { + project_id: args.projectId, + github_repo_name: args.repoName, + github_repo_owner: args.repoOwner, + last_sync_time: Date.now(), + sync_direction: args.syncDirection, + sync_status: args.status, + }); + } + + return null; + }, +}); + +/** + * Check if a project has unresolved conflicts + */ +export const hasUnresolvedConflicts = internalQuery({ + args: { + projectId: v.id("project"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const syncState = await ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + + if (!syncState) { + return false; + } + + // Check if status is conflict + if (syncState.sync_status === "conflict") { + return true; + } + + return false; + }, +}); diff --git a/freebuff/web/convex/github/sync/types.ts b/freebuff/web/convex/github/sync/types.ts new file mode 100644 index 0000000000..501f9ea872 --- /dev/null +++ b/freebuff/web/convex/github/sync/types.ts @@ -0,0 +1,56 @@ +/** + * Shared types for GitHub sync operations + * + * This file consolidates type definitions used across multiple sync services + * to avoid duplication and ensure consistency. + */ + +import { type BackupInfo } from "../../../codebase-utils/codebase/ExtendedGitOperations"; + +// Re-export BackupInfo for convenience +export type { BackupInfo }; + +/** + * Standard sync operation configuration used across all sync services + */ +export interface SyncOperation { + type: string; + projectId: string; + accessToken: string; + installationId?: number; + repoOwner: string; + repoName: string; + options?: { + createBackup?: boolean; + conflictResolution?: "manual" | "block"; + }; +} + +/** + * Result from backup creation operations + */ +export interface BackupResult { + localBackupId: string; + githubBackupBranch: string; + canRollback: boolean; +} + +/** + * Result from sync execution operations + */ +export interface SyncResult { + success: boolean; + operation: string; + projectId: string; + status: "synced" | "conflict" | "error" | "pending"; + message: string; + backup?: { + localBackupId: string; + githubBackupBranch: string; + canRollback: boolean; + }; + conflicts?: { + files: any[]; + resolutionOptions: string[]; + }; +} diff --git a/freebuff/web/convex/github/sync/usage.ts b/freebuff/web/convex/github/sync/usage.ts new file mode 100644 index 0000000000..bfa22b07de --- /dev/null +++ b/freebuff/web/convex/github/sync/usage.ts @@ -0,0 +1,106 @@ +import { v } from "convex/values"; +import { query } from "../../_generated/server"; +import { getAuthUser } from "../../users"; + +/** + * Get GitHub sync usage information for the current user + * Returns connected GitHub account, list of projects with active syncs, and feature access status + */ +export const getGitHubSyncUsage = query({ + args: { + hasGitHubIntegrationAccess: v.optional(v.boolean()), + }, + returns: v.union( + v.object({ + connection: v.union( + v.object({ + github_username: v.string(), + created_at: v.number(), + }), + v.null(), + ), + active_syncs: v.array( + v.object({ + project_id: v.id("project"), + project_name: v.string(), + semantic_identifier: v.string(), + repo_owner: v.string(), + repo_name: v.string(), + sync_status: v.union( + v.literal("synced"), + v.literal("pending"), + v.literal("error"), + v.literal("conflict"), + ), + last_sync_time: v.number(), + }), + ), + has_feature_access: v.boolean(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + // Get GitHub connection + const connection = await ctx.db + .query("github_connections") + .withIndex("by_user", (q) => q.eq("user_id", user._id)) + .first(); + + // Get all project memberships for the user + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(); + + // Batch fetch all projects in parallel + const projects = await Promise.all( + projectMembers.map((pm) => ctx.db.get(pm.project)), + ); + + // Filter out null/deleted projects and get their sync states in parallel + const validProjects = projects.filter( + (project) => project && !project.deleted, + ); + + const syncStates = await Promise.all( + validProjects.map((project) => + ctx.db + .query("github_sync_state") + .withIndex("by_project", (q) => q.eq("project_id", project!._id)) + .first(), + ), + ); + + // Build active syncs list from projects that have sync states + const activeSyncs = validProjects + .map((project, index) => { + const syncState = syncStates[index]; + if (!syncState || !project) return null; + + return { + project_id: project._id, + project_name: project.name || project.semantic_identifier, + semantic_identifier: project.semantic_identifier, + repo_owner: syncState.github_repo_owner, + repo_name: syncState.github_repo_name, + sync_status: syncState.sync_status, + last_sync_time: syncState.last_sync_time, + }; + }) + .filter((sync) => sync !== null); + + return { + connection: connection + ? { + github_username: connection.github_username, + created_at: connection.created_at, + } + : null, + active_syncs: activeSyncs, + has_feature_access: args.hasGitHubIntegrationAccess ?? true, // Default to true for backwards compatibility + }; + }, +}); diff --git a/freebuff/web/convex/github/tokens/index.ts b/freebuff/web/convex/github/tokens/index.ts new file mode 100644 index 0000000000..b773fafd9f --- /dev/null +++ b/freebuff/web/convex/github/tokens/index.ts @@ -0,0 +1,13 @@ +// Token management exports + +// Token monitoring and usage tracking +export * from "./monitoring"; + +// Token rotation functionality +export * from "./rotation"; + +// Token validation +export * from "./validation"; + +// Token queries and mutations +export * from "./queries"; diff --git a/freebuff/web/convex/github/tokens/monitoring.ts b/freebuff/web/convex/github/tokens/monitoring.ts new file mode 100644 index 0000000000..1aea435228 --- /dev/null +++ b/freebuff/web/convex/github/tokens/monitoring.ts @@ -0,0 +1,117 @@ + +/** + * Token rotation monitoring and logging, plus usage tracking utilities + */ + +// Token usage tracking types and functions from utils/tokenTracker.ts +export type TokenType = "installation" | "oauth"; + +export interface TokenUsageEvent { + operation: string; + tokenType: TokenType; + success: boolean; + installationId?: number; + userId?: string; + timestamp: number; + error?: string; +} + +/** + * Log token usage for monitoring and debugging + */ +export function logTokenUsage(event: Omit): void { + const fullEvent: TokenUsageEvent = { + ...event, + timestamp: Date.now(), + }; + + const emoji = event.success ? "✅" : "❌"; + const tokenTypeUpper = event.tokenType.toUpperCase(); + + console.log( + `${emoji} GitHub Operation: ${event.operation} | Token: ${tokenTypeUpper}` + + (event.installationId ? ` (install_id: ${event.installationId})` : "") + + (event.userId ? ` (user: ${event.userId})` : "") + + (event.error ? ` | Error: ${event.error}` : ""), + ); + + // In production, you could send this to monitoring/analytics + // trackGitHubTokenUsage(fullEvent); +} + +/** + * Validate that we have the required token type for an operation + */ +export function validateTokenRequirements( + operation: string, + hasInstallationId: boolean, + hasOAuthToken: boolean, + preferInstallation: boolean = true, +): { shouldUseInstallation: boolean; warning?: string } { + // Operations that MUST use OAuth tokens + const oauthOnlyOperations = [ + "create_repository", + "user_profile", + "user_repositories", + ]; + + // Operations that SHOULD use installation tokens when available + const preferInstallationOperations = [ + "webhook_setup", + "repository_sync", + "git_operations", + "repository_check", + ]; + + if (oauthOnlyOperations.includes(operation)) { + if (!hasOAuthToken) { + throw new Error( + `Operation '${operation}' requires OAuth token but none available`, + ); + } + return { shouldUseInstallation: false }; + } + + if (preferInstallationOperations.includes(operation) || preferInstallation) { + if (hasInstallationId) { + return { shouldUseInstallation: true }; + } else { + const warning = `Operation '${operation}' prefers installation token but falling back to OAuth (installation_id missing)`; + return { + shouldUseInstallation: false, + warning, + }; + } + } + + // Default to OAuth for unknown operations + return { + shouldUseInstallation: false, + warning: hasInstallationId + ? `Operation '${operation}' could potentially use installation token` + : undefined, + }; +} + +/** + * Create a standardized error message for token issues + */ +export function createTokenError( + operation: string, + tokenType: TokenType, + originalError: any, +): Error { + const tokenTypeUpper = tokenType.toUpperCase(); + const message = `GitHub ${tokenTypeUpper} token failed for operation '${operation}': ${originalError.message || originalError}`; + + logTokenUsage({ + operation, + tokenType, + success: false, + error: originalError.message || String(originalError), + }); + + return new Error(message); +} + +// Token rotation monitoring removed - use Axiom for auditing instead diff --git a/freebuff/web/convex/github/tokens/queries.ts b/freebuff/web/convex/github/tokens/queries.ts new file mode 100644 index 0000000000..bcd530e0b9 --- /dev/null +++ b/freebuff/web/convex/github/tokens/queries.ts @@ -0,0 +1,352 @@ +import { v } from "convex/values"; +import { internalQuery, internalMutation } from "../../_generated/server"; + +/** + * Token-related queries and mutations that don't require Node.js runtime + */ + +/** + * Get all connections with tokens expiring soon (legacy version) + */ +export const getExpiringTokens = internalQuery({ + args: { + withinMinutes: v.optional(v.number()), // Default: 60 minutes + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + installation_id: v.optional(v.number()), + installation_token_expires_at: v.optional(v.number()), + token_expires_at: v.optional(v.number()), + tokenTypes: v.array( + v.union(v.literal("installation"), v.literal("oauth")), + ), + }), + ), + handler: async (ctx, args) => { + const withinMinutes = args.withinMinutes || 60; + const thresholdTime = Date.now() + withinMinutes * 60 * 1000; + + // OPTIMIZED: Use compound index range query to exclude undefined at index level + // Range query: null <= installation_token_expires_at <= thresholdTime + const connectionsWithExpiringInstallation = await ctx.db + .query("github_connections") + .withIndex("by_installation_token_expires_at", (q) => + q + .gte("installation_token_expires_at", null as any) + .lte("installation_token_expires_at", thresholdTime), + ) + .collect(); + + // OPTIMIZED: Use compound index range query to exclude undefined at index level + // Range query: null <= token_expires_at <= thresholdTime + const connectionsWithExpiringOAuth = await ctx.db + .query("github_connections") + .withIndex("by_token_expires_at", (q) => + q + .gte("token_expires_at", null as any) + .lte("token_expires_at", thresholdTime), + ) + .collect(); + + // Build a map to deduplicate and track which token types are expiring + const connectionMap = new Map< + string, + { + connection: (typeof connectionsWithExpiringInstallation)[0]; + tokenTypes: Set<"installation" | "oauth">; + } + >(); + + // Process installation token expirations + for (const conn of connectionsWithExpiringInstallation) { + if (!connectionMap.has(conn._id)) { + connectionMap.set(conn._id, { + connection: conn, + tokenTypes: new Set(), + }); + } + connectionMap.get(conn._id)!.tokenTypes.add("installation"); + } + + // Process OAuth token expirations + for (const conn of connectionsWithExpiringOAuth) { + if (!connectionMap.has(conn._id)) { + connectionMap.set(conn._id, { + connection: conn, + tokenTypes: new Set(), + }); + } + connectionMap.get(conn._id)!.tokenTypes.add("oauth"); + } + + // Convert to output format + return Array.from(connectionMap.values()).map( + ({ connection, tokenTypes }) => ({ + _id: connection._id, + user_id: connection.user_id, + installation_id: connection.installation_id, + installation_token_expires_at: connection.installation_token_expires_at, + token_expires_at: connection.token_expires_at, + tokenTypes: Array.from(tokenTypes), + }), + ); + }, +}); + +/** + * Get all connections with tokens expiring soon (optimized version with refresh tokens) + * Pre-fetches refresh_token to avoid redundant queries in scheduleTokenRotation + * + * DATA READ OPTIMIZATION NOTE: + * This function reads ~1.4MB because each github_connections document contains large encrypted + * token fields (access_token, refresh_token, installation_token = ~20-50KB each). + * Reading 1.4MB typically means ~20-50 connections are expiring. + * + * EXPECTED BEHAVIOR: + * - Normal: 5-20 connections expiring within the rotation window (300-700KB) + * - High usage: 20-50 connections (1-2MB) - acceptable if you have many users + * - Problem: >50 connections (>2MB) - may indicate token rotation schedule needs adjustment + * + * If data reads are consistently >2MB, consider: + * 1. Increasing rotation frequency to reduce batch sizes + * 2. Checking if token expiration times are properly staggered + * 3. Investigating if encryption is making tokens unnecessarily large + */ +export const getExpiringTokensWithRefreshTokens = internalQuery({ + args: { + withinMinutes: v.optional(v.number()), // Default: 60 minutes + }, + returns: v.array( + v.object({ + _id: v.id("github_connections"), + user_id: v.id("users"), + installation_id: v.optional(v.number()), + installation_token_expires_at: v.optional(v.number()), + token_expires_at: v.optional(v.number()), + refresh_token: v.optional(v.string()), // Pre-fetched for OAuth rotation + tokenTypes: v.array( + v.union(v.literal("installation"), v.literal("oauth")), + ), + }), + ), + handler: async (ctx, args) => { + const withinMinutes = args.withinMinutes || 60; + // Round to nearest 5 minutes for better caching + // This groups all token rotation checks within a 5-minute window + const roundedNow = + Math.floor(Date.now() / (5 * 60 * 1000)) * (5 * 60 * 1000); + const thresholdTime = roundedNow + withinMinutes * 60 * 1000; + + // OPTIMIZED: Use compound index range query to exclude undefined at index level + // This reads only documents where installation_token_expires_at is defined AND <= thresholdTime + // Range query: null <= installation_token_expires_at <= thresholdTime + // (In Convex ordering: undefined < null < numbers, so gte(null) excludes undefined) + const connectionsWithExpiringInstallation = await ctx.db + .query("github_connections") + .withIndex("by_installation_token_expires_at", (q) => + q + .gte("installation_token_expires_at", null as any) + .lte("installation_token_expires_at", thresholdTime), + ) + .collect(); + + // OPTIMIZED: Use compound index range query to exclude undefined at index level + // Range query: null <= token_expires_at <= thresholdTime + const connectionsWithExpiringOAuth = await ctx.db + .query("github_connections") + .withIndex("by_token_expires_at", (q) => + q + .gte("token_expires_at", null as any) + .lte("token_expires_at", thresholdTime), + ) + .collect(); + + // Build a map to deduplicate and track which token types are expiring + const connectionMap = new Map< + string, + { + connection: (typeof connectionsWithExpiringInstallation)[0]; + tokenTypes: Set<"installation" | "oauth">; + } + >(); + + // Process installation token expirations + for (const conn of connectionsWithExpiringInstallation) { + if (!connectionMap.has(conn._id)) { + connectionMap.set(conn._id, { + connection: conn, + tokenTypes: new Set(), + }); + } + connectionMap.get(conn._id)!.tokenTypes.add("installation"); + } + + // Process OAuth token expirations + for (const conn of connectionsWithExpiringOAuth) { + if (!connectionMap.has(conn._id)) { + connectionMap.set(conn._id, { + connection: conn, + tokenTypes: new Set(), + }); + } + connectionMap.get(conn._id)!.tokenTypes.add("oauth"); + } + + // Convert to output format with refresh_token included + return Array.from(connectionMap.values()).map( + ({ connection, tokenTypes }) => ({ + _id: connection._id, + user_id: connection.user_id, + installation_id: connection.installation_id, + installation_token_expires_at: connection.installation_token_expires_at, + token_expires_at: connection.token_expires_at, + refresh_token: connection.refresh_token, // Include for OAuth rotation + tokenTypes: Array.from(tokenTypes), + }), + ); + }, +}); + +/** + * Update installation token in database + */ +export const updateInstallationToken = internalMutation({ + args: { + connectionId: v.id("github_connections"), + newToken: v.string(), + expiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_token: args.newToken, + installation_token_expires_at: args.expiresAt, + updated_at: Date.now(), + }); + }, +}); + +/** Single mutation: persist new installation token and clear rotation lock (replaces update + release). */ +export const applyInstallationTokenAndClearRotationLock = internalMutation({ + args: { + connectionId: v.id("github_connections"), + newToken: v.string(), + expiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + installation_token: args.newToken, + installation_token_expires_at: args.expiresAt, + rotation_lock_expires_at: undefined, + updated_at: Date.now(), + }); + }, +}); + +/** + * Update OAuth token in database + */ +export const updateOAuthToken = internalMutation({ + args: { + connectionId: v.id("github_connections"), + accessToken: v.string(), + refreshToken: v.optional(v.string()), + expiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + access_token: args.accessToken, + refresh_token: args.refreshToken, + token_expires_at: args.expiresAt, + updated_at: Date.now(), + }); + }, +}); + +/** Single mutation: persist OAuth tokens and clear rotation lock. */ +export const applyOAuthTokenAndClearRotationLock = internalMutation({ + args: { + connectionId: v.id("github_connections"), + accessToken: v.string(), + refreshToken: v.optional(v.string()), + expiresAt: v.optional(v.number()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + access_token: args.accessToken, + refresh_token: args.refreshToken, + token_expires_at: args.expiresAt, + rotation_lock_expires_at: undefined, + updated_at: Date.now(), + }); + }, +}); + +/** + * Try to acquire a rotation lock for a connection + * Returns true if lock was acquired, false if already held by another process + * + * OPTIMIZED: Directly fetches the specific connection by ID to check lock status. + * This reads only 1 document instead of scanning all connections with active locks. + * Previous implementation scanned 20,000+ documents; this reduces reads by 99.995%. + */ +export const tryAcquireRotationLock = internalMutation({ + args: { + connectionId: v.id("github_connections"), + lockDurationMs: v.optional(v.number()), // Default: 5 minutes + }, + returns: v.object({ + acquired: v.boolean(), + reason: v.optional(v.string()), + }), + handler: async (ctx, args) => { + const lockDurationMs = args.lockDurationMs || 5 * 60 * 1000; // 5 minutes default + const now = Date.now(); + const lockExpiresAt = now + lockDurationMs; + + // OPTIMIZED: Directly fetch the specific connection by ID instead of scanning all active locks + // This reads only 1 document instead of 20,000+ documents + const connection = await ctx.db.get(args.connectionId); + if (!connection) { + return { acquired: false, reason: "Connection not found" }; + } + + // Check if this connection has an active lock + if ( + connection.rotation_lock_expires_at && + connection.rotation_lock_expires_at >= now + ) { + return { + acquired: false, + reason: "Lock already held by another process", + }; + } + + // No active lock exists - acquire the lock + await ctx.db.patch(args.connectionId, { + rotation_lock_expires_at: lockExpiresAt, + }); + + return { acquired: true }; + }, +}); + +/** + * Release a rotation lock for a connection + */ +export const releaseRotationLock = internalMutation({ + args: { + connectionId: v.id("github_connections"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.connectionId, { + rotation_lock_expires_at: undefined, + }); + }, +}); diff --git a/freebuff/web/convex/github/tokens/rotation.ts b/freebuff/web/convex/github/tokens/rotation.ts new file mode 100644 index 0000000000..1235876166 --- /dev/null +++ b/freebuff/web/convex/github/tokens/rotation.ts @@ -0,0 +1,597 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { internalAction } from "../../_generated/server"; + +/** + * Token rotation service for GitHub integration + * Handles automatic rotation of installation tokens and OAuth access tokens + */ + +interface TokenRotationResult { + success: boolean; + connectionId: string; + tokenType: "installation" | "oauth"; + error?: string; + newExpiresAt?: number; +} + +/** + * Rotate installation token for a specific connection + * + * OPTIMIZED: Removed redundant getConnectionById call (was 61k calls/day just for logging). + * Old expiry time now passed as optional arg instead of fetching from DB. + */ +export const rotateInstallationToken = internalAction({ + args: { + connectionId: v.id("github_connections"), + installationId: v.number(), + userId: v.optional(v.id("users")), // For logging purposes + oldExpiresAt: v.optional(v.number()), // OPTIMIZATION: Pass this instead of fetching + rotationTriggered: v.optional( + v.union( + v.literal("cron"), + v.literal("just_in_time"), + v.literal("manual"), + ), + ), + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + newExpiresAt: v.optional(v.number()), + skipped: v.optional(v.boolean()), // Indicates rotation was skipped (not an error) + }), + handler: async (ctx, args) => { + const rotationTriggered = args.rotationTriggered || "manual"; + + // Try to acquire rotation lock + const lockResult: { acquired: boolean; reason?: string } = + await ctx.runMutation( + internal.github.tokens.queries.tryAcquireRotationLock, + { + connectionId: args.connectionId, + }, + ); + + if (!lockResult.acquired) { + // Another process is already rotating this connection - this is normal, not an error + console.log( + `[Installation Rotation] Skipping rotation for connection ${args.connectionId}: ${lockResult.reason}`, + ); + return { + success: false, + error: lockResult.reason, + skipped: true, + }; + } + + let lockClearedWithTokenWrite = false; + try { + // Get new installation access token using centralized service + const { createOctokitInstance } = await import( + "../services/octokitService" + ); + const octokit = await createOctokitInstance(args.installationId); + const tokenResponse = + await octokit.rest.apps.createInstallationAccessToken({ + installation_id: args.installationId, + }); + + const newExpiresAt = tokenResponse.data.expires_at + ? new Date(tokenResponse.data.expires_at).getTime() + : undefined; + + // Persist token and clear rotation lock in one mutation (reduces function calls) + await ctx.runMutation( + internal.github.tokens.queries + .applyInstallationTokenAndClearRotationLock, + { + connectionId: args.connectionId, + newToken: tokenResponse.data.token, + expiresAt: newExpiresAt, + }, + ); + lockClearedWithTokenWrite = true; + + console.log( + `Successfully rotated installation token for connection: ${args.connectionId}`, + ); + + return { + success: true, + newExpiresAt, + }; + } catch (error) { + console.error( + `[Installation Rotation] Failed to rotate installation token for connection ${args.connectionId}:`, + error, + ); + + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + return { + success: false, + error: errorMessage, + }; + } finally { + if (!lockClearedWithTokenWrite) { + await ctx.runMutation( + internal.github.tokens.queries.releaseRotationLock, + { + connectionId: args.connectionId, + }, + ); + } + } + }, +}); + +/** + * Rotate OAuth access token using refresh token + * + * OPTIMIZED: Removed redundant getConnectionById call. + * Old expiry time now passed as optional arg instead of fetching from DB. + */ +export const rotateOAuthToken = internalAction({ + args: { + connectionId: v.id("github_connections"), + refreshToken: v.string(), + userId: v.optional(v.id("users")), // For logging purposes + oldExpiresAt: v.optional(v.number()), // OPTIMIZATION: Pass this instead of fetching + rotationTriggered: v.optional( + v.union( + v.literal("cron"), + v.literal("just_in_time"), + v.literal("manual"), + ), + ), + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + newExpiresAt: v.optional(v.number()), + skipped: v.optional(v.boolean()), // Indicates rotation was skipped (not an error) + }), + handler: async (ctx, args) => { + const rotationTriggered = args.rotationTriggered || "manual"; + + // Try to acquire rotation lock + const lockResult: { acquired: boolean; reason?: string } = + await ctx.runMutation( + internal.github.tokens.queries.tryAcquireRotationLock, + { + connectionId: args.connectionId, + }, + ); + + if (!lockResult.acquired) { + // Another process is already rotating this connection - this is normal, not an error + console.log( + `[OAuth Rotation] Skipping rotation for connection ${args.connectionId}: ${lockResult.reason}`, + ); + return { + success: false, + error: lockResult.reason, + skipped: true, + }; + } + + let lockClearedWithTokenWrite = false; + try { + const clientId = process.env.GITHUB_CLIENT_ID; + const clientSecret = process.env.GITHUB_CLIENT_SECRET; + + if (!clientId || !clientSecret) { + throw new Error("GitHub OAuth credentials not configured"); + } + + // Exchange refresh token for new access token + const response = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "refresh_token", + refresh_token: args.refreshToken, + }), + }, + ); + + if (!response.ok) { + throw new Error( + `GitHub OAuth refresh failed: ${response.status} ${response.statusText}`, + ); + } + + const tokenData = await response.json(); + + if (tokenData.error) { + throw new Error( + `GitHub OAuth error: ${tokenData.error_description || tokenData.error}`, + ); + } + + const newExpiresAt = tokenData.expires_in + ? Date.now() + tokenData.expires_in * 1000 + : undefined; + + await ctx.runMutation( + internal.github.tokens.queries.applyOAuthTokenAndClearRotationLock, + { + connectionId: args.connectionId, + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || args.refreshToken, + expiresAt: newExpiresAt, + }, + ); + lockClearedWithTokenWrite = true; + + console.log( + `Successfully rotated OAuth token for connection: ${args.connectionId}`, + ); + + return { + success: true, + newExpiresAt, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + + // Check if error is due to refresh token already being used (race condition) + const isRefreshTokenError = + errorMessage.includes("refresh token") && + (errorMessage.includes("incorrect") || + errorMessage.includes("expired") || + errorMessage.includes("invalid")); + + if (isRefreshTokenError) { + // This likely means another process already rotated the token successfully + console.log( + `[OAuth Rotation] Refresh token already used for connection ${args.connectionId} - likely rotated by concurrent process`, + ); + } else { + // This is a real error that needs attention + console.error( + `[OAuth Rotation] Failed to rotate OAuth token for connection ${args.connectionId}:`, + error, + ); + } + + return { + success: false, + error: errorMessage, + }; + } finally { + if (!lockClearedWithTokenWrite) { + await ctx.runMutation( + internal.github.tokens.queries.releaseRotationLock, + { + connectionId: args.connectionId, + }, + ); + } + } + }, +}); + +/** + * Check if a token is expired or will expire soon + */ +export const isTokenExpired = ( + expiresAt: number | undefined, + bufferMinutes: number = 5, +): boolean => { + if (!expiresAt) return false; + + const bufferTime = bufferMinutes * 60 * 1000; + const expiryWithBuffer = expiresAt - bufferTime; + + return Date.now() >= expiryWithBuffer; +}; + +/** + * Batch rotate tokens for multiple connections + */ +export const batchRotateTokens = internalAction({ + args: { + connectionIds: v.array(v.id("github_connections")), + }, + returns: v.array( + v.object({ + connectionId: v.id("github_connections"), + results: v.array( + v.object({ + tokenType: v.union(v.literal("installation"), v.literal("oauth")), + success: v.boolean(), + error: v.optional(v.string()), + }), + ), + }), + ), + handler: async (ctx, args) => { + const results = []; + + for (const connectionId of args.connectionIds) { + // Get connection details + const connection = await ctx.runQuery( + internal.github.auth.connections.getConnectionById, + { + connectionId, + }, + ); + + if (!connection) { + results.push({ + connectionId, + results: [ + { + tokenType: "installation" as const, + success: false, + error: "Connection not found", + }, + ], + }); + continue; + } + + const tokenResults: Array<{ + tokenType: "installation" | "oauth"; + success: boolean; + error?: string; + }> = []; + + // Rotate installation token if needed and possible + if ( + connection.installation_id && + connection.installation_token_expires_at && + isTokenExpired(connection.installation_token_expires_at, 5) + ) { + const result = await ctx.runAction( + internal.github.tokens.rotation.rotateInstallationToken, + { + connectionId, + installationId: connection.installation_id, + userId: connection.user_id, + oldExpiresAt: connection.installation_token_expires_at, // Pass for logging + rotationTriggered: "manual", + }, + ); + + tokenResults.push({ + tokenType: "installation", + success: result.success, + error: result.error, + }); + } + + // Rotate OAuth token if needed and possible + if ( + connection.refresh_token && + connection.token_expires_at && + isTokenExpired(connection.token_expires_at, 5) + ) { + const result = await ctx.runAction( + internal.github.tokens.rotation.rotateOAuthToken, + { + connectionId, + refreshToken: connection.refresh_token, + userId: connection.user_id, + oldExpiresAt: connection.token_expires_at, // Pass for logging + rotationTriggered: "manual", + }, + ); + + tokenResults.push({ + tokenType: "oauth", + success: result.success, + error: result.error, + }); + } + + results.push({ + connectionId, + results: tokenResults, + }); + } + + return results; + }, +}); + +/** + * Scheduled token rotation job + * Called by cron to proactively rotate tokens before they expire + * + * OPTIMIZED: Processes rotations in parallel batches and pre-fetches all connection data + */ +export const scheduleTokenRotation = internalAction({ + args: {}, + returns: v.object({ + connectionsProcessed: v.number(), + successfulRotations: v.number(), + failedRotations: v.number(), + details: v.array( + v.object({ + connectionId: v.string(), + tokenTypes: v.array( + v.union(v.literal("installation"), v.literal("oauth")), + ), + success: v.boolean(), + errors: v.optional(v.array(v.string())), + }), + ), + }), + handler: async ( + ctx, + ): Promise<{ + connectionsProcessed: number; + successfulRotations: number; + failedRotations: number; + details: Array<{ + connectionId: string; + tokenTypes: Array<"installation" | "oauth">; + success: boolean; + errors?: string[]; + }>; + }> => { + console.log("Starting scheduled token rotation..."); + + // Get tokens expiring in the next 45 minutes with full connection data + const expiringConnections = await ctx.runQuery( + internal.github.tokens.queries.getExpiringTokensWithRefreshTokens, + { + withinMinutes: 45, + }, + ); + + if (expiringConnections.length === 0) { + console.log("No tokens need rotation at this time"); + return { + connectionsProcessed: 0, + successfulRotations: 0, + failedRotations: 0, + details: [], + }; + } + + console.log( + `Found ${expiringConnections.length} connections with expiring tokens`, + ); + + // Process rotations in parallel batches of 30 to avoid overwhelming the system + const BATCH_SIZE = 30; + let successfulRotations: number = 0; + let failedRotations: number = 0; + const details: Array<{ + connectionId: string; + tokenTypes: Array<"installation" | "oauth">; + success: boolean; + errors?: string[]; + }> = []; + + for (let i = 0; i < expiringConnections.length; i += BATCH_SIZE) { + const batch = expiringConnections.slice(i, i + BATCH_SIZE); + + // Process batch in parallel + const batchResults = await Promise.all( + batch.map(async (connection: any) => { + const connectionErrors: string[] = []; + const rotatedTokenTypes: ("installation" | "oauth")[] = []; + + try { + // Rotate installation token if needed + if ( + connection.tokenTypes.includes("installation") && + connection.installation_id + ) { + const result = await ctx.runAction( + internal.github.tokens.rotation.rotateInstallationToken, + { + connectionId: connection._id, + installationId: connection.installation_id, + userId: connection.user_id, + oldExpiresAt: connection.installation_token_expires_at, + rotationTriggered: "cron", + }, + ); + + if (result.success) { + rotatedTokenTypes.push("installation"); + } else if (!result.skipped) { + // Only count as failure if not skipped due to lock + connectionErrors.push( + `Installation token: ${result.error || "Unknown error"}`, + ); + } + } + + // Rotate OAuth token if needed (using pre-fetched refresh_token) + if ( + connection.tokenTypes.includes("oauth") && + connection.refresh_token + ) { + const result = await ctx.runAction( + internal.github.tokens.rotation.rotateOAuthToken, + { + connectionId: connection._id, + refreshToken: connection.refresh_token, + userId: connection.user_id, + oldExpiresAt: connection.token_expires_at, + rotationTriggered: "cron", + }, + ); + + if (result.success) { + rotatedTokenTypes.push("oauth"); + } else if (!result.skipped) { + connectionErrors.push( + `OAuth token: ${result.error || "Unknown error"}`, + ); + } + } else if (connection.tokenTypes.includes("oauth")) { + connectionErrors.push("OAuth token: No refresh token available"); + } + + return { + connectionId: connection._id as string, + tokenTypes: rotatedTokenTypes, + success: connectionErrors.length === 0, + errors: + connectionErrors.length > 0 ? connectionErrors : undefined, + successCount: rotatedTokenTypes.length, + failureCount: connectionErrors.length, + }; + } catch (error) { + console.error( + `Error processing connection ${connection._id}:`, + error, + ); + return { + connectionId: connection._id as string, + tokenTypes: [], + success: false, + errors: [ + `Unexpected error: ${error instanceof Error ? error.message : "Unknown error"}`, + ], + successCount: 0, + failureCount: 1, + }; + } + }), + ); + + // Aggregate batch results + for (const result of batchResults) { + successfulRotations += result.successCount; + failedRotations += result.failureCount; + details.push({ + connectionId: result.connectionId, + tokenTypes: result.tokenTypes, + success: result.success, + errors: result.errors, + }); + } + } + + console.log( + `Token rotation completed. Successful: ${successfulRotations}, Failed: ${failedRotations}`, + ); + + return { + connectionsProcessed: expiringConnections.length, + successfulRotations, + failedRotations, + details, + }; + }, +}); diff --git a/freebuff/web/convex/github/tokens/service.ts b/freebuff/web/convex/github/tokens/service.ts new file mode 100644 index 0000000000..6d3b7f6619 --- /dev/null +++ b/freebuff/web/convex/github/tokens/service.ts @@ -0,0 +1,100 @@ +"use node"; + +import { v } from "convex/values"; +import { internalAction } from "../../_generated/server"; +import { getInstallationToken } from "../../../codebase-utils/github"; +import { logTokenUsage, validateTokenRequirements } from "./monitoring"; + +/** + * Centralized GitHub Token Service + * Extracts the getGitHubToken pattern that's duplicated across sync operations + */ + +export interface SyncOperation { + type: string; + projectId: string; + accessToken: string; + installationId?: number; +} + +/** + * Get the appropriate token for GitHub operations with JIT rotation + * Consolidates the exact logic from sync/engine.ts getGitHubToken() + */ +export const getGitHubToken = internalAction({ + args: { + operation: v.object({ + type: v.string(), + projectId: v.id("project"), + accessToken: v.string(), + installationId: v.optional(v.number()), + }), + operationName: v.string(), + }, + returns: v.object({ + token: v.string(), + tokenType: v.union(v.literal("installation"), v.literal("oauth")), + }), + handler: async ( + ctx, + args, + ): Promise<{ token: string; tokenType: "installation" | "oauth" }> => { + const { operation, operationName } = args; + + // Installation tokens are cheap to mint on demand. Fetching a fresh one + // directly avoids the extra Convex action churn from JIT validation and + // rotation in hot sync paths. + const validation = validateTokenRequirements( + operationName, + !!operation.installationId, + !!operation.accessToken, + true, // prefer installation tokens for sync operations + ); + + if (validation.warning) { + console.warn(`⚠️ [Sync Engine] ${validation.warning}`); + } + + // Step 1: Try to use a fresh installation token when available and preferred + if (validation.shouldUseInstallation && operation.installationId) { + try { + const installationToken = await getInstallationToken( + operation.installationId, + ); + + logTokenUsage({ + operation: `sync_engine_${operationName}`, + tokenType: "installation", + success: true, + installationId: operation.installationId, + }); + + console.log( + `[Sync Engine] Using fresh installation token for ${operationName}`, + ); + return { token: installationToken, tokenType: "installation" }; + } catch (error: any) { + logTokenUsage({ + operation: `sync_engine_${operationName}`, + tokenType: "installation", + success: false, + installationId: operation.installationId, + error: error.message, + }); + + console.log( + `[Sync Engine] Falling back to OAuth token for ${operationName}`, + ); + } + } + + // Step 2: Use OAuth token as fallback or when required + logTokenUsage({ + operation: `sync_engine_${operationName}`, + tokenType: "oauth", + success: true, + }); + + return { token: operation.accessToken, tokenType: "oauth" }; + }, +}); diff --git a/freebuff/web/convex/github/tokens/validation.ts b/freebuff/web/convex/github/tokens/validation.ts new file mode 100644 index 0000000000..ee399af45e --- /dev/null +++ b/freebuff/web/convex/github/tokens/validation.ts @@ -0,0 +1,255 @@ +"use node"; + +import { v } from "convex/values"; +import { internal } from "../../_generated/api"; +import { internalAction } from "../../_generated/server"; + +/** + * Just-in-time token validation and rotation service + * This ensures tokens are valid before being used in operations + */ + +/** + * Get a valid GitHub connection with automatic token rotation + * + * OPTIMIZED: Now relies on cron-based rotation (every 30min) for 95% of cases. + * Only rotates just-in-time if token is ACTUALLY expired (no 5min buffer). + * Uses lightweight checkTokenValidity query that benefits from Convex's automatic caching. + * + * This function should be called before any GitHub API operations. + */ +export const getValidGitHubConnection = internalAction({ + args: { + userId: v.id("users"), + forceRotation: v.optional(v.boolean()), // Force rotation even if not expired + }, + returns: v.union( + v.object({ + _id: v.id("github_connections"), + access_token: v.string(), + refresh_token: v.optional(v.string()), + github_username: v.string(), + github_user_id: v.string(), + installation_id: v.optional(v.number()), + installation_token: v.optional(v.string()), + installation_token_expires_at: v.optional(v.number()), + token_expires_at: v.optional(v.number()), + rotated: v.object({ + installation_token: v.boolean(), + oauth_token: v.boolean(), + }), + }), + v.null(), + ), + handler: async (ctx, args): Promise => { + // OPTIMIZATION: Use lightweight validity check first (auto-cached by Convex) + const validity = await ctx.runQuery( + internal.github.auth.connections.checkTokenValidity, + { userId: args.userId }, + ); + + if (!validity) { + return null; + } + + const rotated = { + installation_token: false, + oauth_token: false, + }; + + // OPTIMIZATION: Only rotate if token is ACTUALLY expired (not 5 min before) + // Cron job handles proactive rotation every 30 min, so this is rare + if ( + validity.installation_id && + validity.installation_token && + (args.forceRotation || validity.installation_token_expired) + ) { + console.log( + `[JIT Rotation] Installation token expired for user: ${args.userId}`, + ); + + // Fetch full connection to get old expiry time for logging + const connection = await ctx.runQuery( + internal.github.auth.connections.getGitHubConnectionWithTokensInternal, + { userId: args.userId }, + ); + + const rotationResult = await ctx.runAction( + internal.github.tokens.rotation.rotateInstallationToken, + { + connectionId: validity.connectionId, + installationId: validity.installation_id, + userId: args.userId, + oldExpiresAt: connection?.installation_token_expires_at, // Pass for logging + rotationTriggered: "just_in_time", + }, + ); + + if (rotationResult.success) { + rotated.installation_token = true; + console.log( + `[JIT Rotation] Successfully rotated installation token for user: ${args.userId}`, + ); + } else if (rotationResult.skipped) { + // Another process is handling rotation - this is normal + console.log( + `[JIT Rotation] Installation token rotation already in progress for user ${args.userId}, continuing with existing token`, + ); + } else { + console.error( + `[JIT Rotation] Failed to rotate installation token for user ${args.userId}: ${rotationResult.error}`, + ); + // Continue with existing token - it might still work + } + } + + // OPTIMIZATION: Only rotate OAuth if ACTUALLY expired + if ( + validity.has_refresh_token && + (args.forceRotation || validity.oauth_token_expired) + ) { + console.log( + `[JIT Rotation] OAuth token expired for user: ${args.userId}`, + ); + + // Need full connection for refresh token + const connection = await ctx.runQuery( + internal.github.auth.connections.getGitHubConnectionWithTokensInternal, + { userId: args.userId }, + ); + + if (connection?.refresh_token) { + const rotationResult = await ctx.runAction( + internal.github.tokens.rotation.rotateOAuthToken, + { + connectionId: connection._id, + refreshToken: connection.refresh_token, + userId: args.userId, + oldExpiresAt: connection.token_expires_at, // Pass for logging + rotationTriggered: "just_in_time", + }, + ); + + if (rotationResult.success) { + rotated.oauth_token = true; + console.log( + `[JIT Rotation] Successfully rotated OAuth token for user: ${args.userId}`, + ); + } else if (rotationResult.skipped) { + // Another process is handling rotation - this is normal + console.log( + `[JIT Rotation] OAuth token rotation already in progress for user ${args.userId}, continuing with existing token`, + ); + } else { + console.error( + `[JIT Rotation] Failed to rotate OAuth token for user ${args.userId}: ${rotationResult.error}`, + ); + } + } + } + + // Get final connection state + const finalConnection = await ctx.runQuery( + internal.github.auth.connections.getGitHubConnectionWithTokensInternal, + { userId: args.userId }, + ); + + if (!finalConnection) { + return null; + } + + return { + ...finalConnection, + rotated, + }; + }, +}); + +/** + * Validate a specific token without rotation + * Useful for checking token status before operations + */ +export const validateToken = internalAction({ + args: { + tokenType: v.union(v.literal("installation"), v.literal("oauth")), + expiresAt: v.optional(v.number()), + bufferMinutes: v.optional(v.number()), + }, + returns: v.object({ + valid: v.boolean(), + expired: v.boolean(), + expiresIn: v.optional(v.number()), // minutes until expiry + needsRotation: v.boolean(), + }), + handler: async (ctx, args) => { + const bufferMinutes = args.bufferMinutes || 5; + const bufferTime = bufferMinutes * 60 * 1000; + const now = Date.now(); + + if (!args.expiresAt) { + // Token doesn't expire (or no expiry info) + return { + valid: true, + expired: false, + needsRotation: false, + }; + } + + const expired = now >= args.expiresAt; + const needsRotation = now >= args.expiresAt - bufferTime; + const expiresIn = expired + ? 0 + : Math.floor((args.expiresAt - now) / (60 * 1000)); + + return { + valid: !expired, + expired, + expiresIn, + needsRotation, + }; + }, +}); + +/** + * Test GitHub token validity by making a simple API call + */ +export const testTokenValidity = internalAction({ + args: { + accessToken: v.string(), + tokenType: v.union(v.literal("oauth"), v.literal("installation")), + }, + returns: v.object({ + valid: v.boolean(), + rateLimitRemaining: v.optional(v.number()), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + try { + const response = await fetch("https://api.github.com/rate_limit", { + headers: { + Authorization: `Bearer ${args.accessToken}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "Crack-App/1.0", + }, + }); + + if (response.ok) { + const data = await response.json(); + return { + valid: true, + rateLimitRemaining: data.resources?.core?.remaining, + }; + } else { + return { + valid: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }; + } + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); diff --git a/freebuff/web/convex/github/utils/index.ts b/freebuff/web/convex/github/utils/index.ts new file mode 100644 index 0000000000..7445b8ca6a --- /dev/null +++ b/freebuff/web/convex/github/utils/index.ts @@ -0,0 +1,2 @@ +// Export Octokit utilities +export * from "./octokit"; diff --git a/freebuff/web/convex/github/utils/octokit.ts b/freebuff/web/convex/github/utils/octokit.ts new file mode 100644 index 0000000000..8ee26d818c --- /dev/null +++ b/freebuff/web/convex/github/utils/octokit.ts @@ -0,0 +1,265 @@ +/** + * Octokit utility functions + */ + +/** + * Parse private key from one-line format to proper PEM format + * basically handles both literal \n characters and space-separated formats + */ +export function parsePrivateKey(privateKey: string): string { + if (privateKey.includes("\n")) { + return privateKey; + } + + if (privateKey.includes("\\n")) { + return privateKey.replace(/\\n/g, "\n"); + } + + // handle space-separated format (common in environment variables) -- regex match + const keyPattern = + /(-----BEGIN[^-]+-----)\s+([A-Za-z0-9+/=\s]+)\s+(-----END[^-]+-----)/; + const match = privateKey.match(keyPattern); + + if (match) { + const header = match[1]; + const keyContent = match[2].replace(/\s+/g, ""); // remove all spaces from key content + const footer = match[3]; + + const formattedKeyContent = + keyContent.match(/.{1,64}/g)?.join("\n") || keyContent; + + return `${header}\n${formattedKeyContent}\n${footer}`; + } + + // if no pattern matches, return as-is (will likely fail, but preserves original) + return privateKey; +} + +/** + * Create a GitHub deployment for a repository + */ +export async function createGitHubDeployment( + octokit: any, + repoOwner: string, + repoName: string, + ref: string, + environment: string, + description: string, + autoMerge: boolean = false, + targetUrl?: string, // Add targetUrl parameter +): Promise<{ id: number; url: string }> { + try { + console.log( + `[DEBUG] Creating GitHub deployment for ${repoOwner}/${repoName} on ${ref}`, + ); + + const deploymentParams: any = { + owner: repoOwner, + repo: repoName, + ref: ref, + environment: environment, + description: description, + auto_merge: autoMerge, + required_contexts: [], // No required status checks + // Add additional parameters to help with deployment status + transient_environment: false, // Make production environments persistent + production_environment: environment === "production", // Mark as production + // Add parameters to make deployment more visible + task: `deploy-${environment}`, // Add a task identifier + payload: { + // Add custom payload for better visibility + deployment_type: "production", + service: "vly.ai", + environment: environment, + }, + }; + + // Add target_url if provided - this is crucial for making deployments visible + if (targetUrl) { + deploymentParams.target_url = targetUrl; + } + + // Add environment_url for better visibility (this is what Vercel uses) + if (targetUrl) { + deploymentParams.environment_url = targetUrl; + } + + console.log(`[DEBUG] GitHub deployment params:`, deploymentParams); + + const response = + await octokit.rest.repos.createDeployment(deploymentParams); + + const deployment = response.data; + console.log(`[DEBUG] GitHub deployment created with ID: ${deployment.id}`); + console.log(`[DEBUG] GitHub deployment response:`, deployment); + + // Immediately create a deployment status to make it more visible + if (targetUrl) { + try { + await octokit.rest.repos.createDeploymentStatus({ + owner: repoOwner, + repo: repoName, + deployment_id: deployment.id, + state: "pending", + target_url: targetUrl, + description: "Deployment in progress...", + }); + console.log( + `[DEBUG] Created initial deployment status for deployment ${deployment.id}`, + ); + } catch (statusError) { + console.error( + `[DEBUG] Failed to create initial deployment status:`, + statusError, + ); + // Don't fail the deployment creation if status creation fails + } + } + + return { + id: deployment.id, + url: deployment.url, + }; + } catch (error) { + console.error("[DEBUG] Failed to create GitHub deployment:", error); + throw new Error(`Failed to create GitHub deployment: ${error}`); + } +} + +/** + * Update GitHub deployment status + */ +export async function updateGitHubDeploymentStatus( + octokit: any, + repoOwner: string, + repoName: string, + deploymentId: number, + state: "pending" | "success" | "failure" | "error" | "inactive", + targetUrl?: string, + description?: string, +): Promise { + try { + console.log( + `[DEBUG] Updating GitHub deployment ${deploymentId} status to ${state}`, + ); + + const statusParams = { + owner: repoOwner, + repo: repoName, + deployment_id: deploymentId, + state: state, + target_url: targetUrl, + description: description, + }; + + console.log(`[DEBUG] GitHub deployment status params:`, statusParams); + + await octokit.rest.repos.createDeploymentStatus(statusParams); + + console.log( + `[DEBUG] GitHub deployment ${deploymentId} status updated to ${state}`, + ); + } catch (error) { + console.error("[DEBUG] Failed to update GitHub deployment status:", error); + throw new Error(`Failed to update GitHub deployment status: ${error}`); + } +} + +/** + * Get GitHub deployment by ID + */ +export async function getGitHubDeployment( + octokit: any, + repoOwner: string, + repoName: string, + deploymentId: number, +): Promise { + try { + const response = await octokit.rest.repos.getDeployment({ + owner: repoOwner, + repo: repoName, + deployment_id: deploymentId, + }); + + return response.data; + } catch (error) { + console.error("Failed to get GitHub deployment:", error); + throw new Error(`Failed to get GitHub deployment: ${error}`); + } +} + +/** + * List GitHub deployments for a repository + */ +export async function listGitHubDeployments( + octokit: any, + repoOwner: string, + repoName: string, + environment?: string, +): Promise { + try { + const params: any = { + owner: repoOwner, + repo: repoName, + per_page: 100, + }; + + if (environment) { + params.environment = environment; + } + + const response = await octokit.rest.repos.listDeployments(params); + return response.data; + } catch (error) { + console.error("Failed to list GitHub deployments:", error); + throw new Error(`Failed to list GitHub deployments: ${error}`); + } +} + +/** + * Update repository homepage URL to point to production deployment + */ +export async function updateRepositoryHomepage( + octokit: any, + repoOwner: string, + repoName: string, + productionUrl: string, +): Promise { + try { + console.log( + `[DEBUG] Updating repository ${repoOwner}/${repoName} homepage to ${productionUrl}`, + ); + + // Get current repository details + const currentRepo = await octokit.rest.repos.get({ + owner: repoOwner, + repo: repoName, + }); + + // Update repository with new homepage URL + await octokit.rest.repos.update({ + owner: repoOwner, + repo: repoName, + homepage: productionUrl, + // Preserve other repository settings + name: currentRepo.data.name, + description: currentRepo.data.description, + private: currentRepo.data.private, + has_issues: currentRepo.data.has_issues, + has_projects: currentRepo.data.has_projects, + has_wiki: currentRepo.data.has_wiki, + default_branch: currentRepo.data.default_branch, + allow_squash_merge: currentRepo.data.allow_squash_merge, + allow_merge_commit: currentRepo.data.allow_merge_commit, + allow_rebase_merge: currentRepo.data.allow_rebase_merge, + delete_branch_on_merge: currentRepo.data.delete_branch_on_merge, + }); + + console.log( + `[DEBUG] Repository ${repoOwner}/${repoName} homepage updated to ${productionUrl}`, + ); + } catch (error) { + console.error("[DEBUG] Failed to update repository homepage:", error); + throw new Error(`Failed to update repository homepage: ${error}`); + } +} diff --git a/freebuff/web/convex/gravity.ts b/freebuff/web/convex/gravity.ts new file mode 100644 index 0000000000..f22148fbbe --- /dev/null +++ b/freebuff/web/convex/gravity.ts @@ -0,0 +1,149 @@ +"use node"; + +/** + * @deprecated COMMENTED OUT - TOO EXPENSIVE + * + * This Convex action was consuming 897+ hours of action compute time because + * Convex actions are billed by wall-clock time (not CPU time). Every millisecond + * waiting for Gravity's API response was billed, making this extremely costly. + * + * Gravity ads are now fetched client-side directly from the browser + * in components/project-2/agent-chat/GravityAdSlot.tsx to avoid these costs. + * + * The client-side implementation: + * - Uses NEXT_PUBLIC_GRAVITY_API_KEY instead of GRAVITY_API_KEY + * - Has a 3-second timeout to prevent long waits + * - Eliminates 100% of Convex action compute costs for ads + * + * This file is kept for reference/rollback purposes only. + */ + +// These imports are kept commented out for reference if we ever need to restore the action +// import { action } from "./_generated/server"; +// import { v } from "convex/values"; +// import { getAuthUser } from "./users"; + +// const GRAVITY_API_URL = "https://server.trygravity.ai/api/v1/ad"; + +// Type kept for reference - now defined in GravityAdSlot.tsx instead +// export type GravityAd = { +// adText: string; +// title: string; +// cta: string; +// brandName: string; +// url: string; +// favicon?: string; +// impUrl: string; +// clickUrl: string; +// }; + +// /** Placement ID sent to Gravity for reporting/targeting. */ +// const PLACEMENT_CHAT = "agent-chat-below-response"; +// const PLACEMENT_CENTER = "project-center"; +// const PLACEMENT_SIDEBAR = "project-sidebar"; + +/* + * COMMENTED OUT - This action was too expensive (897+ hours of compute time). + * See deprecation notice above. Ads are now fetched client-side. + * +// export const getGravityAd = action({ +// args: { +// messages: v.array( +// v.object({ +// role: v.string(), +// content: v.string(), +// }), +// ), +// sessionId: v.string(), +// testAd: v.optional(v.boolean()), +// placementId: v.optional(v.string()), +// }, +// handler: async (ctx, args): Promise => { +// const apiKey = process.env.GRAVITY_API_KEY; +// if (!apiKey) { +// return null; +// } +// +// const placementId = +// args.placementId === "center" +// ? PLACEMENT_CENTER +// : args.placementId === "sidebar" +// ? PLACEMENT_SIDEBAR +// : PLACEMENT_CHAT; +// +// const user = await getAuthUser(ctx); +// const body: Record = { +// messages: args.messages, +// sessionId: args.sessionId, +// placements: [ +// { +// placement: +// placementId === PLACEMENT_CHAT +// ? "below_response" +// : placementId === PLACEMENT_CENTER +// ? "inline_response" +// : "left_response", +// placement_id: placementId, +// }, +// ], +// testAd: args.testAd ?? false, +// }; +// if (user) { +// const tier = user.tier; +// const gravityTier = !tier || tier === "free" ? "free" : "pro"; +// body.user = { +// id: user._id, +// email: user.email, +// subscription_tier: gravityTier, +// }; +// } +// +// try { +// const res = await fetch(GRAVITY_API_URL, { +// method: "POST", +// headers: { +// Authorization: `Bearer ${apiKey}`, +// "Content-Type": "application/json", +// }, +// body: JSON.stringify(body), +// }); +// +// if (res.status === 204 || !res.ok) { +// return null; +// } +// +// const data = (await res.json()) as unknown; +// if (!Array.isArray(data) || data.length === 0) { +// return null; +// } +// +// const first = data[0]; +// if ( +// typeof first !== "object" || +// first === null || +// typeof (first as { adText?: unknown }).adText !== "string" || +// typeof (first as { impUrl?: unknown }).impUrl !== "string" || +// typeof (first as { clickUrl?: unknown }).clickUrl !== "string" +// ) { +// return null; +// } +// +// return { +// adText: (first as { adText: string }).adText, +// title: (first as { title?: string }).title ?? "", +// cta: (first as { cta?: string }).cta ?? "", +// brandName: (first as { brandName?: string }).brandName ?? "", +// url: (first as { url?: string }).url ?? "", +// favicon: (first as { favicon?: string }).favicon, +// impUrl: (first as { impUrl: string }).impUrl, +// clickUrl: (first as { clickUrl: string }).clickUrl, +// }; +// } catch { +// return null; +// } +// }, +// }); +*/ + +// Placeholder export to prevent "no exports" errors +export const _deprecated = true; diff --git a/freebuff/web/convex/gravity_integrations.ts b/freebuff/web/convex/gravity_integrations.ts new file mode 100644 index 0000000000..de91b7f6dc --- /dev/null +++ b/freebuff/web/convex/gravity_integrations.ts @@ -0,0 +1,99 @@ +/** + * Pending Gravity Index integrations (V8 / database side). + * + * A Gravity conversion is only created by `report_integration` — the tracked + * click never converts on its own. We record a pending integration when a + * service is recommended (catalog "Integrate" click or an agent `gravity_index` + * search) and later, when the user saves the required env var(s) in the Keys + * tab, fire `report_integration` deterministically (see `gravity_report.ts`) + * instead of hoping the model remembers to call it. + * + * This file holds only the database mutations/queries (default V8 runtime); the + * Node-only action + outbound HTTP live in `gravity_report.ts`. + */ + +import { v } from "convex/values"; + +import { internalMutation, internalQuery } from "./_generated/server"; + +/** Upsert a pending integration, replacing any existing UNREPORTED row for the + * same (project, slug) so the freshest search_id / env-var set wins. Rows that + * were already reported are left untouched and only re-armed for a genuinely + * new search_id, so saving keys again for a converted service doesn't + * double-report. */ +export const recordPendingIntegrationInternal = internalMutation({ + args: { + projectId: v.id("project"), + userId: v.id("users"), + slug: v.string(), + searchId: v.string(), + requiredEnvVars: v.array(v.string()), + source: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const slug = args.slug.trim().toLowerCase(); + const searchId = args.searchId.trim(); + if (!slug || !searchId) return null; + + const requiredEnvVars = Array.from( + new Set( + args.requiredEnvVars + .map((key) => key.trim()) + .filter((key) => key.length > 0), + ), + ); + + const existing = await ctx.db + .query("gravity_pending_integration") + .withIndex("by_project_and_slug", (q) => + q.eq("projectId", args.projectId).eq("slug", slug), + ) + .collect(); + + const unreported = existing.find((row) => row.reportedAt === undefined); + if (unreported) { + await ctx.db.patch(unreported._id, { + userId: args.userId, + searchId, + requiredEnvVars, + source: args.source, + createdAt: Date.now(), + }); + return null; + } + + if (existing.some((row) => row.searchId === searchId)) return null; + + await ctx.db.insert("gravity_pending_integration", { + projectId: args.projectId, + userId: args.userId, + slug, + searchId, + requiredEnvVars, + source: args.source, + createdAt: Date.now(), + }); + return null; + }, +}); + +export const listUnreportedByProject = internalQuery({ + args: { projectId: v.id("project") }, + handler: async (ctx, args) => { + const rows = await ctx.db + .query("gravity_pending_integration") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + return rows.filter((row) => row.reportedAt === undefined); + }, +}); + +export const markReported = internalMutation({ + args: { id: v.id("gravity_pending_integration") }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.id, { reportedAt: Date.now() }); + return null; + }, +}); diff --git a/freebuff/web/convex/gravity_parse.ts b/freebuff/web/convex/gravity_parse.ts new file mode 100644 index 0000000000..9bd721b867 --- /dev/null +++ b/freebuff/web/convex/gravity_parse.ts @@ -0,0 +1,68 @@ +/** + * Pure parsing helpers for Gravity Index tool output. Kept free of Convex + * imports so they can be unit-tested directly (see gravity_parse.test.ts). + */ + +export interface ParsedGravitySearchResult { + searchId: string; + slug: string; + requiredEnvVars: string[]; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +/** + * Pull {searchId, slug, requiredEnvVars} out of a gravity_index search tool + * output. The tool output is the SDK envelope `[{type:'json', value}]` where + * `value` is the Gravity search response. Returns null unless all three are + * present — we only want fully-actionable recommendations (a save can't confirm + * a service whose required env vars we don't know). + */ +export function extractGravitySearchResult( + output: unknown, +): ParsedGravitySearchResult | null { + const items = Array.isArray(output) ? output : [output]; + for (const item of items) { + const itemRecord = asRecord(item); + const value = itemRecord && "value" in itemRecord ? itemRecord.value : item; + const result = asRecord(value); + if (!result) continue; + + const searchId = + typeof result.search_id === "string" ? result.search_id.trim() : ""; + if (!searchId) continue; + + const recommendation = asRecord(result.recommendation); + const slug = + recommendation && typeof recommendation.slug === "string" + ? recommendation.slug.trim().toLowerCase() + : ""; + if (!slug) continue; + + const credentialRequest = asRecord(result.credential_request); + const requiredEnvVars = credentialRequest + ? asStringArray(credentialRequest.required_env_vars) + : []; + const installEnvVars = asStringArray(asRecord(result.install)?.env_vars); + const envVars = Array.from( + new Set( + (requiredEnvVars.length > 0 ? requiredEnvVars : installEnvVars) + .map((key) => key.trim()) + .filter(Boolean), + ), + ); + if (envVars.length === 0) continue; + + return { searchId, slug, requiredEnvVars: envVars }; + } + return null; +} diff --git a/freebuff/web/convex/gravity_report.ts b/freebuff/web/convex/gravity_report.ts new file mode 100644 index 0000000000..cba8db6b45 --- /dev/null +++ b/freebuff/web/convex/gravity_report.ts @@ -0,0 +1,214 @@ +"use node"; + +/** + * Deterministic Gravity Index conversion reporting (Node side). + * + * Fires `report_integration` once the user actually saves the required env + * var(s) for a recommended service — the real conversion moment — instead of + * relying on the agent to remember the call. The HTTP request goes through the + * main app's `/api/v1/gravity-index` (which holds the Gravity API key) under the + * Freebuff Web service account, passing the real end user as `external_user_id` + * so the conversion attributes per-user rather than to the service account. + * + * Database reads/writes live in `gravity_integrations.ts` (V8 runtime). + */ + +import { v } from "convex/values"; + +import { internal } from "./_generated/api"; +import { action } from "./_generated/server"; +import { extractGravitySearchResult } from "./gravity_parse"; +import { getVerifiedAccessProject } from "./project"; +import { getAuthUser } from "./users"; + +import type { Id } from "./_generated/dataModel"; +import type { ActionCtx } from "./_generated/server"; + +const GRAVITY_REPORT_SURFACE = "freebuff_web_keys"; + +function codebuffAppUrl(): string { + return ( + process.env.NEXT_PUBLIC_CODEBUFF_APP_URL ?? + process.env.CODEBUFF_APP_URL ?? + "https://codebuff.com" + ); +} + +/** POST report_integration to the main app's Gravity Index endpoint under the + * service account, attributing to the real end user. Best-effort: never throws + * (a failed report must not break saving env vars). Returns true on success. */ +async function postReportIntegration(params: { + searchId: string; + slug: string; + userId: string; +}): Promise { + const apiKey = process.env.CODEBUFF_API_KEY; + if (!apiKey) { + console.warn("[gravity] CODEBUFF_API_KEY missing; cannot report integration", { + slug: params.slug, + }); + return false; + } + + try { + const response = await fetch(`${codebuffAppUrl()}/api/v1/gravity-index`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + action: "report_integration", + search_id: params.searchId, + integrated_slug: params.slug, + external_user_id: params.userId, + external_session_id: params.userId, + metadata: { surface: GRAVITY_REPORT_SURFACE }, + }), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + console.warn("[gravity] report_integration upstream failed", { + slug: params.slug, + status: response.status, + body: body.slice(0, 300), + }); + return false; + } + console.log("[gravity] report_integration sent", { + slug: params.slug, + searchId: params.searchId, + }); + return true; + } catch (error) { + console.warn("[gravity] report_integration request errored", { + slug: params.slug, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Given the set of env var keys a project now has set, fire report_integration + * for any pending integration whose required env vars are all present. Best + * effort and idempotent (rows are marked reported). Safe to call after every + * env-var save. + */ +export async function reportCompletedIntegrations( + ctx: ActionCtx, + params: { projectId: Id<"project">; presentEnvKeys: Set }, +): Promise { + let pending: Array<{ + _id: Id<"gravity_pending_integration">; + slug: string; + searchId: string; + requiredEnvVars: string[]; + userId: Id<"users">; + }>; + try { + pending = await ctx.runQuery( + internal.gravity_integrations.listUnreportedByProject, + { projectId: params.projectId }, + ); + } catch (error) { + console.warn("[gravity] failed to load pending integrations", { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + for (const row of pending) { + // A service with no known required env vars can't be confirmed by a save. + if (row.requiredEnvVars.length === 0) continue; + const allPresent = row.requiredEnvVars.every((key) => + params.presentEnvKeys.has(key), + ); + if (!allPresent) continue; + + const ok = await postReportIntegration({ + searchId: row.searchId, + slug: row.slug, + userId: row.userId, + }); + if (ok) { + await ctx.runMutation(internal.gravity_integrations.markReported, { + id: row._id, + }); + } + } +} + +/** + * Capture a pending integration from a gravity_index search tool result emitted + * during an agent run. Best-effort; tolerates partial/garbled payloads. + */ +export async function capturePendingIntegrationFromToolOutput( + ctx: ActionCtx, + params: { + projectId: Id<"project">; + userId: Id<"users">; + output: unknown; + }, +): Promise { + try { + const result = extractGravitySearchResult(params.output); + if (!result) return; + await ctx.runMutation( + internal.gravity_integrations.recordPendingIntegrationInternal, + { + projectId: params.projectId, + userId: params.userId, + slug: result.slug, + searchId: result.searchId, + requiredEnvVars: result.requiredEnvVars, + source: "agent_search", + }, + ); + } catch (error) { + console.warn( + "[gravity] failed to capture pending integration from tool output", + { error: error instanceof Error ? error.message : String(error) }, + ); + } +} + +/** + * Session-authed action the integration catalog calls when the user clicks + * "Integrate". Records the pending integration so a later env-var save converts + * it into a report_integration deterministically. + */ +export const recordPendingIntegration = action({ + args: { + semanticIdentifier: v.string(), + slug: v.string(), + searchId: v.string(), + requiredEnvVars: v.array(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) return null; + + await ctx.runMutation( + internal.gravity_integrations.recordPendingIntegrationInternal, + { + projectId: project._id, + userId: user._id, + slug: args.slug, + searchId: args.searchId, + requiredEnvVars: args.requiredEnvVars, + source: "catalog_integrate", + }, + ); + return null; + }, +}); diff --git a/freebuff/web/convex/http.ts b/freebuff/web/convex/http.ts new file mode 100644 index 0000000000..2f5cac1fd1 --- /dev/null +++ b/freebuff/web/convex/http.ts @@ -0,0 +1,883 @@ +import { httpRouter } from 'convex/server' +import { api, internal } from './_generated/api' +import { httpAction } from './_generated/server' +import { processRuntimeError } from './runtime_errors' +import { http_validateIntegrationBearerKey } from './integration_auth' +import { PauseReason } from './constants' + +/** + * Get allowed CORS origin based on environment + */ +function getAllowedOrigin(request: Request): string { + const origin = request.headers.get('origin') + + // Always allow localhost for development + if (origin?.includes('localhost') || origin?.includes('127.0.0.1')) { + return origin + } + + // In production, only allow specific domains + const allowedOrigins = [ + 'https://vly.ai', + 'https://www.vly.ai', + "freebuff.com", + "www.freebuff.com", + "freebuff.dev", + "www.freebuff.dev", + ...(process.env.ALLOWED_ORIGINS?.split(',').map((o) => o.trim()) || []), + ] + + if (origin && allowedOrigins.includes(origin)) { + return origin + } + + // Fallback to primary domain + return 'https://freebuff.com' +} + +const http = httpRouter() + +http.route({ + path: '/api/web/.well-known/jwks.json', + method: 'GET', + handler: httpAction(async () => { + const publicJwk = process.env.VLY_CONVEX_JWT_PUBLIC_JWK + if (!publicJwk) { + return new Response('Missing VLY_CONVEX_JWT_PUBLIC_JWK', { + status: 500, + }) + } + + return new Response(JSON.stringify({ keys: [JSON.parse(publicJwk)] }), { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=300', + }, + }) + }), +}) + +// Runtime error endpoint +http.route({ + path: '/runtime-error', + method: 'POST', + handler: processRuntimeError, +}) + +http.route({ + path: '/runtime-error', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + // Make sure the necessary headers are present + // for this to be a valid pre-flight request + const headers = request.headers + if ( + headers.get('Origin') !== null && + headers.get('Access-Control-Request-Method') !== null && + headers.get('Access-Control-Request-Headers') !== null + ) { + return new Response(null, { + headers: new Headers({ + // e.g. https://mywebsite.com, configured on your Convex dashboard + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type, Digest', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }), + }) + } else { + return new Response() + } + }), +}) + +// Secure image serving endpoint +http.route({ + path: '/image', + method: 'GET', + handler: httpAction(async (ctx, request) => { + const { searchParams } = new URL(request.url) + const storageId = searchParams.get('storageId') + + if (!storageId) { + return new Response('Missing storage ID', { status: 400 }) + } + + try { + // Get the image blob from storage + const blob = await ctx.storage.get(storageId) + if (!blob) { + return new Response('Image not found', { status: 404 }) + } + + // Get metadata for content type + const metadata = await ctx.storage.getMetadata(storageId) + + // Return the image with proper headers + return new Response(blob, { + headers: { + 'Content-Type': metadata?.contentType || 'image/jpeg', + 'Cache-Control': 'private, max-age=3600', + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + } catch (error) { + console.error('Error serving image:', error) + return new Response('Internal server error', { status: 500 }) + } + }), +}) + +http.route({ + path: '/allow_project', + method: 'GET', + handler: httpAction(async (ctx, request) => { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get('projectId') + + if (!projectId) { + return new Response('Missing project ID', { status: 400 }) + } + + try { + const matchingProject = await ctx.runQuery( + internal.project.getProjectFromIdentifier, + { + semanticIdentifier: projectId, + }, + ) + + if (!matchingProject) { + return new Response('Project not found', { status: 404 }) + } + + const matchingRecord = await ctx.runQuery(internal.convex_instance.get, { + projectId: matchingProject._id, + }) + + if (matchingRecord) { + // Skip restoreEnvLocal for Daytona sandboxes + if (!matchingProject.sandbox_id.startsWith('daytona:')) { + await ctx.runAction(internal.localEnvFix.restoreEnvLocal, { + projectId: matchingProject._id, + devDeploymentName: matchingRecord.devDeploymentName, + }) + } + return new Response(JSON.stringify({ migrated: true }), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + } else { + // kick off a migration job + await ctx.scheduler.runAfter(0, internal.migrations.migrateDeployKeys, { + project: { + _id: matchingProject._id, + _creationTime: matchingProject._creationTime.toString(), + semantic_identifier: matchingProject.semantic_identifier, + sandbox_id: matchingProject.sandbox_id, + convex_url: matchingProject.convex_url, + }, + }) + + return new Response( + JSON.stringify({ migrated: false, project: matchingProject }), + { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Credentials': 'true', + }, + }, + ) + } + } catch (error) { + console.info('Error checking allow project:', error) + return new Response('Not allowed: ' + error, { status: 403 }) + } + }), +}) + +// OPTIONS handler for allow_project endpoint CORS +http.route({ + path: '/allow_project', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +// OPTIONS handler for image endpoint CORS +http.route({ + path: '/image', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +// Screenshot upload endpoint - receives screenshots and uploads to Cloudflare R2 +http.route({ + path: '/api/screenshot/upload', + method: 'POST', + handler: httpAction(async (ctx, request) => { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get('projectId') + const requestId = searchParams.get('requestId') + const origin = getAllowedOrigin(request) + + // Helper to create response with CORS headers + const createResponse = ( + body: string | null, + status: number, + contentType?: string, + ) => { + return new Response(body, { + status, + headers: { + ...(contentType ? { 'Content-Type': contentType } : {}), + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + }, + }) + } + + try { + if (!projectId || !requestId) { + console.error('[Screenshot] Missing params', { projectId, requestId }) + return createResponse('Missing required query parameters', 400) + } + + // Verify project exists + const project = await ctx.runQuery(internal.project.getProject, { + projectId: projectId as any, + }) + + if (!project) { + console.error('[Screenshot] Project not found', { + projectId, + requestId, + }) + return createResponse('Project not found', 404) + } + + // Get and validate image blob + const imageBlob = await request.blob() + if (!imageBlob || imageBlob.size === 0) { + console.error('[Screenshot] Empty image data', { + projectId, + requestId, + size: imageBlob?.size, + }) + return createResponse('Invalid image data', 400) + } + + // Upload to Cloudflare R2 using Cloudflare API + const r2AccountId = process.env.R2_ACCOUNT_ID + const r2ApiToken = process.env.R2_API_TOKEN // Cloudflare API token with R2 write permissions + const r2BucketName = process.env.R2_BUCKET_NAME + const r2PublicDomain = process.env.R2_PUBLIC_DOMAIN // e.g., "screenshots.vly.ai" + + if (!r2AccountId || !r2ApiToken || !r2BucketName || !r2PublicDomain) { + console.error('[Screenshot] R2 credentials not configured', { + hasAccountId: !!r2AccountId, + hasApiToken: !!r2ApiToken, + hasBucketName: !!r2BucketName, + hasPublicDomain: !!r2PublicDomain, + }) + return createResponse('Server configuration error', 500) + } + + // Delete old screenshot from R2 if it exists + if (project.screenshot_r2_url) { + try { + // Extract filename from the old URL (e.g., "https://screenshots.vly.ai/projectId-123.png" -> "projectId-123.png") + const oldUrl = new URL(project.screenshot_r2_url) + const oldFilename = oldUrl.pathname.slice(1) // Remove leading slash + + if (oldFilename) { + console.log('[Screenshot] Deleting old screenshot from R2', { + oldFilename, + }) + + const deleteResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${r2AccountId}/r2/buckets/${r2BucketName}/objects/${oldFilename}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${r2ApiToken}`, + }, + }, + ) + + if (deleteResponse.ok) { + console.log('[Screenshot] Old screenshot deleted successfully') + } else { + // Log but don't fail - continue with upload even if delete fails + console.warn('[Screenshot] Failed to delete old screenshot', { + status: deleteResponse.status, + oldFilename, + }) + } + } + } catch (deleteError) { + // Log but don't fail - continue with upload even if delete fails + console.warn('[Screenshot] Error deleting old screenshot', { + error: + deleteError instanceof Error + ? deleteError.message + : String(deleteError), + }) + } + } + + // Generate filename: projectId-timestamp.png + const filename = `${projectId}-${Date.now()}.png` + const r2Url = `https://${r2PublicDomain}/${filename}` + + // Upload to R2 using Cloudflare API (Bearer token auth, no AWS SDK needed) + const arrayBuffer = await imageBlob.arrayBuffer() + const uploadResponse = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${r2AccountId}/r2/buckets/${r2BucketName}/objects/${filename}`, + { + method: 'PUT', + body: arrayBuffer, + headers: { + Authorization: `Bearer ${r2ApiToken}`, + 'Content-Type': 'image/png', + }, + }, + ) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + console.error('[Screenshot] R2 upload failed', { + status: uploadResponse.status, + error: errorText, + }) + throw new Error(`R2 upload failed: ${uploadResponse.status}`) + } + + // Save R2 URL to project and reset commit counter + await ctx.runMutation(internal.screenshot.saveProjectScreenshotInternal, { + projectId: projectId as any, + r2Url, + }) + + console.log('[Screenshot] OK - Uploaded to R2', { + projectId, + r2Url, + size: imageBlob.size, + }) + + return createResponse( + JSON.stringify({ success: true, r2Url, projectId, requestId }), + 200, + 'application/json', + ) + } catch (error) { + console.error('[Screenshot] Exception', { + projectId, + requestId, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) + return createResponse( + JSON.stringify({ + success: false, + error: 'Failed to upload screenshot', + }), + 500, + 'application/json', + ) + } + }), +}) + +// Screenshot upload OPTIONS handler for CORS preflight +http.route({ + path: '/api/screenshot/upload', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +// GitHub webhook endpoint +http.route({ + path: '/github/webhook', + method: 'POST', + handler: httpAction(async (ctx, request) => { + try { + const githubEvent = request.headers.get('x-github-event') || '' + + const payload = await request.text() + const signature = request.headers.get('x-hub-signature-256') || '' + const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET + const userAgent = request.headers.get('user-agent') || '' + + // Basic validation + if (!webhookSecret) { + console.warn('GitHub webhook secret not configured') + return new Response('Webhook secret not configured', { status: 500 }) + } + + // Verify User-Agent header (GitHub sends "GitHub-Hookshot/...") + if (!userAgent.startsWith('GitHub-Hookshot/')) { + console.warn('Invalid User-Agent header:', userAgent) + return new Response('Invalid User-Agent', { status: 403 }) + } + + // Verify HMAC signature + if (!signature) { + console.warn('No webhook signature received') + return new Response('Missing signature', { status: 403 }) + } + + console.log('Calling crypto verification action for GitHub webhook') + const isValidSignature = await ctx.runAction( + api.utils.crypto.verifyGitHubSignature, + { + payload, + signature, + secret: webhookSecret, + }, + ) + console.log('Crypto verification action completed', { isValidSignature }) + + if (!isValidSignature) { + console.warn('Invalid webhook signature') + return new Response('Invalid signature', { status: 403 }) + } + + console.log('GitHub webhook signature verified successfully') + + // Process the webhook + await ctx.runAction( + internal.github.sync.services.webhookService.handleGitHubWebhook, + { + payload, + signature, + githubEvent, + }, + ) + + return new Response('OK', { status: 200 }) + } catch (error) { + console.error('GitHub webhook error:', error) + return new Response('Internal server error', { status: 500 }) + } + }), +}) + +// Project owner lookup endpoint +http.route({ + path: '/project-owner', + method: 'GET', + handler: httpAction(async (ctx, request) => { + try { + // Validate API key + const authHeader = request.headers.get('authorization') + const apiKey = process.env.PROJECT_OWNER_API_KEY + + if (!apiKey) { + console.error('PROJECT_OWNER_API_KEY not configured') + return new Response( + JSON.stringify({ error: 'API key not configured' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ) + } + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response( + JSON.stringify({ error: 'Missing or invalid authorization header' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const providedKey = authHeader.substring(7) // Remove 'Bearer ' prefix + if (providedKey !== apiKey) { + return new Response(JSON.stringify({ error: 'Invalid API key' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Get deployment name from query params + const { searchParams } = new URL(request.url) + const deploymentName = searchParams.get('deployment') + + if (!deploymentName) { + return new Response( + JSON.stringify({ error: 'Missing deployment parameter' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Look up project by deployment name + const project = await ctx.runQuery( + internal.project.getProjectByDeploymentName, + { deploymentName }, + ) + + if (!project) { + return new Response( + JSON.stringify({ error: 'Project not found for deployment' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Get project owner information + const ownerInfo = await ctx.runQuery(internal.project.getProjectOwner, { + projectId: project._id, + }) + + if (!ownerInfo) { + return new Response( + JSON.stringify({ error: 'Owner information not found' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Build response + const response = { + success: true, + project: { + id: project._id, + name: project.name || null, + semantic_identifier: project.semantic_identifier, + }, + owner: ownerInfo, + } + + return new Response(JSON.stringify(response), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + } catch (error) { + console.error('Project owner lookup error:', error) + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }), +}) + +// OPTIONS handler for project-owner endpoint CORS +http.route({ + path: '/project-owner', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +// Integration key validation endpoint +http.route({ + path: '/integration-auth/validate', + method: 'POST', + handler: http_validateIntegrationBearerKey, +}) + +// Pause user deployments by Clerk ID and Autumn feature ID +http.route({ + path: '/pause-user-deployments', + method: 'POST', + handler: httpAction(async (ctx, request) => { + try { + // Validate API key + const authHeader = request.headers.get('authorization') + const apiKey = process.env.DEPLOYMENT_PAUSE_API_KEY + + if (!apiKey) { + console.error('DEPLOYMENT_PAUSE_API_KEY not configured') + return new Response( + JSON.stringify({ error: 'API key not configured' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ) + } + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response( + JSON.stringify({ error: 'Missing or invalid authorization header' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ) + } + + const providedKey = authHeader.substring(7) // Remove 'Bearer ' prefix + if (providedKey !== apiKey) { + return new Response(JSON.stringify({ error: 'Invalid API key' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Parse request body + const body = await request.json() + const { userId, featureId } = body + + if (!userId || !featureId) { + return new Response( + JSON.stringify({ + error: 'Missing required fields: userId and featureId', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Look up Convex user ID from Clerk ID + const user = await ctx.runQuery(internal.users.getUserByClerkId, { + clerkId: userId, + }) + + if (!user) { + return new Response(JSON.stringify({ error: 'User not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Validate feature ID and map to pause reason + // Note: This is a subset of PauseReason - only includes reasons that can be triggered by Autumn features + // "db_storage_depleted" maps to convex_database_bw in the reverse mapping + // "manual_admin" is not triggered via this endpoint + const featureIdMapping: Record< + string, + Extract< + PauseReason, + | 'db_bandwidth_depleted' + | 'compute_depleted' + | 'file_bandwidth_depleted' + | 'function_calls_depleted' + > + > = { + convex_database_bw: 'db_bandwidth_depleted', + convex_compute: 'compute_depleted', + convex_file_bw: 'file_bandwidth_depleted', + convex_function_calls: 'function_calls_depleted', + } + + const pauseReason: PauseReason | undefined = featureIdMapping[featureId] + + if (!pauseReason) { + return new Response( + JSON.stringify({ + error: + 'Invalid feature ID. Supported: convex_database_bw, convex_compute, convex_file_bw, convex_function_calls', + }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + + // Pause all user deployments when feature limit is exceeded + const result = await ctx.runAction( + internal.deployment_management.pauseAllUserDeployments, + { + userId: user._id, + pauseReason, + autoUnpauseEnabled: true, + }, + ) + + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } catch (error) { + console.error('Pause by feature webhook error:', error) + return new Response(JSON.stringify({ error: 'Internal server error' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + }), +}) + +// WebContainer filesystem snapshot upload (binary, lz4-compressed tarball). +// Trust model mirrors /api/screenshot/upload: gated on a valid projectId +// rather than a bearer token, since this is invoked directly from the +// browser tab that owns the WebContainer. +http.route({ + path: '/api/webcontainer/snapshot', + method: 'POST', + handler: httpAction(async (ctx, request) => { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get('projectId') + const origin = getAllowedOrigin(request) + + const createResponse = (body: string | null, status: number) => + new Response(body, { + status, + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + }, + }) + + try { + if (!projectId) { + return createResponse('Missing required query parameter: projectId', 400) + } + + const project = await ctx.runQuery(internal.project.getProject, { + projectId: projectId as any, + }) + if (!project || !project.sandbox_id.startsWith('webcontainer:')) { + return createResponse('Project not found', 404) + } + + const blob = await request.blob() + if (!blob || blob.size === 0) { + return createResponse('Invalid snapshot data', 400) + } + + const storageId = await ctx.storage.store(blob) + await ctx.runMutation(internal.codesandbox.webcontainerSnapshot.saveSnapshotRecord, { + projectId: project._id, + storageId, + sizeBytes: blob.size, + }) + + return createResponse(JSON.stringify({ ok: true }), 200) + } catch (error) { + console.error('[WebContainer snapshot] upload error:', error) + return createResponse('Internal server error', 500) + } + }), +}) + +http.route({ + path: '/api/webcontainer/snapshot', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +// WebContainer publish: built `dist/` artifacts uploaded from the browser +// (JSON payload of base64 files) ahead of the server-side Vercel deploy (see +// codesandbox/webcontainerPublish.finalizeWebContainerDeployment). Trust +// model mirrors /api/webcontainer/snapshot: the blob is inert until an +// authenticated, project-owning user references its storageId in the +// finalize action. +http.route({ + path: '/api/webcontainer/dist', + method: 'POST', + handler: httpAction(async (ctx, request) => { + const { searchParams } = new URL(request.url) + const projectId = searchParams.get('projectId') + const origin = getAllowedOrigin(request) + + const createResponse = (body: string | null, status: number) => + new Response(body, { + status, + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + 'Content-Type': 'application/json', + }, + }) + + try { + if (!projectId) { + return createResponse( + JSON.stringify({ error: 'Missing required query parameter: projectId' }), + 400, + ) + } + + const project = await ctx.runQuery(internal.project.getProject, { + projectId: projectId as any, + }) + if (!project || !project.sandbox_id.startsWith('webcontainer:')) { + return createResponse(JSON.stringify({ error: 'Project not found' }), 404) + } + + const blob = await request.blob() + if (!blob || blob.size === 0) { + return createResponse(JSON.stringify({ error: 'Invalid dist data' }), 400) + } + + const storageId = await ctx.storage.store(blob) + return createResponse(JSON.stringify({ storageId }), 200) + } catch (error) { + console.error('[WebContainer dist] upload error:', error) + return createResponse(JSON.stringify({ error: 'Internal server error' }), 500) + } + }), +}) + +http.route({ + path: '/api/webcontainer/dist', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': getAllowedOrigin(request), + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + 'Access-Control-Max-Age': '86400', + 'Access-Control-Allow-Credentials': 'true', + }, + }) + }), +}) + +export default http diff --git a/freebuff/web/convex/import_projects.ts b/freebuff/web/convex/import_projects.ts new file mode 100644 index 0000000000..954bd1a8eb --- /dev/null +++ b/freebuff/web/convex/import_projects.ts @@ -0,0 +1,416 @@ +import { v } from "convex/values"; +import { Resend } from "resend"; +import { internal } from "./_generated/api"; +import { Doc, Id } from "./_generated/dataModel"; +import { + action, + internalMutation, + internalQuery, + mutation, +} from "./_generated/server"; +import { getAuthUser } from "./users"; +import { rateLimiter } from "./coding_agent/rateLimiter"; + +// ===================================================================== +// Constants +// ===================================================================== + +const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutes +const OTP_MAX_ATTEMPTS = 5; +const FREEBUFF_FROM_EMAIL = "James from Freebuff "; +const FREEBUFF_REPLY_TO_EMAIL = "support@codebuff.com"; + +// ===================================================================== +// Helpers +// ===================================================================== + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function generateSixDigitCode(): string { + // Use Web Crypto for non-predictable codes (Convex runtime supports it). + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + const n = buf[0] % 1_000_000; + return n.toString().padStart(6, "0"); +} + +function buildOtpEmailHtml(code: string, expiresInMinutes: number): string { + return ` +
+

Hi,

+

Someone (hopefully you) requested to import projects from this email address into a Freebuff account.

+

Your verification code is:

+

${code}

+

This code expires in ${expiresInMinutes} minutes. If you didn't request this, you can safely ignore this email.

+
+ `; +} + +function buildOtpEmailText(code: string, expiresInMinutes: number): string { + return [ + "Hi,", + "", + "Someone (hopefully you) requested to import projects from this email address into a Freebuff account.", + "", + `Your verification code is: ${code}`, + "", + `This code expires in ${expiresInMinutes} minutes. If you didn't request this, you can safely ignore this email.`, + ].join("\n"); +} + +// ===================================================================== +// Internal queries / mutations +// ===================================================================== + +/** + * Look up a single user row by email. Returns null if none, or the most + * recently created row if multiple share an email (a known data drift case + * the existing schema doesn't enforce uniqueness on). + */ +export const getLegacyUserByEmail = internalQuery({ + args: { email: v.string() }, + handler: async (ctx, args) => { + const normalized = normalizeEmail(args.email); + const matches = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", normalized)) + .collect(); + + if (matches.length === 0) return null; + // If multiple, pick the one with the most projects (best candidate), + // mirroring the existing resolveUserByFreebuffIdOrEmail heuristic. + const scored = await Promise.all( + matches.map(async (user) => { + const memberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(); + return { user, count: memberships.length }; + }), + ); + scored.sort((a, b) => { + if (b.count !== a.count) return b.count - a.count; + return b.user._creationTime - a.user._creationTime; + }); + return scored[0].user; + }, +}); + +/** + * Insert a fresh OTP record. Caller is responsible for deleting any prior + * unconsumed OTPs for the same (requester, email) pair. + */ +export const insertOtpInternal = internalMutation({ + args: { + requesterUserId: v.id("users"), + email: v.string(), + code: v.string(), + expiresAt: v.number(), + }, + handler: async (ctx, args) => { + const normalized = normalizeEmail(args.email); + + // Invalidate any existing unconsumed OTP for the same pair so a new code + // supersedes the old one. + const existing = await ctx.db + .query("import_email_otps") + .withIndex("by_requester_and_email", (q) => + q.eq("requester_user_id", args.requesterUserId).eq("email", normalized), + ) + .collect(); + for (const row of existing) { + if (!row.consumed) { + await ctx.db.delete(row._id); + } + } + + return await ctx.db.insert("import_email_otps", { + requester_user_id: args.requesterUserId, + email: normalized, + code: args.code, + expires_at: args.expiresAt, + attempts: 0, + consumed: false, + created_at: Date.now(), + }); + }, +}); + +// ===================================================================== +// Action: request OTP +// ===================================================================== + +export const requestImportOtp = action({ + args: { email: v.string() }, + handler: async ( + ctx, + args, + ): Promise< + | { ok: true } + | { ok: false; error: string; retryAfter?: number } + > => { + const user = await getAuthUser(ctx); + if (!user) { + return { ok: false, error: "Not authenticated" }; + } + + const normalized = normalizeEmail(args.email); + if (!normalized) { + return { ok: false, error: "Email is required" }; + } + if (normalized === normalizeEmail(user.email)) { + return { + ok: false, + error: "That's the email of the account you're already signed in to.", + }; + } + + // Rate limit: max 3 sends per hour per requester + const rl = await rateLimiter.limit(ctx, "importOtpSends", { + key: user._id, + throws: false, + }); + if (!rl.ok) { + return { + ok: false, + error: `Too many requests. Try again in ${Math.ceil(rl.retryAfter / 60000)} minute(s).`, + retryAfter: rl.retryAfter, + }; + } + + // Look up legacy user + const legacy: Doc<"users"> | null = await ctx.runQuery( + internal.import_projects.getLegacyUserByEmail, + { email: normalized }, + ); + + if (!legacy) { + return { + ok: false, + error: "No account found with that email.", + }; + } + if (legacy._id === user._id) { + // Defensive: same row matched both freebuff_user_id and email. + return { + ok: false, + error: "That email already belongs to your current account.", + }; + } + if ( + legacy.freebuff_user_id && + legacy.freebuff_user_id !== user.freebuff_user_id + ) { + return { + ok: false, + error: "That account is already linked to another GitHub login.", + }; + } + + // Generate code, persist, send + const code = generateSixDigitCode(); + const expiresAt = Date.now() + OTP_TTL_MS; + + await ctx.runMutation(internal.import_projects.insertOtpInternal, { + requesterUserId: user._id, + email: normalized, + code, + expiresAt, + }); + + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error("[requestImportOtp] RESEND_API_KEY is not configured"); + return { ok: false, error: "Email service not configured" }; + } + + const resend = new Resend(apiKey); + const ttlMinutes = Math.round(OTP_TTL_MS / 60000); + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [normalized], + subject: "Your Freebuff project-import code", + text: buildOtpEmailText(code, ttlMinutes), + html: buildOtpEmailHtml(code, ttlMinutes), + }); + + if (error) { + console.error( + `[requestImportOtp] Failed to send to ${normalized}: ${error.message}`, + ); + return { ok: false, error: "Failed to send verification email." }; + } + + return { ok: true }; + }, +}); + +// ===================================================================== +// Mutation: verify OTP and run transfer (Strategy A or B) +// ===================================================================== + +type VerifyResult = + | { ok: true; strategy: "A" | "B"; importedProjectCount: number } + | { ok: false; error: string; retryAfter?: number }; + +export const verifyAndImport = mutation({ + args: { + email: v.string(), + code: v.string(), + }, + handler: async (ctx, args): Promise => { + const user = await getAuthUser(ctx); + if (!user) { + return { ok: false, error: "Not authenticated" }; + } + + const normalized = normalizeEmail(args.email); + if (!normalized) { + return { ok: false, error: "Email is required" }; + } + const submittedCode = args.code.trim(); + if (submittedCode.length !== 6) { + return { ok: false, error: "Invalid code" }; + } + + // Rate limit verify attempts (per requester) + const rl = await rateLimiter.limit(ctx, "importOtpVerifies", { + key: user._id, + throws: false, + }); + if (!rl.ok) { + return { + ok: false, + error: `Too many attempts. Try again in ${Math.ceil(rl.retryAfter / 1000)}s.`, + retryAfter: rl.retryAfter, + }; + } + + // Find OTP record (must be issued to this requester for this email) + const otpRows = await ctx.db + .query("import_email_otps") + .withIndex("by_requester_and_email", (q) => + q.eq("requester_user_id", user._id).eq("email", normalized), + ) + .collect(); + const otp = otpRows + .filter((r) => !r.consumed) + .sort((a, b) => b.created_at - a.created_at)[0]; + + if (!otp) { + return { ok: false, error: "No active code. Request a new one." }; + } + if (otp.expires_at < Date.now()) { + return { ok: false, error: "Code expired. Request a new one." }; + } + if (otp.attempts >= OTP_MAX_ATTEMPTS) { + return { + ok: false, + error: "Too many failed attempts. Request a new code.", + }; + } + + if (otp.code !== submittedCode) { + await ctx.db.patch(otp._id, { attempts: otp.attempts + 1 }); + return { ok: false, error: "Incorrect code." }; + } + + // ---- OTP verified. Re-run all guards before mutating. ---- + const legacyMatches = await ctx.db + .query("users") + .withIndex("by_email", (q) => q.eq("email", normalized)) + .collect(); + const legacy = legacyMatches.find((u) => u._id !== user._id) ?? null; + if (!legacy) { + await ctx.db.patch(otp._id, { consumed: true }); + return { ok: false, error: "No account found with that email." }; + } + if ( + legacy.freebuff_user_id && + legacy.freebuff_user_id !== user.freebuff_user_id + ) { + await ctx.db.patch(otp._id, { consumed: true }); + return { + ok: false, + error: "That account is already linked to another GitHub login.", + }; + } + + // ---- Hybrid decision: A if current has no project_members, else B. ---- + const currentMemberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(); + + const legacyMemberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", legacy._id)) + .collect(); + + if (currentMemberships.length === 0) { + // ===== Strategy A: claim legacy row, delete current stub ===== + const identity = await ctx.auth.getUserIdentity(); + const freebuffUserId = identity?.subject ?? user.freebuff_user_id; + if (!freebuffUserId) { + return { ok: false, error: "Missing identity subject." }; + } + + // Patch legacy row to take over the current GitHub identity. + await ctx.db.patch(legacy._id, { + freebuff_user_id: freebuffUserId, + clerk_id: freebuffUserId, + }); + + // Delete the current stub row. Confirmed it has no project_member rows. + // Other tables may reference it (community, hiring forms, etc.) but + // those are out-of-scope per the screenshot's note about secondary + // surfaces. We don't delete those rows; we just delete the stub user. + await ctx.db.delete(user._id); + + await ctx.db.patch(otp._id, { consumed: true }); + + // importedProjectCount is the count of distinct projects the legacy + // user has membership in (which the user now sees, since they ARE + // legacy now). + const distinctProjects = new Set( + legacyMemberships.map((m) => m.project), + ); + return { + ok: true, + strategy: "A", + importedProjectCount: distinctProjects.size, + }; + } + + // ===== Strategy B: transfer project_member rows ===== + let transferred = 0; + const currentMembershipByProject = new Map, Doc<"project_member">>(); + for (const m of currentMemberships) { + currentMembershipByProject.set(m.project, m); + } + + for (const legacyM of legacyMemberships) { + const existing = currentMembershipByProject.get(legacyM.project); + if (existing) { + // Conflict: current user already a member. Delete legacy row; + // current's role wins to preserve the user's existing state. + await ctx.db.delete(legacyM._id); + } else { + // Re-parent the membership to the current user; preserve role. + await ctx.db.patch(legacyM._id, { user: user._id }); + transferred += 1; + } + } + + await ctx.db.patch(otp._id, { consumed: true }); + + return { + ok: true, + strategy: "B", + importedProjectCount: transferred, + }; + }, +}); diff --git a/freebuff/web/convex/integration_auth.ts b/freebuff/web/convex/integration_auth.ts new file mode 100644 index 0000000000..df837e6d7e --- /dev/null +++ b/freebuff/web/convex/integration_auth.ts @@ -0,0 +1,255 @@ +import { internal } from "./_generated/api"; +import { v } from "convex/values"; +import { + httpAction, + internalMutation, + internalQuery, +} from "./_generated/server"; +import { getOrganizationContext } from "./org_security"; + +/** + * Performs constant-time string comparison to prevent timing attacks + * @param a First string to compare + * @param b Second string to compare + * @returns true if strings are equal, false otherwise + */ +function constantTimeStringCompare(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + + return result === 0; +} + +export const createIntegrationBearerKey = internalMutation({ + args: { + projectId: v.id("project"), + key: v.string(), + }, + handler: async (ctx, args) => { + const record = await ctx.db.insert("integration_bearer_keys", { + project_id: args.projectId, + key: args.key, + }); + return record; + }, +}); + +export const getIntegrationKeyForProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + returns: v.union(v.string(), v.null()), + handler: async (ctx, args) => { + const record = await ctx.db + .query("integration_bearer_keys") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .first(); + return record?.key ?? null; + }, +}); + +export const validateIntegrationBearerKey = internalQuery({ + args: { + key: v.string(), + }, + returns: v.union( + v.object({ + valid: v.boolean(), + projectId: v.id("project"), + customerId: v.string(), + customerData: v.object({ + name: v.string(), + email: v.string(), + }), + semanticIdentifier: v.string(), + }), + v.object({ + valid: v.boolean(), + error: v.string(), + }), + ), + handler: async (ctx, args) => { + // Step 1: Validate the bearer key and get project ID + const record = await ctx.db + .query("integration_bearer_keys") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + if (!record) { + return { + valid: false, + error: "Invalid bearer key", + }; + } + + // Step 2: Get project to check organization context + const project = await ctx.db.get(record.project_id); + if (!project) { + return { + valid: false, + error: "Project not found", + }; + } + + // Step 3: Get project owner for fallback user data + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", record.project_id)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .first(); + + if (!projectMember) { + return { + valid: false, + error: "Project owner not found", + }; + } + + const user = await ctx.db.get(projectMember.user); + if (!user) { + return { + valid: false, + error: "User not found", + }; + } + + // Step 4: Determine customer ID and data based on organization context + // This matches the billing logic used in Autumn integration + const customerId = project.organization_id || user.clerk_id; + + // Get organization context to retrieve real organization name + const orgContext = await getOrganizationContext(ctx); + const organizationName = + project.organization_id && orgContext?.name + ? orgContext.name + : project.organization_id + ? "Organization" // Fallback for organizations without names + : user.name; + + const customerData = { + name: organizationName, + email: user.email, // Use project owner email for contact + }; + + return { + valid: true, + projectId: record.project_id, + customerId, + customerData, + semanticIdentifier: project.semantic_identifier, + }; + }, +}); + +export const http_validateIntegrationBearerKey = httpAction( + async (ctx, request) => { + const timestamp = new Date().toISOString(); + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Request received`, + ); + + // Log request headers (without revealing sensitive values) + const vlyHeader = request.headers.get("x-vly-master-integration-key"); + const hasMasterKeyHeader = !!vlyHeader; + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Has master key header: ${hasMasterKeyHeader}`, + ); + + const masterKey = process.env.VLY_MASTER_INTEGRATION_KEY; + const hasMasterKeyEnv = !!masterKey; + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Has master key env var: ${hasMasterKeyEnv}`, + ); + + if ( + !vlyHeader || + !masterKey || + !constantTimeStringCompare(vlyHeader, masterKey) + ) { + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Master key authentication failed`, + ); + return new Response( + "Unauthorized - set x-vly-master-integration-key header to the master integration key", + { status: 401 }, + ); + } + + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Master key authenticated successfully`, + ); + + let body; + try { + body = await request.json(); + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Request body parsed`, + ); + } catch (error) { + console.error( + `[${timestamp}] [http_validateIntegrationBearerKey] Failed to parse request body:`, + error, + ); + return new Response("Invalid JSON body", { status: 400 }); + } + + const key: string | undefined | null = body.key; + + if (!key) { + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] No key provided in body`, + ); + return new Response("Key is required", { status: 400 }); + } + + // Log key prefix for debugging (first 10 chars only) + const keyPrefix = key.substring(0, 10); + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Validating key starting with: ${keyPrefix}...`, + ); + + let result; + try { + result = await ctx.runQuery( + internal.integration_auth.validateIntegrationBearerKey as any, + { + key: body.key, + }, + ); + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Validation query completed. Valid: ${result.valid}`, + ); + if (!result.valid && result.error) { + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Validation error: ${result.error}`, + ); + } + } catch (error) { + console.error( + `[${timestamp}] [http_validateIntegrationBearerKey] Validation query failed:`, + error, + ); + return new Response("Internal server error during validation", { + status: 500, + }); + } + + const responseStatus = result.valid ? 200 : 400; + console.log( + `[${timestamp}] [http_validateIntegrationBearerKey] Returning response with status: ${responseStatus}`, + ); + + return new Response(JSON.stringify(result), { + status: responseStatus, + headers: { + "Content-Type": "application/json", + }, + }); + }, +); diff --git a/freebuff/web/convex/integrations.ts b/freebuff/web/convex/integrations.ts new file mode 100644 index 0000000000..cfeeb5c01c --- /dev/null +++ b/freebuff/web/convex/integrations.ts @@ -0,0 +1,982 @@ +import { Id } from "./_generated/dataModel"; +import { + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { internal } from "./_generated/api"; +import { paginationOptsValidator } from "convex/server"; +import { v } from "convex/values"; +import { getAuthUser } from "./users"; +import { getVerifiedAccessProject } from "./project"; + +// Internal cacheable version - enables Convex query caching for individual integrations +// This provides maximum cache reuse since each integration ID is cached independently +export const getIntegrationByIdInternal = internalQuery({ + args: { + integrationId: v.id("integration"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.integrationId); + }, +}); + +// Get a single integration by ID +export const getIntegrationById = query({ + args: { + integrationId: v.id("integration"), + }, + handler: async (ctx, args): Promise => { + return await ctx.runQuery( + internal.integrations.getIntegrationByIdInternal, + { + integrationId: args.integrationId, + }, + ); + }, +}); + +// Get multiple integrations by IDs (batch query to avoid N+1 problem) +// Uses individual cached queries for maximum cache reuse across different messages +export const getIntegrationsByIds = query({ + args: { + integrationIds: v.array(v.id("integration")), + }, + handler: async (ctx, args): Promise => { + // Each call benefits from individual integration caching + // This is better than batch caching because integrations are reused across messages + const integrations = await Promise.all( + args.integrationIds.map((id) => + ctx.runQuery(internal.integrations.getIntegrationByIdInternal, { + integrationId: id, + }), + ), + ); + return integrations.filter( + (i: any): i is NonNullable => i !== null, + ); + }, +}); + +// Get integrations for a specific project +export const getProjectIntegrations = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Note: No feature check here - viewing existing project integrations + // should not block project access. Feature access is enforced when + // adding new integrations via addIntegrationToProject mutation. + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Get project integrations + const projectIntegrations = await ctx.db + .query("project_integration") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .collect(); + + // Get full integration details + const integrations = await Promise.all( + projectIntegrations.map(async (pi) => { + const integration = await ctx.db.get(pi.integrationId); + return integration; + }), + ); + + return integrations.filter( + (i: any): i is NonNullable => i !== null, + ); + }, +}); + +// Internal version of getProjectIntegrations that takes projectId directly +export const getProjectIntegrationsInternal = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + // Get project integrations + const projectIntegrations = await ctx.db + .query("project_integration") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + + // Get full integration details + const integrations = await Promise.all( + projectIntegrations.map(async (pi) => { + const integration = await ctx.db.get(pi.integrationId); + return integration; + }), + ); + + return integrations.filter( + (i: any): i is NonNullable => i !== null, + ); + }, +}); + +// Internal cacheable version - accepts userRole to avoid JWT lookup +export const getIntegrationsInternal = internalQuery({ + args: { + userRole: v.optional(v.string()), + search: v.optional(v.string()), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + // Scope the base query using indexes so we never scan the whole table + // If search provided, use search index so filtering occurs before pagination + if (args.search) { + // Non-god users: restrict to public results using filterFields + const searchPage = + args.userRole === "god" + ? await ctx.db + .query("integration") + .withSearchIndex("search_all", (q) => + q.search("search_text", args.search!), + ) + .paginate(args.paginationOpts) + : await ctx.db + .query("integration") + .withSearchIndex("search_all", (q) => + q.search("search_text", args.search!).eq("public", true), + ) + .paginate(args.paginationOpts); + return searchPage; + } + + // No search term: just paginate by public/non-public + const baseQuery = + args.userRole === "god" + ? ctx.db.query("integration").withIndex("by_last_updated") + : ctx.db + .query("integration") + .withIndex("by_public_and_last_updated", (q) => + q.eq("public", true), + ); + + return await baseQuery.order("desc").paginate(args.paginationOpts); + }, +}); + +// Get all integrations (library) with server-side pagination and lightweight search +export const getIntegrations = query({ + args: { + search: v.optional(v.string()), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args): Promise => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // No feature check here - viewing the integrations library should be + // accessible to all users. Feature access is enforced when adding + // integrations to a project via addIntegrationToProject mutation. + + // Delegate to internal cached version + return await ctx.runQuery(internal.integrations.getIntegrationsInternal, { + userRole: user.role, + search: args.search, + paginationOpts: args.paginationOpts, + }); + }, +}); + +// Add a new integration +export const addIntegration = mutation({ + args: { + title: v.string(), + description: v.string(), + cover_image: v.string(), + tags: v.array(v.string()), + documentation_urls: v.array(v.string()), + llm_instructions: v.string(), + user_instructions: v.string(), + human_added_notes: v.optional(v.string()), + env_variables: v.optional( + v.array( + v.object({ + id: v.string(), + description: v.string(), + }), + ), + ), + images: v.optional(v.array(v.string())), + projectId: v.optional(v.id("project")), + semanticIdentifier: v.optional(v.string()), + recommended: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + let projectId: Id<"project"> | undefined = args.projectId; + if (!projectId && args.semanticIdentifier) { + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) projectId = undefined; + else projectId = project._id; + } + + // Create the integration + const integrationId = await ctx.db.insert("integration", { + ...args, + public: false, + type: "user generated", + creator: projectId, + last_updated: Date.now(), + reference_id: args.title.toLowerCase().replace(/\s+/g, "_"), + search_text: + `${args.title} ${args.description} ${(args.tags || []).join(" ")}`.toLowerCase(), + approval_status: "pending", // User-generated integrations start as pending + }); + + return await ctx.db.get(integrationId); + }, +}); + +export const addIntegrationInternal = internalMutation({ + args: { + title: v.string(), + description: v.string(), + cover_image: v.string(), + tags: v.array(v.string()), + documentation_urls: v.array(v.string()), + llm_instructions: v.string(), + user_instructions: v.string(), + human_added_notes: v.optional(v.string()), + env_variables: v.optional( + v.array( + v.object({ + id: v.string(), + description: v.string(), + }), + ), + ), + images: v.optional(v.array(v.string())), + projectId: v.id("project"), + recommended: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + // Create the integration + const integrationId = await ctx.db.insert("integration", { + ...args, + public: false, + type: "user generated", + creator: args.projectId, + last_updated: Date.now(), + reference_id: args.title.toLowerCase().replace(/\s+/g, "_"), + search_text: + `${args.title} ${args.description} ${(args.tags || []).join(" ")}`.toLowerCase(), + approval_status: "pending", // User-generated integrations start as pending + }); + + return await ctx.db.get(integrationId); + }, +}); + +export const addIntegrationToLibraryAndProject = internalMutation({ + args: { + integration: v.object({ + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + env_variables: v.array( + v.object({ + id: v.string(), + description: v.string(), + }), + ), + user_instructions: v.string(), + llm_instructions: v.string(), + documentation_urls: v.array(v.string()), + cover_image: v.string(), + context7_library_id: v.optional(v.string()), + public: v.optional(v.boolean()), + }), + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + // Generate a reference_id from the title + const reference_id = args.integration.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + + // Add integration to library + const integrationId = await ctx.db.insert("integration", { + ...args.integration, + reference_id, + public: args.integration.public ?? true, // Use agent-determined public status, default to false + type: "user generated", + creator: args.projectId, + last_updated: Date.now(), + approval_status: "pending", // User-generated integrations start as pending + }); + + // Add integration to project + await ctx.db.insert("project_integration", { + projectId: args.projectId, + integrationId, + }); + + // Return the created integration + const integration = await ctx.db.get(integrationId); + return integration; + }, +}); + +// Add an integration to a project +export const addIntegrationToProject = mutation({ + args: { + semanticIdentifier: v.string(), + integrationId: v.id("integration"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Feature access is enforced client-side via useFeatureAccess hook + // in IntegrationsLibrary.tsx (shows upgrade dialog for users without access). + // Server-side autumn.check() was incorrectly blocking Business+ plan users + // due to Autumn API sync issues, so the hard gate was removed here. + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Check if integration exists + const integration = await ctx.db.get(args.integrationId); + if (!integration) throw new Error("Integration not found"); + + // Check if integration is already added to project + const existing = await ctx.db + .query("project_integration") + .withIndex("by_project_and_integration", (q) => + q.eq("projectId", project._id).eq("integrationId", args.integrationId), + ) + .unique(); + + if (existing) { + throw new Error("Integration already added to project"); + } + + // Add integration to project + await ctx.db.insert("project_integration", { + projectId: project._id, + integrationId: args.integrationId, + }); + + await ctx.scheduler.runAfter( + 0, + internal.cloud_feature_usage.recordCloudFeatureUsage, + { + userId: user._id, + feature: "integration", + projectId: project._id, + detail: integration.title ?? undefined, + }, + ); + + return true; + }, +}); + +export const addIntegrationReferenceIdToProject = internalMutation({ + args: { + projectId: v.id("project"), + integrationReferenceId: v.string(), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + // find integration based on name using name index - use first() instead of unique() + const integration = await ctx.db + .query("integration") + .withIndex("by_reference_id", (q) => + q.eq("reference_id", args.integrationReferenceId), + ) + .first(); + + if (!integration) throw new Error("Integration not found"); + + // Check if integration is already added to project + const existing = await ctx.db + .query("project_integration") + .withIndex("by_project_and_integration", (q) => + q.eq("projectId", project._id).eq("integrationId", integration._id), + ) + .first(); + + if (existing) { + // Don't throw error, just return success silently + console.log( + `[addIntegrationReferenceIdToProject] Integration ${args.integrationReferenceId} already exists in project`, + ); + return integration; + } + + // add integration to project + await ctx.db.insert("project_integration", { + projectId: args.projectId, + integrationId: integration._id, + }); + + return integration; // return the integration details + }, +}); + +// Remove an integration from a project +export const removeIntegrationFromProject = mutation({ + args: { + semanticIdentifier: v.string(), + integrationId: v.id("integration"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Find and delete the project integration + const projectIntegration = await ctx.db + .query("project_integration") + .withIndex("by_project_and_integration", (q) => + q.eq("projectId", project._id).eq("integrationId", args.integrationId), + ) + .unique(); + + if (!projectIntegration) { + throw new Error("Integration not found in project"); + } + + await ctx.db.delete(projectIntegration._id); + return true; + }, +}); + +// Generate an upload URL for file uploads +export const generateUploadUrl = mutation({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + return await ctx.storage.generateUploadUrl(); + }, +}); + +// Save a file URL to the integration +export const saveFileUrl = mutation({ + args: { + storageId: v.id("_storage"), + type: v.union(v.literal("logo"), v.literal("image")), + index: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + const url = await ctx.storage.getUrl(args.storageId); + return url; + }, +}); + +// takes in an array and returns all threads +export const getIntegrationsInContext = internalQuery({ + args: { + integrationIds: v.array(v.id("integration")), + }, + handler: async (ctx, args) => { + const integrations = []; + for (const integrationId of args.integrationIds) { + if (!integrationId) continue; + const integration = await ctx.db.get(integrationId); + if (integration) { + integrations.push(integration); + } + } + return integrations; + }, +}); + +export const getAllIntegrations = internalQuery({ + args: { + projectId: v.id("project"), + searchQuery: v.optional(v.string()), // Optional search query to filter integrations + }, + handler: async (ctx, args) => { + let publicIntegrations; + + if (args.searchQuery) { + const result = await ctx.db + .query("integration") + .withSearchIndex("search_all", (q) => + q.search("search_text", args.searchQuery!).eq("public", true), + ) + .paginate({ numItems: 3, cursor: null }); + publicIntegrations = result.page; + } else { + publicIntegrations = await ctx.db + .query("integration") + .withIndex("by_public", (q) => q.eq("public", true)) + .take(3); + } + + // Get all integrationIds already in the project + const projectIntegrations = await ctx.db + .query("project_integration") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + const projectIntegrationIds = new Set( + projectIntegrations.map((pi) => pi.integrationId.toString()), + ); + + // Filter out integrations already in the project + const filteredIntegrations = publicIntegrations.filter( + (integration) => !projectIntegrationIds.has(integration._id.toString()), + ); + + return filteredIntegrations.map((integration) => ({ + title: integration.title, + description: integration.description, + reference_id: integration.reference_id, + tags: integration.tags || [], + })); + }, +}); + +export const updateIntegration = mutation({ + args: { + integrationId: v.id("integration"), + title: v.optional(v.string()), + description: v.optional(v.string()), + cover_image: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + documentation_urls: v.optional(v.array(v.string())), + llm_instructions: v.optional(v.string()), + user_instructions: v.optional(v.string()), + human_added_notes: v.optional(v.string()), + env_variables: v.optional( + v.array( + v.object({ + id: v.string(), + description: v.string(), + }), + ), + ), + images: v.optional(v.array(v.string())), + public: v.optional(v.boolean()), + type: v.optional(v.string()), + approval_status: v.optional( + v.union( + v.literal("pending"), + v.literal("approved"), + v.literal("rejected"), + ), + ), + }, + returns: v.any(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + if (user.role !== "god" && user.role !== "admin") { + throw new Error("Not authorized"); + } + + const updateData: Record = {}; + if (args.title !== undefined) updateData.title = args.title; + if (args.description !== undefined) + updateData.description = args.description; + if (args.cover_image !== undefined) + updateData.cover_image = args.cover_image; + if (args.tags !== undefined) updateData.tags = args.tags; + if (args.documentation_urls !== undefined) + updateData.documentation_urls = args.documentation_urls; + if (args.llm_instructions !== undefined) + updateData.llm_instructions = args.llm_instructions; + if (args.user_instructions !== undefined) + updateData.user_instructions = args.user_instructions; + if (args.human_added_notes !== undefined) + updateData.human_added_notes = args.human_added_notes; + if (args.env_variables !== undefined) + updateData.env_variables = args.env_variables; + if (args.images !== undefined) updateData.images = args.images; + if (args.type !== undefined) updateData.type = args.type; + + // Handle approval status and auto-set public flag + if (args.approval_status !== undefined) { + updateData.approval_status = args.approval_status; + // Auto-set public flag based on approval status + if (args.approval_status === "approved") { + updateData.public = true; + } else if ( + args.approval_status === "pending" || + args.approval_status === "rejected" + ) { + updateData.public = false; + } + } + updateData.last_updated = Date.now(); + + // Keep unified search_text in sync if relevant fields changed + if ( + updateData.title !== undefined || + updateData.description !== undefined || + updateData.tags !== undefined + ) { + const current = await ctx.db.get(args.integrationId); + if (current) { + const title = updateData.title ?? current.title; + const description = updateData.description ?? current.description; + const tags = (updateData.tags ?? current.tags) || []; + updateData.search_text = + `${title} ${description} ${tags.join(" ")}`.toLowerCase(); + } + } + + await ctx.db.patch(args.integrationId, updateData); + return await ctx.db.get(args.integrationId); + }, +}); + +export const deleteIntegration = mutation({ + args: { + integrationId: v.id("integration"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + // Allow both god and admin roles + if (user.role !== "god" && user.role !== "admin") { + throw new Error("Not authorized"); + } + + // Delete all project associations first + const projectIntegrations = await ctx.db + .query("project_integration") + .withIndex("by_integration", (q) => + q.eq("integrationId", args.integrationId), + ) + .collect(); + + for (const pi of projectIntegrations) { + await ctx.db.delete(pi._id); + } + + // Then delete the integration + await ctx.db.delete(args.integrationId); + return true; + }, +}); + +// Integration logs - for tracking integration creation process +export const createIntegrationLog = internalMutation({ + args: { + projectId: v.id("project"), + query: v.string(), + }, + returns: v.id("integration_logs"), + handler: async (ctx, args) => { + const logId = await ctx.db.insert("integration_logs", { + projectId: args.projectId, + query: args.query, + status: "draft", + type: "existing", // Default to existing, will be updated + steps: [], + created_at: Date.now(), + }); + return logId; + }, +}); + +export const addIntegrationLogStep = internalMutation({ + args: { + logId: v.id("integration_logs"), + step: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + const log = await ctx.db.get(args.logId); + if (!log) throw new Error("Integration log not found"); + + // Truncate individual step if it's too large (max 10 KB per step) + const truncatedStep = truncateString(args.step, 10 * 1024, "log step"); + + await ctx.db.patch(args.logId, { + steps: [...log.steps, `[${new Date().toISOString()}] ${truncatedStep}`], + }); + return null; + }, +}); + +export const updateIntegrationLogType = internalMutation({ + args: { + logId: v.id("integration_logs"), + type: v.union(v.literal("existing"), v.literal("new")), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.logId, { + type: args.type, + }); + return null; + }, +}); + +// Helper function to truncate strings to prevent exceeding 1 MiB limit +function truncateString( + str: string, + maxBytes: number, + fieldName?: string, +): string { + if (!str) return str; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + if (bytes.length <= maxBytes) { + return str; + } + + // Log warning when truncation occurs + const originalSize = (bytes.length / 1024).toFixed(2); + const truncatedSize = (maxBytes / 1024).toFixed(2); + console.error( + `[Integration Logs] Truncation warning: ${fieldName || "Field"} exceeded size limit. ` + + `Original: ${originalSize} KB, Truncated to: ${truncatedSize} KB`, + ); + + // Truncate and add indicator + const decoder = new TextDecoder(); + const truncated = decoder.decode(bytes.slice(0, maxBytes)); + return truncated + "\n\n[... truncated due to size limit]"; +} + +export const updateIntegrationLogDeepResearch = internalMutation({ + args: { + logId: v.id("integration_logs"), + deep_research_results: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Truncate to 400 KB to leave room for other fields + const truncated = truncateString( + args.deep_research_results, + 400 * 1024, + "deep_research_results", + ); + + await ctx.db.patch(args.logId, { + deep_research_results: truncated, + }); + return null; + }, +}); + +export const updateIntegrationLogContext7Results = internalMutation({ + args: { + logId: v.id("integration_logs"), + context7_results: v.object({ + searched: v.boolean(), + search_results: v.optional(v.string()), + results_count: v.optional(v.number()), + selected_library_id: v.optional(v.string()), + raw_documentation: v.optional(v.string()), + documentation_length: v.optional(v.number()), + error: v.optional(v.string()), + }), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Truncate large fields to prevent exceeding 1 MiB + const truncatedResults = { + ...args.context7_results, + search_results: args.context7_results.search_results + ? truncateString( + args.context7_results.search_results, + 100 * 1024, + "context7_results.search_results", + ) // 100 KB limit + : undefined, + raw_documentation: args.context7_results.raw_documentation + ? truncateString( + args.context7_results.raw_documentation, + 300 * 1024, + "context7_results.raw_documentation", + ) // 300 KB limit + : undefined, + }; + + await ctx.db.patch(args.logId, { + context7_results: truncatedResults, + }); + return null; + }, +}); + +export const completeIntegrationLog = internalMutation({ + args: { + logId: v.id("integration_logs"), + integrationId: v.id("integration"), + status: v.union( + v.literal("created"), + v.literal("already_existing"), + v.literal("failed"), + ), + result: v.optional(v.string()), + error: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.logId, { + integrationId: args.integrationId, + status: args.status, + result: args.result + ? truncateString(args.result, 50 * 1024, "result") + : undefined, + error: args.error + ? truncateString(args.error, 50 * 1024, "error") + : undefined, + completed_at: Date.now(), + }); + return null; + }, +}); + +export const failIntegrationLog = internalMutation({ + args: { + logId: v.id("integration_logs"), + error: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.logId, { + status: "failed", + error: truncateString(args.error, 50 * 1024, "error"), + completed_at: Date.now(), + }); + return null; + }, +}); + +// Query functions for integration logs +export const getIntegrationLogs = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + const logs = await ctx.db + .query("integration_logs") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .order("desc") + .collect(); + + return logs; + }, +}); + +export const getIntegrationLog = query({ + args: { + logId: v.id("integration_logs"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + const log = await ctx.db.get(args.logId); + if (!log) throw new Error("Integration log not found"); + + // Verify user has access to the project + const project = await ctx.db.get(log.projectId); + if (!project) throw new Error("Project not found"); + + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", project._id).eq("user", user._id), + ) + .unique(); + + if (!projectMember && user.role !== "god" && user.role !== "admin") { + throw new Error("Not authorized to view this integration log"); + } + + return log; + }, +}); +// List all user-generated integrations (any status) +export const listAllUserGeneratedIntegrations = query({ + args: { + approval_status: v.optional( + v.union( + v.literal("pending"), + v.literal("approved"), + v.literal("rejected"), + ), + ), + paginationOpts: paginationOptsValidator, + }, + returns: v.any(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + if (user.role !== "god" && user.role !== "admin") { + throw new Error("Not authorized"); + } + + if (args.approval_status) { + return await ctx.db + .query("integration") + .withIndex("by_type_and_approval_status", (q) => + q + .eq("type", "user generated") + .eq("approval_status", args.approval_status), + ) + .order("desc") + .paginate(args.paginationOpts); + } + + return await ctx.db + .query("integration") + .withIndex("by_type_and_approval_status", (q) => + q.eq("type", "user generated"), + ) + .order("desc") + .paginate(args.paginationOpts); + }, +}); diff --git a/freebuff/web/convex/integrations/add_integration.ts b/freebuff/web/convex/integrations/add_integration.ts new file mode 100644 index 0000000000..5247197f42 --- /dev/null +++ b/freebuff/web/convex/integrations/add_integration.ts @@ -0,0 +1,943 @@ +"use node"; + +import { internal } from "!/_generated/api"; +import { Id } from "!/_generated/dataModel"; +import { ActionCtx } from "!/_generated/server"; +import { SharedContext } from "!/coding_agent/context/assembly"; +import { MODELS } from "!/utils/registry"; +import { anthropic } from "@ai-sdk/anthropic"; +import { google } from "@ai-sdk/google"; +import { generateObject, generateText, stepCountIs, tool } from "ai"; +import { z } from "zod"; +import { + searchContext7Libraries, + fetchContext7Documentation, +} from "!/utils/context7"; + +/** + * This is what is called + * @param ctx + * @param sharedContext + * @param query + * @returns + */ +export async function addIntegration( + ctx: ActionCtx, + sharedContext: SharedContext, + query: string, +) { + console.log(`[Add Integration] Starting flow - Query: "${query}"`); + + // Create integration log + const logId = await ctx.runMutation( + internal.integrations.createIntegrationLog, + { + projectId: sharedContext.project._id, + query, + }, + ); + + // OPTIMIZATION: Use search query to pre-filter relevant integrations from the library + // This significantly reduces data reads by only fetching integrations matching the user's request + const allIntegrations = await ctx.runQuery( + internal.integrations.getAllIntegrations, + { + projectId: sharedContext.project._id, + searchQuery: query, // Pass user's query to filter relevant integrations + }, + ); + + console.log( + `[Add Integration] Loaded ${allIntegrations.length} relevant integrations (search-filtered), starting selection phase`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Loaded ${allIntegrations.length} relevant integrations from library (search-filtered)`, + }); + + // message being returned to the agent + let resultMessage = "Failed to create integration."; + + // use different one to select. 2.5 flash + const selection = await generateText({ + model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + providerOptions: { + google: { + thinkingConfig: { + thinkingBudget: 2000, + includeThoughts: false, + }, + }, + }, + tools: { + google_search: google.tools.googleSearch({}) as any, + }, + prompt: `You are a search and select agent for integrations for a web application that a user is trying to add. You are given an integration description for what the user is looking to integrate into their project, and your job is to: + 1. Find an existing integration that matches the description based on reference_id and output it + 2. OR request a new integration to be researched if it doesn't exist in the library + + This codebase is built with the following tech stack: + + - Typescript for the language. Everything in .ts or .tsx + - React (write all frontend with react) + - Tailwind for styling and shad cn as the UI library + - Convex for the backend and database and auth + - Various integrations, including convex components, general integrations, etc. + + Here are the integrations that have been added to the project so far: + ${sharedContext.projectIntegrations + .map( + (integration) => ` + - ${integration.title} + `, + ) + .join("\n")} + + + Here are the remaining integrations that can be added to the project that you will select from (if it exists in the library): + + + ${allIntegrations + .map( + (integration: any) => ` + + integration id: ${integration.reference_id} + title: ${integration.title} + description: ${integration.description} + tags: ${integration.tags.join(", ")} + + + `, + ) + .join("\n")} + + + Here is the integration details as to the integration that the user wishes to add to the codebase: + + ${query} + + + ** OUTPUT FORMAT ** + You MUST output your response in ONE of the following two formats: + + 1. If you find an existing integration that matches, output ONLY the reference_id (do not include any other prefixes or suffixes; only the original id): + the-reference-id + + 2. If the integration does NOT exist in the library, output a brief description for research: + Brief description of what needs to be researched and created + + Do NOT output both tags. Choose ONE based on whether the integration exists in the library or not. + Do NOT include any other text outside these XML tags. + Call the complete_research tool once you have found the integration and are ready to return the results. + Keep the results extremely concise and make the additional information as short and concise as possible (do not include much information; maximum 1 sentence) + `, + }); + + console.log(`[Add Integration] Raw output: ${selection.text}`); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: "Completed selection phase - analyzing AI response", + }); + + // Extract XML tags from response + // Remove the 's' flag for compatibility with environments before 'es2018' + const referenceIdMatch = selection.text.match( + /(.*?)<\/reference_id>/, + ); + const newIntegrationMatch = selection.text.match( + /(.*?)<\/new_integration>/, + ); + + // Process based on which tag was found + if (referenceIdMatch) { + const referenceId = referenceIdMatch[1].trim(); + console.log( + `[Add Integration] Selected existing integration: ${referenceId}`, + ); + + await ctx.runMutation(internal.integrations.updateIntegrationLogType, { + logId, + type: "existing", + }); + + try { + const integration = await ctx.runMutation( + internal.integrations.addIntegrationReferenceIdToProject, + { + projectId: sharedContext.project._id, + integrationReferenceId: referenceId, + }, + ); + + console.log( + `[Add Integration] Success: Added existing integration "${integration.title}"`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Successfully added existing integration: ${integration.title}`, + }); + + // Update the assistant message with the integration reference + if (sharedContext.assistantMessageId) { + await ctx.runMutation( + internal.messages.updateMessageIntegrationReference, + { + messageId: sharedContext.assistantMessageId, + integrationId: integration._id, + }, + ); + } + + await ctx.runMutation(internal.integrations.completeIntegrationLog, { + logId, + integrationId: integration._id, + status: "already_existing", + result: `Added existing integration: ${integration.title}`, + }); + + resultMessage = `The following existing integration has been added to the project from the library: ${integration.title}. Here are its details and setup instructions: + + + title: ${integration.title} + description: ${integration.description} + tags: ${integration.tags.join(", ")} + documentation_urls: ${integration.documentation_urls.join(", ")} + + + + ${integration.user_instructions} + + Env variables: ${integration.env_variables?.map((envVar: { id: string; description: string }) => `${envVar.id}: ${envVar.description}`).join(", ") || "None"} + + Make sure that the user sets up the env vars correctly. + + + + ${integration.llm_instructions} + `; + } catch (error) { + console.error( + `[Add Integration] Error: Failed to add existing integration - ${error}`, + ); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: `Failed to add existing integration: ${error}`, + }); + + resultMessage = `Error adding integration: ${error}`; + } + } else if (newIntegrationMatch) { + const researchDescription = newIntegrationMatch[1].trim(); + console.log( + `[Add Integration] Creating new integration: ${researchDescription}`, + ); + + await ctx.runMutation(internal.integrations.updateIntegrationLogType, { + logId, + type: "new", + }); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `No existing integration found - starting deep research: ${researchDescription}`, + }); + + const result = await researchNewIntegration( + ctx, + sharedContext, + `${query}\nAdditional context: ${researchDescription}`, + logId, + ); + + console.log(`[Add Integration] New integration research completed`); + resultMessage = result; + } else { + console.error( + `[Add Integration] Error: No valid XML tags found in response`, + ); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: "No valid XML tags found in AI response", + }); + + resultMessage = `Error: Unable to process integration request. The AI did not provide a valid response format.`; + } + + console.log( + `[Add Integration] Completed - Result: ${resultMessage.substring(0, 100)}...`, + ); + + return resultMessage; +} + +async function researchNewIntegration( + ctx: ActionCtx, + sharedContext: SharedContext, + query: string, + logId: Id<"integration_logs">, +) { + console.log( + `[Research Integration] Starting deep research - Query: "${query}"`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: "Starting Context7 search for relevant documentation", + }); + + // Step 1: Search Context7 for relevant libraries + let context7Documentation = ""; + let selectedLibraryName = ""; + + // Track Context7 results for logging + const context7LogData: { + searched: boolean; + search_results?: string; + results_count?: number; + selected_library_id?: string; + raw_documentation?: string; + documentation_length?: number; + error?: string; + } = { + searched: true, + }; + + try { + console.log( + `[Research Integration] Searching Context7 with query: "${query}"`, + ); + const context7Results = await searchContext7Libraries(query); + + if (context7Results.error) { + console.log( + `[Research Integration] Context7 search error: ${context7Results.error}`, + ); + context7LogData.error = context7Results.error; + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Context7 search error: ${context7Results.error}`, + }); + } else if (context7Results.results && context7Results.results.length > 0) { + // Store the full search results as JSON + context7LogData.search_results = JSON.stringify( + context7Results.results, + null, + 2, + ); + context7LogData.results_count = context7Results.results.length; + console.log( + `[Research Integration] Found ${context7Results.results.length} Context7 results`, + ); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Found ${context7Results.results.length} potential matches in Context7`, + }); + + // Step 2: Have AI select the best matching library + const selectionPrompt = `You are selecting the best matching library from Context7 search results for the user's query. + +User's integration request: "${query}" + +Context7 Search Results: +${JSON.stringify( + context7Results.results.slice(0, 5).map((r: any) => ({ + id: r.id, + title: r.title, + description: r.description, + })), + null, + 2, +)} + +Respond with ONLY the library ID (e.g., "/twilio/twilio-node") of the best match for the id specifically, or respond with "NO_MATCH" if none of these libraries match the user's request. + +Guidelines: +- Select the library that most closely matches what the user is asking for +- Consider the library title and description +- If the user is asking for a specific service (like Twilio, Stripe, etc.), match that service +- It must match the id exactly. do not include any prefixes etc. + +If there is clearly zero match, mark no_match as true. + +Most of the time, it matches the user's request. Go for the one that is most relevant. Fill library_id with the library ID if there is a match, otherwise leave it blank. +`; + + const selectionResponse = await generateObject({ + model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + providerOptions: { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: 2000, + }, + }, + }, + prompt: selectionPrompt, + schema: z.object({ + library_id: z + .string() + .describe("The library ID of the best match for the user's request") + .optional(), + no_match: z + .boolean() + .describe("Whether no match was found for the user's request") + .optional(), + }), + }); + + const selectedLibraryId = selectionResponse.object.library_id; + console.log( + `[Research Integration] AI selected library: ${selectedLibraryId}`, + ); + + if (selectedLibraryId && !selectionResponse.object.no_match) { + context7LogData.selected_library_id = selectedLibraryId; + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Selected Context7 library: ${selectedLibraryId}`, + }); + + // Step 3: Fetch the Context7 documentation + console.log( + `[Research Integration] Fetching Context7 docs for: ${selectedLibraryId}`, + ); + try { + const docs = await fetchContext7Documentation(selectedLibraryId, { + tokens: 15000, + }); + + if (docs) { + context7Documentation = docs; + selectedLibraryName = selectedLibraryId; + // Store the full raw documentation + context7LogData.raw_documentation = docs; + context7LogData.documentation_length = docs.length; + console.log( + `[Research Integration] Successfully fetched Context7 docs (${docs.length} chars)`, + ); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Fetched Context7 documentation (${docs.length} characters)`, + }); + } else { + console.log( + `[Research Integration] No Context7 documentation available (null response)`, + ); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Context7 documentation not available`, + }); + } + } catch (docError) { + const docErrorMsg = + docError instanceof Error ? docError.message : String(docError); + context7LogData.error = `Documentation fetch error: ${docErrorMsg}`; + console.error( + `[Research Integration] Error fetching Context7 docs:`, + docError, + ); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Context7 documentation fetch error: ${docErrorMsg}`, + }); + } + } else { + console.log( + `[Research Integration] No matching library found in Context7`, + ); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `No matching library found in Context7 results`, + }); + } + } else { + context7LogData.results_count = 0; + context7LogData.search_results = JSON.stringify([], null, 2); + console.log(`[Research Integration] No Context7 results found`); + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `No results found in Context7`, + }); + } + } catch (error) { + const errorDetails = + error instanceof Error + ? `${error.message}\nStack: ${error.stack}` + : String(error); + console.error(`[Research Integration] Context7 search failed:`, error); + context7LogData.error = errorDetails; + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Context7 search failed: ${errorDetails}`, + }); + } + + // Log Context7 results to integration log + await ctx.runMutation( + internal.integrations.updateIntegrationLogContext7Results, + { + logId, + context7_results: context7LogData, + }, + ); + + // Now proceed with the main research agent + if (context7Documentation) { + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Proceeding with AI research using Context7 docs (${context7Documentation.length} chars) as foundation`, + }); + } else { + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: "Proceeding with AI research using web search only (no Context7 docs found)", + }); + } + + // take the query and have it fill out the information for the new integration + let resultMessage = "Failed to research new integration."; + + let response; + let resultText = ""; + + const webSearchTool = anthropic.tools.webSearch_20250305({ + maxUses: 25, + }); + try { + // have the AI choose from the existing integrations which one to add based on reference id + response = await generateText({ + // model: MODELS.PRIMARY_MODELS.GROK_4_FAST, + // providerOptions: { + // xai: { + // searchParameters: { + // mode: 'auto', // 'auto', 'on', or 'off' + // returnCitations: true, + // maxSearchResults: 10, + // }, + // }, + // }, + model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + providerOptions: { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: 4000, + }, + }, + }, + + //model: openai("o4-mini"), + // tools: { + // web_search_preview: openai.tools.webSearchPreview({ + // // optional configuration: + // searchContextSize: 'high', + // userLocation: { + // type: 'approximate', + // city: 'San Francisco', + // region: 'California', + // }, + // }) + // }, + // Force web search tool (optional): + //model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + tools: { + web_search: webSearchTool as any, + + //google_search: google.tools.googleSearch({}) as any, + + //web_search: openai.tools.webSearch({searchContextSize: 'medium'}), + // google_search: google.tools.googleSearch({}) as any, + // url_context: google.tools.urlContext({}) as any, + think_and_search_again: tool({ + description: + "Think about the information and the next step to take. Call this to reason about the information gathered; do not call this to submit your integration (do not call any tools to do so). Calling this tool will enable you to search again for more details. Call the search tool.", + inputSchema: z.object({ + thinking: z.string(), + }), + execute: async ({ thinking }) => { + console.log( + `[Research Integration] Thinking tool called: ${thinking}`, + ); + // log as step + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Thinking tool called: ${thinking}`, + }); + return "Thinking done. Continue with searching for more resources."; + }, + }), + // complete_research: tool({ + // description: "Once research is done, call this tool to complete the research and return the results. You must call this tool after you have completed your research. You must fill in the field in the output format specified", + // inputSchema: z.object({ + // output: z.string().describe("The output of the research in the format specified"), + // }), + // execute: async ({ output }) => { + // resultText = output; + // return "Research completed. Return the results."; + // }, + // }), + }, + //toolChoice: "required", + stopWhen: stepCountIs(40), + + prompt: `You are a deep research agent whose job is to research a new integration for a web application project. You will be given an integration description, and your goal is to research the project's setup instructions, environmental variables, etc. + + This codebase is built with the following tech stack: + + - Typescript for the language. Everything in .ts or .tsx + - React (write all frontend with react) + - Tailwind for styling and shad cn as the UI library + - Convex for the backend and database and auth + - Various integrations, including convex components, general integrations, etc + - pnpm as the package manager + + When researching integrations, make sure that the integrations fit with the convex backend, the react frontend, and the typescript node environment (as well as other relevant parts). + + Remember that all node runtimes for convex actions require a "use node" directive at the top of the file, and that you cannot directly access the database from actions in the same file. + + However, do not include any code about convex-specific integration; the main AI will already know how to integrate with convex. + + ${ + context7Documentation + ? ` + ** CONTEXT7 DOCUMENTATION AVAILABLE ** + We have already fetched documentation from Context7 for: ${selectedLibraryName} + + This documentation provides foundational information about the integration. Your job is to: + 1. Use this Context7 documentation as the primary source + 2. Fill in ANY missing information with web search + 3. Verify and enhance the information with additional details as needed + + Context7 Documentation: + + ${context7Documentation.substring(0, 15000)} + + ` + : "" + } + + ** RESEARCHING ** + - You must thoroughly research the integration and its documentation + ${context7Documentation ? "- Use the Context7 documentation provided above as your primary source" : ""} + - Use web search to fill in any gaps or missing information + - Research for: integration description, user setup instructions, llm setup instructions, and environment variables + - Once you have found the piece of information, remember it and make sure it is accurate when you return it to the user. + - Complete your research in maximum 10 steps + - Make sure to use the node js version of the sdk that you research so that it runs best through sdk in typescript node environment on convex. + + ** REQUIRED INFORMATION ** + You are to return the following information when submitting the integration: + - Title: The short title of the integration + - Description: The concise description of the integration + - Tags: The tags of the integration for searchability (max 3) + - Env variables (IMPORTANT): The environment variables and api keys required for the integration with descriptions + - User instructions (IMPORTANT): The concise user instructions of the integration for what the user needs to do to find the api keys and configure them. ONLY INCLUDE USER INSTRUCTIONS, NOT SETUP INSTRUCTIONS. It should not contain any installation details, etc. + - LLM instructions: The llm instructions of the integration for setup and basic usage. this is for installation on how to install it and how to use it. be thorough and detailed and have very clear examples with code snippets but limit it to 1 page of instructions to keep it concise. + - Documentation urls: Relevant documentation urls + - Main domain: The main domain of the integration. used to extract the favicon. can be main site or docs. + - Public status: Determine if this integration should be public or private based on the criteria below + + ** PUBLIC/PRIVATE DETERMINATION CRITERIA:** + You should make all integrations public by default. Make it private only if it is inappropriate or sensitive. + + KEEP GOING UNTIL YOUR RESULTS ARE THOROUGH AND COMPLETE. REPEATEDLY CALL THE SEARCH TOOL TO FIND MORE INFORMATION. + + ** OUTPUT FORMAT ** + You MUST output your final integration details in the following XML format: + + + Integration Title + Integration description + tag1,tag2,tag3 + + + API_KEY + Description of this env var + + + User setup instructions (where to sign up and get the api keys, concise markdown format) + LLM setup instructions (installation, basic usage) + https://url1.com,https://url2.com + example.com + PUBLIC/PRIVATE + + + ** RULES TO FOLLOW ** + - ALWAYS MAKE SURE TO RETURN THE INFORMATION IN THE CORRECT XML FORMAT. Make sure all information is accurate and verified + - Always be as concise as possible. Keep minimal words and do not use any filler words or irrelevant information. Be concise; use bullet points and clear concise sections in instructions + - Output ONLY the XML at the end of your research. Do not include any other text outside the tags. + - Continue searching through resources to find the correct documentation. + - Keep LLM instructions as concise as possible; make sure all content is 100% accurate and verified; do not include unverified information and do not include any convex-specific modifications. + + Your text output is the one that will be parsed and used. Do not include the output format in any thinking or tool calls. + + Here is the user request for the integration you must deep research. Use this information and any other context7 or web resources to fulfill this request: + + ${query} + + `, + }); + resultText = response.text; + + console.log(`[Research Integration] Raw output: ${response.text}`); + + // Save the raw AI output to the integration log + await ctx.runMutation( + internal.integrations.updateIntegrationLogDeepResearch, + { + logId, + deep_research_results: JSON.stringify(response), + }, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: "AI research completed - parsing integration details", + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error( + `[Research Integration] Error during AI research: ${errorMessage}`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `AI research failed: ${errorMessage}`, + }); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: `AI research generation failed: ${errorMessage}`, + }); + + resultMessage = `Error during AI research: ${errorMessage}`; + return resultMessage; + } + + // Log the result text being processed + console.log( + `[Research Integration] Processing result text (length: ${resultText.length}): ${resultText.substring(0, 500)}...`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Processing result text from complete_research tool: ${resultText}`, + }); + + // Parse the XML response + const integrationMatch = resultText.match( + /([\s\S]*?)<\/integration>/, + ); + + if (!integrationMatch) { + console.error( + `[Research Integration] Error: No valid integration XML found`, + ); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: + "Unable to extract integration details from research - no valid XML found", + }); + + resultMessage = `Error: Unable to extract integration details from research.`; + return resultMessage; + } + + try { + const integrationXml = integrationMatch[1]; + + // Extract fields from XML + const title = + integrationXml.match(/([\s\S]*?)<\/title>/)?.[1]?.trim() || ""; + const description = + integrationXml + .match(/<description>([\s\S]*?)<\/description>/)?.[1] + ?.trim() || ""; + const tagsStr = + integrationXml.match(/<tags>([\s\S]*?)<\/tags>/)?.[1]?.trim() || ""; + const tags = tagsStr + .split(",") + .map((t) => t.trim()) + .filter((t) => t); + const userInstructions = + integrationXml + .match(/<user_instructions>([\s\S]*?)<\/user_instructions>/)?.[1] + ?.trim() || ""; + const llmInstructions = + integrationXml + .match(/<llm_instructions>([\s\S]*?)<\/llm_instructions>/)?.[1] + ?.trim() || ""; + const docsStr = + integrationXml + .match(/<documentation_urls>([\s\S]*?)<\/documentation_urls>/)?.[1] + ?.trim() || ""; + const documentation_urls = docsStr + .split(",") + .map((u) => u.trim()) + .filter((u) => u); + const main_domain = + integrationXml + .match(/<main_domain>([\s\S]*?)<\/main_domain>/)?.[1] + ?.trim() || ""; + + // Parse public status + const publicStatusText = + integrationXml + .match(/<public_status>([\s\S]*?)<\/public_status>/)?.[1] + ?.trim() || ""; + const isPublic = publicStatusText.toLowerCase().includes("public"); + + // Parse env variables + const envVarsSection = + integrationXml.match(/<env_variables>([\s\S]*?)<\/env_variables>/)?.[1] || + ""; + const envVarMatches = [ + ...envVarsSection.matchAll( + /<env_var>[\s\S]*?<id>([\s\S]*?)<\/id>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<\/env_var>/g, + ), + ]; + const env_variables = envVarMatches.map((match) => ({ + id: match[1].trim(), + description: match[2].trim(), + })); + + console.log( + `[Research Integration] Parsed - Title: "${title}", Tags: [${tags.join(", ")}], Env vars: ${env_variables.length}`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Parsed integration details: ${title} with ${env_variables.length} env vars`, + }); + + // Generate favicon URL + let faviconUrl = ""; + if (main_domain) { + faviconUrl = `https://www.google.com/s2/favicons?domain=${main_domain}&sz=32`; + } else if (documentation_urls.length > 0) { + const mainUrl = documentation_urls[0]; + const fallbackUrl = documentation_urls.find( + (url) => !url.toLowerCase().includes("github.com"), + ); + if (fallbackUrl) { + const urlObj = new URL(fallbackUrl); + faviconUrl = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; + } else { + const urlObj = new URL(mainUrl); + faviconUrl = `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; + } + } + + const integrationDetails = { + title, + description, + tags, + env_variables, + user_instructions: userInstructions, + llm_instructions: llmInstructions, + documentation_urls, + cover_image: faviconUrl, + context7_library_id: selectedLibraryName || undefined, + public: isPublic, + }; + + // Save integration to DB and add to project + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Saving new integration to library and adding to project${selectedLibraryName ? ` (Context7: ${selectedLibraryName})` : ""}`, + }); + + const savedIntegration = await ctx.runMutation( + internal.integrations.addIntegrationToLibraryAndProject, + { + integration: integrationDetails, + projectId: sharedContext.project._id, + }, + ); + + if (!savedIntegration) { + console.error( + `[Research Integration] Error: Failed to save integration - no result returned`, + ); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: "Failed to save integration - no result returned from database", + }); + + resultMessage = "Error saving new researched integration"; + return resultMessage; + } + + console.log( + `[Research Integration] Success: Saved and added integration "${savedIntegration.title}"`, + ); + + await ctx.runMutation(internal.integrations.addIntegrationLogStep, { + logId, + step: `Successfully created and saved integration: ${savedIntegration.title}`, + }); + + // Update the assistant message with the integration reference + if (sharedContext.assistantMessageId) { + await ctx.runMutation( + internal.messages.updateMessageIntegrationReference, + { + messageId: sharedContext.assistantMessageId, + integrationId: savedIntegration._id, + }, + ); + } + + await ctx.runMutation(internal.integrations.completeIntegrationLog, { + logId, + integrationId: savedIntegration._id, + status: "created", + result: `Created new integration: ${savedIntegration.title}`, + }); + + resultMessage = `The following new integration has been researched and added to the project. Here are its details and setup instructions: + + <integration_details> + title: ${savedIntegration.title} + description: ${savedIntegration.description} + tags: ${savedIntegration.tags.join(", ")} + documentation_urls: ${savedIntegration.documentation_urls.join(", ")} + </integration_details> + + <setup_instructions> + ${savedIntegration.user_instructions} + + Env variables: ${savedIntegration.env_variables?.map((envVar: { id: string; description: string }) => `${envVar.id}: ${envVar.description}`).join(", ") || "None"} + + Make sure that the user sets up the env vars correctly. + </setup_instructions> + + <llm_instructions> + ${savedIntegration.llm_instructions} + </llm_instructions>`; + } catch (error) { + console.error( + `[Research Integration] Error: Failed to parse or save integration - ${error}`, + ); + + await ctx.runMutation(internal.integrations.failIntegrationLog, { + logId, + error: `Failed to parse or save integration: ${error}`, + }); + + resultMessage = `Error processing integration: ${error}`; + } + + console.log( + `[Research Integration] Completed - Result: ${resultMessage.substring(0, 100)}...`, + ); + + return resultMessage; +} diff --git a/freebuff/web/convex/integrations/integration_agent.ts b/freebuff/web/convex/integrations/integration_agent.ts new file mode 100644 index 0000000000..7fb55ae39b --- /dev/null +++ b/freebuff/web/convex/integrations/integration_agent.ts @@ -0,0 +1,766 @@ +// /** +// * DEPRECATED: Use addIntegration instead +// */ + +// "use node"; + +// import { action, ActionCtx } from "../_generated/server"; +// import { v } from "convex/values"; +// import { getAuthUser } from "../users"; +// import { getVerifiedAccessProject } from "../project"; +// import { z } from "zod"; +// import { generateObject, stepCountIs, tool } from "ai"; +// import { MODELS } from "!/utils/registry"; +// import { Id } from "!/_generated/dataModel"; + +// import { generateText } from "ai"; +// import { google } from "@ai-sdk/google"; +// import { internal } from "!/_generated/api"; + +// async function resolveRedirectUrl(url: string): Promise<string> { +// try { +// const response = await fetch(url, { method: "HEAD" }); +// return response.url; +// } catch (error) { +// console.warn(`URL redirect resolution failed for ${url}:`, error); +// return url; +// } +// } + +// async function isValidUrl(url: string): Promise<boolean> { +// try { +// new URL(url); +// return true; +// } catch { +// return false; +// } +// } + +// // Context7 API functions +// async function searchContext7Libraries(query: string) { +// try { +// const url = new URL("https://context7.com/api/v1/search"); +// url.searchParams.set("query", query); + +// const response = await fetch(url); +// if (!response.ok) { +// const errorCode = response.status; +// if (errorCode === 429) { +// console.error(`Context7 rate limited. Please try again later.`); +// return { +// results: [], +// error: `Context7 rate limited. Please try again later.`, +// }; +// } +// console.error( +// `Failed to search Context7 libraries. Error code: ${errorCode}`, +// ); +// return { +// results: [], +// error: `Failed to search Context7 libraries. Error code: ${errorCode}`, +// }; +// } + +// return await response.json(); +// } catch (error) { +// console.error("Error searching Context7 libraries:", error); +// return { +// results: [], +// error: `Error searching Context7 libraries: ${error}`, +// }; +// } +// } + +// async function fetchContext7Documentation( +// libraryId: string, +// options: { +// tokens?: number; +// topic?: string; +// } = {}, +// ) { +// try { +// if (libraryId.startsWith("/")) { +// libraryId = libraryId.slice(1); +// } + +// const url = new URL(`https://context7.com/api/v1/${libraryId}`); +// if (options.tokens) +// url.searchParams.set("tokens", options.tokens.toString()); +// if (options.topic) url.searchParams.set("topic", options.topic); +// url.searchParams.set("type", "txt"); + +// const response = await fetch(url, { +// headers: { +// "X-Context7-Source": "mcp-server", +// }, +// }); + +// if (!response.ok) { +// const errorCode = response.status; +// if (errorCode === 429) { +// const errorMessage = `Context7 rate limited. Please try again later.`; +// console.error(errorMessage); +// return errorMessage; +// } +// const errorMessage = `Failed to fetch Context7 documentation. Error code: ${errorCode}`; +// console.error(errorMessage); +// return errorMessage; +// } + +// const text = await response.text(); +// if ( +// !text || +// text === "No content available" || +// text === "No context data available" +// ) { +// return null; +// } + +// return text; +// } catch (error) { +// const errorMessage = `Error fetching Context7 documentation: ${error}`; +// console.error(errorMessage); +// return errorMessage; +// } +// } + +// const envVariable = z.object({ +// id: z.string(), +// description: z.string(), +// }); + +// const generatedIntegration = z.object({ +// title: z.string().describe("The name of the integration"), +// description: z +// .string() +// .describe("A brief description of what the integration does"), +// tags: z.array(z.string()).describe("Relevant tags for categorization"), +// env_variables: z +// .array( +// z.object({ +// id: z.string().describe("Environment variable name"), +// description: z +// .string() +// .describe("Description of what this env var is for"), +// }), +// ) +// .describe("Required environment variables"), +// user_instructions: z +// .string() +// .describe("Step-by-step instructions for user setup"), +// llm_instructions: z +// .string() +// .describe("Technical implementation instructions for the LLM"), +// documentation_urls: z +// .array(z.string()) +// .describe("Official documentation URLs"), +// main_domain: z +// .string() +// .optional() +// .describe( +// "The main domain URL (e.g., 'twilio.com', 'stripe.com') for favicon generation - NOT GitHub or docs subdomains", +// ), +// }); +// type GeneratedIntegration = z.infer<typeof generatedIntegration>; + +// /** +// * DEPRECATED: Use addIntegration instead +// */ +// export const generateIntegration = action({ +// args: { +// userInput: v.string(), +// semanticIdentifier: v.string(), +// }, +// handler: async (ctx, args) => { +// console.log( +// "[Integration Agent] Starting integration generation for:", +// args.userInput, +// ); + +// const user = await getAuthUser(ctx); +// if (!user) throw new Error("Not authenticated"); +// console.log("[Integration Agent] User authenticated:", user._id); + +// // Verify project access and get project +// const project = await getVerifiedAccessProject( +// ctx, +// user._id, +// args.semanticIdentifier, +// ); +// if (!project) throw new Error("Project not found"); +// console.log("[Integration Agent] Project verified:", project._id); +// return await generateAndStructure(ctx, args.userInput, project._id); +// }, +// }); + +// // Function to extract main domain from URLs and generate favicon +// function extractMainDomainAndFavicon(urls: string[]): string { +// if (!urls || urls.length === 0) return ""; + +// // Use the first URL as the main URL +// const mainUrl = urls[0]; + +// if (!mainUrl) { +// // Fallback to first non-GitHub URL +// const fallbackUrl = urls.find( +// (url) => !url.toLowerCase().includes("github.com"), +// ); +// if (fallbackUrl) { +// const urlObj = new URL(fallbackUrl); +// return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; +// } +// return ""; +// } + +// const urlObj = new URL(mainUrl); +// return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; +// } + +// export async function generateAndStructure( +// ctx: ActionCtx, +// prompt: string, +// projectId: Id<"project">, +// ) { +// console.log( +// `[Integration Agent] generateAndStructure called with prompt: "${prompt}"`, +// ); + +// // Initialize per-request web search counter +// let webSearchCount = 0; + +// const systemPrompt = `You are a deep research agent specialized in finding and extracting developer documentation for integrations. You have access to the Context7 API which provides comprehensive documentation for popular libraries and services, plus web search capabilities for supplemental research. + +// YOU MUST USE THE PROVIDED TOOLS TO RESEARCH. Do not skip research or provide generic answers. + +// Your job is to: +// 1. Use searchContext7Libraries to find relevant libraries +// 2. Use fetchContext7Documentation to get detailed documentation +// 3. Use webSearchGrounding as needed to supplement information +// 4. You can make multiple web searches if needed for comprehensive coverage - there are no artificial limits + +// SEARCH STRATEGY: +// 1. **Context7 First Approach:** +// - When user requests a specific provider, search Context7 directly for that provider name +// - When user requests generic functionality, think of the most popular providers for that functionality: +// * SMS/Text messaging → Try searching for: twilio, vonage, messagebird +// * Payments → Try searching for: stripe, square, paypal +// * Email → Try searching for: sendgrid, mailgun, postmark +// * Authentication → Try searching for: auth0, clerk, supabase +// * Database → Try searching for: mongodb, postgresql, mysql +// * Search → Try searching for: algolia, elasticsearch, typesense +// - Search Context7 using these specific provider names for better results +// - Select libraries with high trust scores and good documentation coverage + +// 2. **Context7 Sufficiency Check (MUST evaluate before using web search):** +// Context7 documentation is SUFFICIENT if it includes ALL of: +// - ✓ Installation command (npm install package-name (or pnpm, etc)) +// - ✓ Basic setup/initialization code +// - ✓ At least one working code example +// - ✓ API key/authentication setup instructions +// - ✓ Basic usage documentation + +// If Context7 has ALL 5 items → DO NOT use web search +// If Context7 missing ANY items → Make ONE comprehensive web search for ALL missing information + +// 3. **Web Search Strategy:** +// - Use web search to fill in gaps from Context7 documentation +// - Make focused searches for specific missing information: +// □ npm install command and setup +// □ TypeScript configuration +// □ Code examples and usage +// □ API key instructions +// □ User account setup +// □ Implementation guides +// - You can make multiple targeted searches for better results +// - Focus each search on specific aspects for comprehensive coverage + +// You will search for a relevant integration and provide comprehensive documentation including: +// - Installation instructions (using pnpm) +// - Setup and configuration steps +// - Code examples and usage patterns +// - API key requirements and setup +// - Complete implementation guidance + +// Your documentation and instructions should be relevant to this tech stack: +// - Language: TypeScript (ts and tsx) +// - Frontend: React +// - Backend: Convex (convex.dev) - Use Convex actions and mutations instead of raw HTTP calls. +// - Package Manager: pnpm + +// CRITICAL CONVEX-SPECIFIC REQUIREMENTS: +// - For backend operations, ALWAYS use Convex actions (not raw fetch/axios calls) +// - Should be end to end typescript code +// - However, NEVER WRITE NEW CONVEX CODE. only cite code specified originally in the documentation. + +// CRITICAL REQUIREMENTS: +// - Only include accurate information from official documentation sources +// - NEVER MAKE UP ANY NEW CODE OR EXAMPLES +// - Include complete, working code examples directly from documentation +// - Do not write custom code or examples not found in documentation +// - Focus on practical implementation details +// - Ensure all API keys and environment variables are accurately described +// - When using Context7, prioritize libraries with trust scores >= 7 and good token coverage +// - MOST IMPORTANT: The integration you return MUST match what the user requested +// - ALL INSTALLATION INSTRUCTIONS YOU WRITE MUST BE USING PNPM. Do not show npm examples; when writing instructions, they must all be using pnpm +// Be as short and concise as possible in all user instructions and descriptions. + +// The user will provide an input for the integration they want. For generic requests, research and select the best provider automatically. + +// Structure your response with these sections: +// - Title: Clear, concise integration name +// - Description: One sentence describing what it does +// - Tags: Relevant categories for searchability +// - API Keys: Exact environment variable names and obtaining instructions +// - User Instructions: Clear, step-by-step guide for API key setup and account creation. MAKE IT SHORT AND CONCISE. AS SHORT AS POSSIBLE. +// - LLM Instructions: Comprehensive technical implementation guide with code examples +// - Documentation URLs: All sources referenced +// - Main Domain: The main website domain (e.g., "twilio.com", "stripe.com") for favicon generation + +// SPECIFIC FORMATTING REQUIREMENTS FOR USER INSTRUCTIONS: +// - Write clear, numbered steps (1. 2. 3. etc.) +// - Include specific URLs where users need to go +// - Be very specific about where to find API keys (exact page names, button labels, etc.) +// - Include any verification steps required +// - Format as clean text without escape characters +// - Make it as short and concise as possible + +// CRITICAL IMPLEMENTATION NOTES: +// - Use proper newlines in all documentation (not \\n) +// - Include TypeScript types in all code examples +// - Show complete, working implementations +// - Include error handling examples +// - Provide both basic and advanced usage patterns +// - IMPORTANT: Extract the main domain from documentation URLs (e.g., from "https://docs.twilio.com" extract "twilio.com") +// - The main_domain should be the primary website domain, NOT GitHub or documentation subdomains +// `; + +// const query = ` +// Here is the integration the user would like to add: +// <integration> +// ${prompt} +// </integration> + +// RESEARCH APPROACH: +// 1. **Search Context7 First:** +// - Search for specific provider names (e.g., "twilio" not "sms provider") +// - Fetch documentation and analyze what's present vs missing + +// 2. **Analyze Context7 Coverage:** +// Evaluate what Context7 provides: +// - [ ] pnpm/yarn/npm install command? +// - [ ] Setup/initialization code? +// - [ ] Code examples? +// - [ ] API key instructions? +// - [ ] User account setup? + +// 3. **Supplement with Web Search (as needed):** +// For any missing information from Context7: +// - Make focused searches for specific gaps +// - Example searches: "Twilio Node.js installation", "Twilio API key setup", "Twilio TypeScript examples" +// - Use multiple searches to ensure comprehensive coverage + +// 4. **Extract and Structure:** +// - Combine information from Context7 and web search results +// - Ensure all required fields are properly filled +// `; + +// console.log( +// "[Integration Agent] Starting research with Context7 and web search for:", +// prompt, +// ); + +// const response = await generateText({ +// model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, +// system: systemPrompt, +// prompt: query, +// stopWhen: stepCountIs(7), +// //maxSteps: 4, // Limited to: 1) Context7 search, 2) Context7 fetch, 3) ONE web search, 4) Final processing +// toolChoice: "required", +// tools: { +// searchContext7Libraries: tool({ +// description: +// "Search Context7 API for libraries and services. For best results, search using specific provider/library names rather than generic terms.", +// inputSchema: z.object({ +// query: z +// .string() +// .describe( +// "Search query to find relevant libraries/services. Use specific provider names when possible for better results.", +// ), +// }), +// execute: async ({ query }) => { +// console.log( +// "[Integration Agent] Searching Context7 libraries with query:", +// query, +// ); + +// const result = await searchContext7Libraries(query); +// console.log( +// "[Integration Agent] Context7 search results for", +// query, +// ":", +// result.results?.length || 0, +// "results found", +// ); + +// // Log full result structure +// console.log( +// "[Integration Agent] Context7 full response structure:", +// JSON.stringify(result, null, 2).substring(0, 1000), +// ); + +// if (result.results?.length > 0) { +// console.log( +// "[Integration Agent] Top results:", +// result.results.slice(0, 3).map((r: any) => ({ +// id: r.id, +// title: r.title, +// description: r.description?.substring(0, 100), +// })), +// ); +// } else { +// console.error( +// "[Integration Agent] ERROR: No Context7 results found for query:", +// query, +// ); +// } + +// return JSON.stringify(result, null, 2); +// }, +// }), +// fetchContext7Documentation: tool({ +// description: +// "Fetch detailed documentation for a specific library from Context7. Use the library ID from search results.", +// inputSchema: z.object({ +// libraryId: z +// .string() +// .describe( +// "Library ID from Context7 search results (e.g., '/mongodb/docs', '/vercel/next.js')", +// ), +// tokens: z +// .number() +// .optional() +// .describe("Number of tokens to fetch (default: 15000)"), +// topic: z +// .string() +// .optional() +// .describe( +// "Specific topic to focus on (e.g., 'installation', 'authentication')", +// ), +// }), +// execute: async ({ libraryId, tokens = 15000, topic }) => { +// console.log( +// "[Integration Agent] Fetching Context7 documentation for:", +// libraryId, +// ); + +// const result = await fetchContext7Documentation(libraryId, { +// tokens, +// topic, +// }); + +// console.log( +// "[Integration Agent] Context7 documentation length:", +// result?.length || 0, +// ); + +// if (result) { +// // Analyze sufficiency of Context7 documentation +// const hasInstallCommand = +// /npm install|yarn add|yarn install|pnpm add|pnpm install|bun add|bun install/i.test( +// result, +// ); +// const hasCodeExample = /```(?:typescript|javascript|js|ts)/i.test( +// result, +// ); +// const hasAPIKey = /api.?key|auth.?token|secret/i.test(result); +// const hasSetup = /setup|initialize|config/i.test(result); +// const hasUserInstructions = +// /sign.?up|register|account|dashboard/i.test(result); + +// const isSufficient = +// hasInstallCommand && +// hasCodeExample && +// hasAPIKey && +// hasSetup && +// hasUserInstructions; + +// console.log("[Integration Agent] Context7 sufficiency analysis:", { +// hasInstallCommand, +// hasCodeExample, +// hasAPIKey, +// hasSetup, +// hasUserInstructions, +// isSufficient, +// }); + +// // Add sufficiency info to the result +// if (!isSufficient) { +// const missing = []; +// if (!hasInstallCommand) missing.push("install command"); +// if (!hasCodeExample) missing.push("code examples"); +// if (!hasAPIKey) missing.push("API key instructions"); +// if (!hasSetup) missing.push("setup/initialization"); +// if (!hasUserInstructions) +// missing.push("user account instructions"); + +// return ( +// result + +// `\n\n[CONTEXT7_ANALYSIS] Missing information: ${missing.join(", ")}` +// ); +// } +// } + +// return result || "No documentation available"; +// }, +// }), +// webSearchGrounding: tool({ +// description: +// "Search the web for comprehensive integration information using Exa semantic search. Can be called multiple times as needed for thorough research.", +// inputSchema: z.object({ +// query: z +// .string() +// .describe( +// "Specific search query for integration information. Focus on one aspect at a time for better results.", +// ), +// mode: z +// .enum(["supplement", "fallback"]) +// .describe( +// "Use 'supplement' when Context7 partially covers the integration. Use 'fallback' when Context7 has no results.", +// ), +// focus: z +// .string() +// .optional() +// .describe( +// "Primary focus area for the search (e.g., 'installation', 'authentication', 'examples')", +// ), +// }), +// execute: async ({ query, mode, focus }) => { +// webSearchCount++; + +// console.log( +// `[Integration Agent] Using web search grounding (${mode} mode) for:`, +// query, +// ); +// console.log( +// `[Integration Agent] Web search count: ${webSearchCount}`, +// ); + +// const systemPrompt = `You are a technical documentation researcher. ${ +// mode === "fallback" +// ? "Context7 is unavailable, so provide complete integration information." +// : "Context7 has partial information, so focus on the specific aspect requested." +// } Focus on: ${focus || "integration details"}`; + +// const searchPrompt = `Search for integration information: ${query} + +// Provide specific, actionable information for: + +// ${ +// focus === "installation" +// ? "- Exact npm/yarn/pnpm install commands\n- Dependencies and setup requirements" +// : focus === "authentication" +// ? "- API key setup and configuration\n- Authentication patterns and examples" +// : focus === "examples" +// ? "- Working code examples with TypeScript\n- Best practices and error handling" +// : "- Installation instructions\n- Authentication and setup\n- Code examples and usage patterns" +// } + +// Keep responses focused and technical. Include official documentation sources when available.`; + +// // Use Google Gemini with Google Search grounding capabilities +// const searchResponse = await generateText({ +// model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, +// system: systemPrompt, +// prompt: searchPrompt, +// tools: { +// google_search: google.tools.googleSearch({}) as any, +// }, +// }); + +// console.log( +// `[Integration Agent] Web search response length:`, +// searchResponse.text?.length || 0, +// ); +// console.log( +// `[Integration Agent] Web search preview:`, +// searchResponse.text?.substring(0, 300) || "No content", +// ); + +// return `Web Search Results (${mode} mode) for "${query}":\n\n${searchResponse.text}`; +// }, +// }), +// }, +// }); + +// console.log("Research completed, processing results..."); + +// // Log the actual research response to debug +// console.log( +// "[Integration Agent] Full response object keys:", +// Object.keys(response), +// ); +// console.log( +// "[Integration Agent] Response text length:", +// response.text?.length || 0, +// ); +// console.log( +// "[Integration Agent] Response text preview:", +// response.text?.substring(0, 500) || "No response text", +// ); + +// // Log tool calls if available +// if (response.toolCalls) { +// console.log( +// "[Integration Agent] Tool calls made:", +// response.toolCalls.length, +// ); +// } +// if (response.steps) { +// console.log("[Integration Agent] Steps taken:", response.steps.length); +// } + +// // Try to extract the actual research content +// let researchContent = response.text; + +// // If response.text is empty, try to find content in other parts of the response +// if (!researchContent || researchContent.length < 100) { +// console.error( +// "[Integration Agent] ERROR: response.text is empty or minimal", +// ); + +// // Try to extract from steps or tool results +// if (response.steps && response.steps.length > 0) { +// console.log( +// "[Integration Agent] Attempting to extract content from steps", +// ); +// // This might need adjustment based on actual response structure +// researchContent = JSON.stringify(response.steps); +// } +// } + +// // Check if we actually got research results +// if (!researchContent || researchContent.length < 100) { +// console.error( +// "[Integration Agent] ERROR: Research returned empty or minimal content", +// ); +// throw new Error("Integration research failed - no documentation found"); +// } + +// const model = "gemini-2.0-flash"; +// const initialCitations: string[] = []; + +// const resolvedCitations = await Promise.all( +// initialCitations.map(resolveRedirectUrl), +// ); +// console.log("Resolved citations:", resolvedCitations); + +// const citationValidationResults = await Promise.all( +// resolvedCitations.map(isValidUrl), +// ); +// const citations = resolvedCitations.filter( +// (_, index) => citationValidationResults[index], +// ); + +// console.log("Citations:", citations); + +// const user = await getAuthUser(ctx); + +// if (user?._id) { +// const searchLog = +// (await ctx.runMutation(internal.search.insertSearchLog, { +// projectId: projectId, +// userId: user?._id, +// query: query, +// response: JSON.stringify(researchContent), +// model: model, +// citations: citations, +// })) || undefined; +// } + +// const objectResponse = await generateObject({ +// model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, +// schema: generatedIntegration, +// prompt: `You are an expert at structuring developer integration documentation from research results. + +// Extract and structure ONLY the integration that was actually researched in the content below. + +// The research was conducted for: "${prompt}" + +// Extract and structure the research findings into this exact format: + +// { +// "title": "string", +// "description": "string", +// "tags": ["string"], +// "env_variables": [ +// { +// "id": "string", +// "description": "string" +// } +// ], +// "user_instructions": "string", +// "llm_instructions": "string", +// "documentation_urls": ["string"], +// "main_domain": "string" +// } + +// Field Requirements: +// - Title: Use the actual integration/library name from the research +// - Description: One sentence describing what the integration does +// - Tags: Relevant categories based on the research +// - Environment Variables: Extract the actual env vars mentioned in the research +// - User Instructions: Clear steps from the research for setup and API key configuration +// - LLM Instructions: Technical implementation details from the research +// - Documentation URLs: Official documentation links from the research +// - Main Domain: Extract the main website domain from documentation URLs (e.g., "twilio.com" from "https://docs.twilio.com") + +// Research Content: +// <research> +// ${researchContent} +// </research> + +// IMPORTANT: +// - Extract information ONLY from the research content provided +// - If the research content appears empty or lacks sufficient information, throw an error +// - The title should be the actual library/service name followed by "Integration" (e.g., "Twilio Integration", "Stripe Integration") +// - Ensure all code examples and instructions come directly from the research +// - For main_domain, extract the primary website domain, not GitHub or documentation subdomains (e.g., "twilio.com" not "docs.twilio.com" or "github.com/twilio/twilio-node") + +// `, +// }); + +// const generatedObject = objectResponse.object; + +// if (generatedObject.documentation_urls) { +// const validationResults = await Promise.all( +// generatedObject.documentation_urls.map(isValidUrl), +// ); +// generatedObject.documentation_urls = +// generatedObject.documentation_urls.filter( +// (_, index) => validationResults[index], +// ); +// } + +// // Generate favicon URL from AI-provided main_domain or documentation URLs +// let faviconUrl = ""; +// if (generatedObject.main_domain) { +// faviconUrl = `https://www.google.com/s2/favicons?domain=${generatedObject.main_domain}&sz=32`; +// } else { +// faviconUrl = extractMainDomainAndFavicon( +// generatedObject.documentation_urls, +// ); +// } +// console.log("[Integration Agent] Generated favicon URL:", faviconUrl); + +// console.log( +// "[Integration Agent] Generated integration:", +// generatedObject.title, +// ); + +// // Return the integration with the favicon URL (excluding main_domain from database save) +// const { main_domain, ...integrationWithoutMainDomain } = generatedObject; +// return { +// ...integrationWithoutMainDomain, +// cover_image: faviconUrl, +// }; +// } diff --git a/freebuff/web/convex/integrations/research_agent.ts b/freebuff/web/convex/integrations/research_agent.ts new file mode 100644 index 0000000000..f6c15d7f5a --- /dev/null +++ b/freebuff/web/convex/integrations/research_agent.ts @@ -0,0 +1,111 @@ +"use node"; + +import { ActionCtx } from "../_generated/server"; +import { perplexity } from "../utils/perplexity"; +import { Doc, Id } from "!/_generated/dataModel"; +import { internal } from "!/_generated/api"; + +/** + * DEPRECATED: Use addIntegration instead + */ +export async function researchIntegration( + ctx: ActionCtx, + args: { + threadId: Id<"thread">; + query: string; + integrationReferenceId?: string; + deepResearch?: boolean; + }, +) { + let integration: Doc<"integration"> | undefined; + if (args.integrationReferenceId) { + integration = await ctx.runMutation( + internal.thread.addIntegrationIdToContext, + { + threadId: args.threadId, + integrationReferenceId: args.integrationReferenceId, + }, + ); + } + + // Enhanced system prompt that prioritizes cached Context7 data + const systemPrompt = `You are an expert integration support agent with access to comprehensive cached documentation and deep web search capabilities. + + RESEARCH STRATEGY (Follow this priority order): + 1. **Primary Source: Cached Context7 Documentation** - Use the provided integration's LLM instructions, user instructions, and documentation URLs first + 2. **Secondary Source: Deep Web Search** - Supplement with EXA deep research for additional context, recent updates, or missing information + 3. **Focus on Practical Solutions** - Provide actionable code examples, troubleshooting steps, and implementation guidance + + ANSWER REQUIREMENTS: + - Start with information from the cached integration documentation + - Supplement with web search findings when helpful + - Provide specific, actionable solutions with code examples + - Reference both cached documentation URLs and any additional sources found + - Focus on TypeScript/JavaScript solutions + - Be comprehensive but concise + + When cached integration data is available, treat it as the authoritative source for: + - Implementation instructions and code patterns + - Environment variable setup + - API configuration and authentication + - Official documentation references + + Use web search to supplement with: + - Recent updates or changes not in cached data + - Additional code examples and use cases + - Troubleshooting for specific error scenarios + - Community best practices and tips`; + + // Enhanced query that includes Context7 cached data prominently + const query = ` + User Question: ${args.query} + + ${ + integration + ? ` + CACHED INTEGRATION DOCUMENTATION (Primary Source): + + **${integration.title}** + ${integration.description} + + **Implementation Instructions (from Context7 cache):** + ${integration.llm_instructions} + + **User Setup Instructions:** + ${integration.user_instructions} + + **Environment Variables:** + ${JSON.stringify(integration.env_variables, null, 2)} + + **Official Documentation URLs:** + ${integration.documentation_urls.join("\n")} + + **Additional Notes:** + ${integration.human_added_notes || "None"} + + Please answer the user's question using this cached Context7 documentation as your primary source. If you need additional information beyond what's cached, supplement with web search. + ` + : ` + No specific integration context provided. Use deep web research to answer the user's question about integrations or development topics. + ` + }`; + + console.log( + "[Research Agent] Processing query with Context7 cache + EXA search:", + args.query, + ); + + const response = await perplexity( + query, + args.deepResearch ? "sonar-deep-research" : "sonar-pro", + systemPrompt, + undefined, + ctx, + ); + + console.log( + "[Research Agent] Research completed using cached Context7 data + EXA", + ); + + return response; +} diff --git a/freebuff/web/convex/invites.ts b/freebuff/web/convex/invites.ts new file mode 100644 index 0000000000..264d7a8234 --- /dev/null +++ b/freebuff/web/convex/invites.ts @@ -0,0 +1,517 @@ +import { v } from "convex/values"; +import { Resend } from "resend"; +import { internal } from "./_generated/api"; +import { Doc } from "./_generated/dataModel"; +import { + action, + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { getAuthUser } from "./users"; +import { getVerifiedAccessProject } from "./project"; + +const FREEBUFF_FROM_EMAIL = "James from Freebuff <james@mail.freebuff.app>"; +const FREEBUFF_REPLY_TO_EMAIL = "support@codebuff.com"; + +function generateInviteToken(): string { + return Math.random().toString(36).substring(2) + Date.now().toString(36); +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +async function sendEmail(to: string, subject: string, body: string) { + const resend = new Resend(process.env.RESEND_API_KEY); + + console.log(`Sending email to ${to}: ${subject}`); + console.log(body); + + const { data, error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [to], + subject: subject, + html: body, + }); + + if (error) { + console.error(error); + } + + return data; +} + +export const sendInvite = action({ + args: { + projectId: v.id("project"), + email: v.string(), + }, + handler: async (ctx, args) => { + // Get project owner information to check member limits + const ownerInfo = await ctx.runQuery(internal.project.getProjectOwner, { + projectId: args.projectId, + }); + + if (!ownerInfo) { + throw new Error("Project owner not found"); + } + + // Determine customer ID (organization_id or user clerk_id) + let customerId: string; + if (ownerInfo.type === "organization") { + customerId = ownerInfo.organization_id; + } else { + customerId = ownerInfo.user.clerk_id; + } + + if (!customerId) { + throw new Error("Customer ID not found for billing check"); + } + + // Get total member count across all user's projects (not per-project) + const [totalMemberCount, pendingInvites] = await Promise.all([ + ctx.runQuery(internal.invites.getTotalMemberCountForOwner, { + clerkId: customerId, + }), + ctx.runQuery(internal.invites.getInvitesByProjectInternal, { + projectId: args.projectId, + }), + ]); + + void totalMemberCount; + void pendingInvites; + void customerId; + + // Create invite (this will also verify access) + const { token } = await ctx.runMutation(internal.invites.createInvite, { + projectId: args.projectId, + email: args.email, + }); + + // Get project name + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) throw new Error("Project not found"); + + const projectName = project.name || "Untitled Project"; + + // Send invite email with project name + await sendEmail( + args.email, + `Invitation to collaborate on ${projectName.replace("\n", " ")}`, + `You've been invited to collaborate on "${projectName}". Click here to accept: ${process.env.PUBLIC_APP_URL}/invite/${token}`, + ); + + const inviter = await getAuthUser(ctx); + if (inviter) { + await ctx.scheduler.runAfter( + 0, + internal.cloud_feature_usage.recordCloudFeatureUsage, + { userId: inviter._id, feature: "invite", projectId: args.projectId }, + ); + } + }, +}); + +export const createInvite = internalMutation({ + args: { + projectId: v.id("project"), + email: v.string(), + projectRole: v.optional( + v.union(v.literal("member"), v.literal("admin"), v.literal("owner")), + ), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + if (user.role !== "god" && user.role !== "admin") { + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + ); + + if (!project) { + throw new Error("Project not found"); + } + } else { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + } + + // Create invite + const token = generateInviteToken(); + const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7 days + + const inviteId = await ctx.db.insert("invites", { + project: args.projectId, + email: normalizeEmail(args.email), + token, + expires_at: expiresAt, + project_role: args.projectRole ?? "member", + }); + + return { token: token, inviteId: inviteId }; + }, +}); + +export const deleteProjectInvitesByEmailInternal = internalMutation({ + args: { + projectId: v.id("project"), + email: v.string(), + }, + handler: async (ctx, args) => { + const normalizedEmail = normalizeEmail(args.email); + const projectInvites = await ctx.db + .query("invites") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + const matchingInvites = projectInvites.filter( + (invite) => invite.email === normalizedEmail, + ); + + for (const invite of matchingInvites) { + await ctx.db.delete(invite._id); + } + + return { deletedCount: matchingInvites.length }; + }, +}); + +export const deleteProjectOwnershipInvitesInternal = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const projectInvites = await ctx.db + .query("invites") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + const ownershipInvites = projectInvites.filter( + (invite) => invite.project_role === "owner", + ); + + for (const invite of ownershipInvites) { + await ctx.db.delete(invite._id); + } + + return { deletedCount: ownershipInvites.length }; + }, +}); + +export const transferProjectOwnershipByEmail = action({ + args: { + projectId: v.id("project"), + email: v.string(), + }, + handler: async ( + ctx, + args, + ): Promise<{ + status: "transferred" | "invite_sent"; + email: string; + semanticIdentifier: string; + }> => { + const user = await getAuthUser(ctx); + + if (!user || (user.role !== "god" && user.role !== "admin")) { + throw new Error("Unauthorized: Admin access required"); + } + + const normalizedEmail = normalizeEmail(args.email); + if (!normalizedEmail) { + throw new Error("Email is required"); + } + + const project: Doc<"project"> | null = await ctx.runQuery( + internal.project.getProject, + { + projectId: args.projectId, + }, + ); + + if (!project) { + throw new Error("Project not found"); + } + + const currentMembers: Doc<"project_member">[] = await ctx.runQuery( + internal.project.getProjectMembersInternal, + { projectId: args.projectId }, + ); + const existingUser = await ctx.runQuery(internal.users.getUserByEmail, { + email: normalizedEmail, + }); + + if (existingUser) { + const currentOwnerMemberships = currentMembers.filter( + (member: Doc<"project_member">) => member.project_role === "owner", + ); + const existingMembership = currentMembers.find( + (member: Doc<"project_member">) => member.user === existingUser._id, + ); + + if ( + existingMembership?.project_role === "owner" && + currentOwnerMemberships.length === 1 + ) { + throw new Error("That email already owns this project"); + } + + await ctx.runMutation(internal.project.transferProjectOwnershipInternal, { + projectId: args.projectId, + newOwnerUserId: existingUser._id, + }); + await ctx.runMutation( + internal.invites.deleteProjectOwnershipInvitesInternal, + { + projectId: args.projectId, + }, + ); + await ctx.runMutation( + internal.invites.deleteProjectInvitesByEmailInternal, + { + projectId: args.projectId, + email: normalizedEmail, + }, + ); + + return { + status: "transferred" as const, + email: normalizedEmail, + semanticIdentifier: project.semantic_identifier, + }; + } + + await ctx.runMutation( + internal.invites.deleteProjectOwnershipInvitesInternal, + { + projectId: args.projectId, + }, + ); + await ctx.runMutation( + internal.invites.deleteProjectInvitesByEmailInternal, + { + projectId: args.projectId, + email: normalizedEmail, + }, + ); + + const { token } = await ctx.runMutation(internal.invites.createInvite, { + projectId: args.projectId, + email: normalizedEmail, + projectRole: "owner", + }); + + const projectName = project.name || "Untitled Project"; + + await sendEmail( + normalizedEmail, + `Invitation to take ownership of ${projectName.replace("\n", " ")}`, + `You've been invited to take ownership of "${projectName}". Click here to accept: ${process.env.PUBLIC_APP_URL}/invite/${token}`, + ); + + return { + status: "invite_sent" as const, + email: normalizedEmail, + semanticIdentifier: project.semantic_identifier, + }; + }, +}); + +export const acceptInvite = mutation({ + args: { + token: v.string(), + }, + handler: async (ctx, args) => { + console.log("AcceptInvite called with token:", args.token); + + const user = await getAuthUser(ctx); + console.log("User from auth:", user?._id); + if (!user) throw new Error("Not authenticated - please sign in first"); + + console.log("Searching for invite with token:", args.token); + const invite = await ctx.db + .query("invites") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .unique(); + + console.log("Found invite:", invite); + if (!invite) + throw new Error( + "Invalid invite token - the invitation may have been used or deleted", + ); + + if (invite.expires_at && invite.expires_at < Date.now()) { + console.log( + "Invite expired. Expires at:", + new Date(invite.expires_at), + "Current time:", + new Date(), + ); + throw new Error("Invite expired - please request a new invitation"); + } + + // Get project to return its semantic identifier + console.log("Getting project with ID:", invite.project); + const projectRecord = await ctx.db.get(invite.project); + console.log("Found project:", projectRecord); + if (!projectRecord) + throw new Error("Project not found - the project may have been deleted"); + + const inviteRole = invite.project_role ?? "member"; + + // Check if user is already a member + const existingMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", invite.project).eq("user", user._id), + ) + .unique(); + + if (existingMember) { + console.log("User already a member, deleting invite"); + if (inviteRole === "owner") { + await ctx.runMutation( + internal.project.transferProjectOwnershipInternal, + { + projectId: invite.project, + newOwnerUserId: user._id, + }, + ); + } + await ctx.db.delete(invite._id); + return projectRecord.semantic_identifier; + } + + console.log("Adding user as project member"); + try { + if (inviteRole === "owner") { + await ctx.runMutation( + internal.project.transferProjectOwnershipInternal, + { + projectId: invite.project, + newOwnerUserId: user._id, + }, + ); + } else { + await ctx.db.insert("project_member", { + project: invite.project, + user: user._id, + project_role: inviteRole, + }); + } + console.log("Successfully added user as member"); + } catch (error) { + console.error("Error adding user as member:", error); + throw new Error("Failed to add user to project - please try again"); + } + + // Delete the invite + console.log("Deleting invite"); + try { + await ctx.db.delete(invite._id); + console.log("Successfully deleted invite"); + } catch (error) { + console.error("Error deleting invite:", error); + // Don't throw here as the user was already added + } + + console.log( + "Returning semantic identifier:", + projectRecord.semantic_identifier, + ); + return projectRecord.semantic_identifier; + }, +}); + +export const getInvitesByProject = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, // projectSemanticIdentifier + args.projectId, // projectId + ); + if (!project) throw new Error("Project not found"); + + return await ctx.db + .query("invites") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + }, +}); + +/** + * Get total member count across all projects owned by a user (identified by clerk_id) + * This counts all memberships across all projects, not unique users + */ +export const getTotalMemberCountForOwner = internalQuery({ + args: { + clerkId: v.string(), + }, + handler: async (ctx, args) => { + // Find the user by clerk_id + const user = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerk_id", args.clerkId)) + .first(); + + if (!user) { + return 0; + } + + // Get all projects where this user is the owner + const ownedProjectMemberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .collect(); + + // For each owned project, count all members (excluding the owner) + let totalMemberCount = 0; + for (const ownership of ownedProjectMemberships) { + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", ownership.project)) + .filter((q) => q.neq(q.field("project_role"), "owner")) + .collect(); + totalMemberCount += projectMembers.length; + } + + return totalMemberCount; + }, +}); + +/** + * Internal version of getInvitesByProject for use in actions + * Does not require authentication - used for checking limits + */ +export const getInvitesByProjectInternal = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("invites") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + }, +}); diff --git a/freebuff/web/convex/issue_reports.ts b/freebuff/web/convex/issue_reports.ts new file mode 100644 index 0000000000..d36102da8b --- /dev/null +++ b/freebuff/web/convex/issue_reports.ts @@ -0,0 +1,350 @@ +import { v } from 'convex/values' +import { internal } from './_generated/api' +import { + internalMutation, + internalQuery, + mutation, + query, +} from './_generated/server' +import { getAuthUser } from './users' +import type { Id } from './_generated/dataModel' + +const issueReportStatusValidator = v.union( + v.literal('open'), + v.literal('reviewing'), + v.literal('resolved'), +) + +const emailSendStatusValidator = v.union( + v.literal('pending'), + v.literal('sent'), + v.literal('failed'), +) + +const reportTypeValidator = v.union( + v.literal('bug'), + v.literal('feature_request'), +) + +export const issueCategoryValidator = v.union( + v.literal('agent_response'), + v.literal('ui_ux'), + v.literal('deployment'), + v.literal('previews'), + v.literal('github_sync'), + v.literal('integrations'), + v.literal('backend'), + v.literal('other'), +) + +// Anti-abuse: cap combined bug reports + feature requests per user per day. +export const MAX_ISSUE_REPORTS_PER_DAY = 2 +const ISSUE_REPORT_WINDOW_MS = 24 * 60 * 60 * 1000 + +const CATEGORY_LABELS: Record<string, string> = { + agent_response: 'Agent Response', + ui_ux: 'UI/UX', + deployment: 'Deployment', + previews: 'Previews', + github_sync: 'Github Sync', + integrations: 'Integrations', + backend: 'Backend', + other: 'Other', +} + +export function issueCategoryLabel(category: string | undefined): string { + if (!category) return 'Other' + return CATEGORY_LABELS[category] ?? category +} + +async function countRecentReports( + ctx: { db: any }, + userId: any, +): Promise<number> { + const cutoff = Date.now() - ISSUE_REPORT_WINDOW_MS + const recent = await ctx.db + .query('issue_reports') + .withIndex('by_user', (q: any) => q.eq('userId', userId)) + .collect() + return recent.filter((r: any) => r.submittedAt >= cutoff).length +} + +function truncateContent(value: string, maxLength: number): string { + const trimmed = value.trim() + if (trimmed.length <= maxLength) return trimmed + return `${trimmed.slice(0, maxLength)}…` +} + +function getAgentAssistantText( + assistantStream: + | Array<{ type: string; content: string }> + | undefined, +): string { + return (assistantStream ?? []) + .filter((item) => item.type === 'text' || item.type === 'assistant') + .map((item) => item.content) + .join('') + .trim() +} + +export const getRecentThreadContext = internalQuery({ + args: { + source: v.union(v.literal('chat'), v.literal('cloud')), + threadId: v.optional(v.string()), + limit: v.optional(v.number()), + }, + returns: v.array( + v.object({ + role: v.string(), + content: v.string(), + date: v.optional(v.number()), + }), + ), + handler: async (ctx, args) => { + if (!args.threadId) return [] + + const limit = Math.min(args.limit ?? 6, 10) + + if (args.source === 'cloud') { + const messages = await ctx.db + .query('agent_message') + .withIndex('by_thread_active', (q) => + q + .eq('thread_id', args.threadId as Id<'agent_thread'>) + .eq('isStreaming', false) + .eq('deactivated', false), + ) + .order('desc') + .take(limit) + + const context: Array<{ + role: string + content: string + date?: number + }> = [] + + for (const message of messages.reverse()) { + if (message.user_message?.trim()) { + context.push({ + role: 'user', + content: truncateContent(message.user_message, 800), + date: message._creationTime, + }) + } + const assistantText = getAgentAssistantText(message.assistant_stream) + if (assistantText) { + context.push({ + role: 'assistant', + content: truncateContent(assistantText, 800), + date: message._creationTime, + }) + } + } + + return context.slice(-limit) + } + + const messages = await ctx.db + .query('messages') + .withIndex('by_thread', (q) => + q + .eq('thread_id', args.threadId as Id<'thread'>) + .eq('streaming', false), + ) + .order('desc') + .filter((q) => q.neq(q.field('deactivated'), true)) + .take(limit) + + return messages + .reverse() + .map((message) => ({ + role: message.role, + content: truncateContent( + message.core_message || message.content || '', + 800, + ), + date: message.date ?? message._creationTime, + })) + .filter((message) => message.content.length > 0) + }, +}) + +export const getScreenshotUrls = internalQuery({ + args: { + screenshotIds: v.array(v.id('_storage')), + }, + returns: v.array(v.union(v.string(), v.null())), + handler: async (ctx, args) => { + return await Promise.all( + args.screenshotIds.map((id) => ctx.storage.getUrl(id)), + ) + }, +}) + +export const insertIssueReport = internalMutation({ + args: { + userId: v.id('users'), + recordedUserName: v.optional(v.string()), + recordedUserEmail: v.optional(v.string()), + replyEmail: v.string(), + reportType: reportTypeValidator, + severity: v.number(), + issue: v.string(), + category: v.optional(issueCategoryValidator), + reproductionSteps: v.optional(v.string()), + additionalLogs: v.optional(v.string()), + screenshotIds: v.optional(v.array(v.id('_storage'))), + source: v.union(v.literal('chat'), v.literal('cloud')), + pageUrl: v.optional(v.string()), + userAgent: v.optional(v.string()), + threadId: v.optional(v.string()), + projectId: v.optional(v.id('project')), + projectSemanticIdentifier: v.optional(v.string()), + }, + returns: v.id('issue_reports'), + handler: async (ctx, args) => { + const recentCount = await countRecentReports(ctx, args.userId) + if (recentCount >= MAX_ISSUE_REPORTS_PER_DAY) { + throw new Error('RATE_LIMITED') + } + const reportId = await ctx.db.insert('issue_reports', { + ...args, + status: 'open', + submittedAt: Date.now(), + emailSendStatus: 'pending', + }) + + // Automated bug-fixer pipeline: AI triage, then (if approved) a queued + // Codex run against the configured cloud project. Scheduled so a triage + // failure can never break report submission. No-ops unless the bot is + // enabled in /web/admin/bug-fixer. + await ctx.scheduler.runAfter( + 0, + internal.bug_fixer.triage.triageIssueReport, + { reportId }, + ) + + return reportId + }, +}) + +export const generateUploadUrl = mutation({ + args: {}, + returns: v.string(), + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + return await ctx.storage.generateUploadUrl() + }, +}) + +// Lets the client show the Discord fallback before the user fills out the form. +export const getDailyQuota = query({ + args: {}, + returns: v.object({ + used: v.number(), + limit: v.number(), + remaining: v.number(), + }), + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + return { + used: 0, + limit: MAX_ISSUE_REPORTS_PER_DAY, + remaining: MAX_ISSUE_REPORTS_PER_DAY, + } + } + const used = await countRecentReports(ctx, user._id) + return { + used, + limit: MAX_ISSUE_REPORTS_PER_DAY, + remaining: Math.max(0, MAX_ISSUE_REPORTS_PER_DAY - used), + } + }, +}) + +export const updateEmailStatus = internalMutation({ + args: { + reportId: v.id('issue_reports'), + emailSendStatus: emailSendStatusValidator, + emailError: v.optional(v.string()), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.reportId, { + emailSendStatus: args.emailSendStatus, + emailSentAt: args.emailSendStatus === 'sent' ? Date.now() : undefined, + emailError: args.emailError, + }) + return null + }, +}) + +export const listAll = query({ + args: { + status: v.optional(issueReportStatusValidator), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user || (user.role !== 'god' && user.role !== 'admin')) { + throw new Error('Unauthorized: Admin access required') + } + + const reports = + args.status !== undefined + ? await ctx.db + .query('issue_reports') + .withIndex('by_status', (q) => q.eq('status', args.status!)) + .collect() + : await ctx.db.query('issue_reports').collect() + + const reportsWithDetails = await Promise.all( + reports.map(async (report) => { + const reportUser = await ctx.db.get(report.userId) + const project = report.projectId + ? await ctx.db.get(report.projectId) + : null + const screenshotUrls = report.screenshotIds + ? await Promise.all( + report.screenshotIds.map((id) => ctx.storage.getUrl(id)), + ) + : [] + return { + ...report, + userName: + report.recordedUserName || + reportUser?.name || + reportUser?.email || + 'Unknown User', + userEmail: report.recordedUserEmail || reportUser?.email, + projectName: project?.name || project?.semantic_identifier, + screenshotUrls: screenshotUrls.filter( + (url): url is string => typeof url === 'string', + ), + } + }), + ) + + return reportsWithDetails.sort((a, b) => b.submittedAt - a.submittedAt) + }, +}) + +export const updateStatus = mutation({ + args: { + reportId: v.id('issue_reports'), + status: issueReportStatusValidator, + }, + returns: v.null(), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user || (user.role !== 'god' && user.role !== 'admin')) { + throw new Error('Unauthorized: Admin access required') + } + + await ctx.db.patch(args.reportId, { status: args.status }) + return null + }, +}) diff --git a/freebuff/web/convex/issue_reports_email.ts b/freebuff/web/convex/issue_reports_email.ts new file mode 100644 index 0000000000..68ecf1580f --- /dev/null +++ b/freebuff/web/convex/issue_reports_email.ts @@ -0,0 +1,360 @@ +'use node' + +import { Resend } from 'resend' +import { v } from 'convex/values' +import { action } from './_generated/server' +import { internal } from './_generated/api' +import { + issueCategoryLabel, + issueCategoryValidator, +} from './issue_reports' +import { getAuthUser } from './users' +import type { Id } from './_generated/dataModel' + +const FREEBUFF_FROM_EMAIL = 'Freebuff Reports <james@mail.freebuff.app>' +const ISSUE_REPORT_RECIPIENTS = [ + 'victor@codebuff.com', + 'victor@vly.ai', + 'harsh@vly.ai', +] +const MAX_ISSUE_LENGTH = 5000 +const MAX_REPRODUCTION_LENGTH = 5000 +const MAX_LOGS_LENGTH = 10000 + +type SubmitIssueReportResult = { + reportId: Id<'issue_reports'> + emailSent: boolean + emailError?: string +} + +type ThreadContextMessage = { + role: string + content: string + date?: number +} + +function clampSeverity(severity: number): number { + if (!Number.isFinite(severity)) return 0 + return Math.min(10, Math.max(0, Math.round(severity))) +} + +function truncate(value: string, maxLength: number): string { + return value.length > maxLength ? value.slice(0, maxLength) : value +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function isLikelyEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) +} + +function projectLink( + source: 'chat' | 'cloud', + semanticIdentifier?: string, + pageUrl?: string, +): string | null { + if (pageUrl) return pageUrl + if (!semanticIdentifier) return null + const base = + source === 'cloud' + ? `https://freebuff.com/cloud/project/${semanticIdentifier}` + : `https://freebuff.com/web/project/${semanticIdentifier}` + return base +} + +function formatThreadContext(messages: ThreadContextMessage[]): string[] { + if (messages.length === 0) return [] + return [ + '', + 'Recent thread messages:', + ...messages.map((message) => { + const timestamp = message.date + ? new Date(message.date).toISOString() + : 'unknown time' + return `[${message.role} @ ${timestamp}]\n${message.content}` + }), + ] +} + +function renderThreadContextHtml(messages: ThreadContextMessage[]): string { + if (messages.length === 0) return '' + return ` + <h3 style="margin: 20px 0 8px;">Recent thread messages</h3> + ${messages + .map((message) => { + const timestamp = message.date + ? new Date(message.date).toLocaleString() + : 'unknown time' + return `<div style="margin-bottom: 10px; border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px; background: #fafafa;"> + <div style="font-size: 12px; color: #6b7280; margin-bottom: 6px;"><strong>${escapeHtml(message.role)}</strong> · ${escapeHtml(timestamp)}</div> + <div style="white-space: pre-wrap; font-size: 13px;">${escapeHtml(message.content)}</div> + </div>` + }) + .join('')} + ` +} + +export const submitIssueReport = action({ + args: { + replyEmail: v.string(), + reportType: v.union(v.literal('bug'), v.literal('feature_request')), + severity: v.number(), + issue: v.string(), + category: v.optional(issueCategoryValidator), + reproductionSteps: v.optional(v.string()), + additionalLogs: v.optional(v.string()), + screenshotIds: v.optional(v.array(v.id('_storage'))), + source: v.union(v.literal('chat'), v.literal('cloud')), + pageUrl: v.optional(v.string()), + userAgent: v.optional(v.string()), + threadId: v.optional(v.string()), + projectId: v.optional(v.id('project')), + projectSemanticIdentifier: v.optional(v.string()), + }, + returns: v.object({ + reportId: v.id('issue_reports'), + emailSent: v.boolean(), + emailError: v.optional(v.string()), + }), + handler: async (ctx, args): Promise<SubmitIssueReportResult> => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + + const replyEmail = args.replyEmail.trim().toLowerCase() + if (!isLikelyEmail(replyEmail)) { + throw new Error('Enter a valid reply email') + } + + const issue = truncate(args.issue.trim(), MAX_ISSUE_LENGTH) + if (!issue) { + throw new Error('Describe the issue before sending') + } + + const reproductionSteps = args.reproductionSteps + ? truncate(args.reproductionSteps.trim(), MAX_REPRODUCTION_LENGTH) + : undefined + const additionalLogs = args.additionalLogs + ? truncate(args.additionalLogs.trim(), MAX_LOGS_LENGTH) + : undefined + const screenshotIds = args.screenshotIds ?? [] + + if (args.reportType === 'bug') { + if (!args.category) { + throw new Error('Select a bug category') + } + if (!reproductionSteps) { + throw new Error('Describe how to reproduce the bug') + } + if (screenshotIds.length === 0) { + throw new Error('Attach at least one screenshot of the issue') + } + } + + const severity = clampSeverity(args.severity) + const reportId: Id<'issue_reports'> = await ctx.runMutation( + internal.issue_reports.insertIssueReport, + { + userId: user._id, + recordedUserName: user.name, + recordedUserEmail: user.email, + replyEmail, + reportType: args.reportType, + severity, + issue, + category: args.category, + reproductionSteps, + additionalLogs, + screenshotIds: screenshotIds.length > 0 ? screenshotIds : undefined, + source: args.source, + pageUrl: args.pageUrl, + userAgent: args.userAgent, + threadId: args.threadId, + projectId: args.projectId, + projectSemanticIdentifier: args.projectSemanticIdentifier, + }, + ) + + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) { + const emailError = 'RESEND_API_KEY not configured' + await ctx.runMutation(internal.issue_reports.updateEmailStatus, { + reportId, + emailSendStatus: 'failed', + emailError, + }) + return { reportId, emailSent: false, emailError } + } + + const [recentMessages, screenshotUrls] = await Promise.all([ + ctx.runQuery(internal.issue_reports.getRecentThreadContext, { + source: args.source, + threadId: args.threadId, + limit: 6, + }), + screenshotIds.length > 0 + ? ctx.runQuery(internal.issue_reports.getScreenshotUrls, { + screenshotIds, + }) + : Promise.resolve([]), + ]) + + const validScreenshotUrls = screenshotUrls.filter( + (url): url is string => typeof url === 'string' && url.length > 0, + ) + + const reportLabel = + args.reportType === 'feature_request' ? 'Feature request' : 'Bug report' + const scaleLabel = + args.reportType === 'feature_request' ? 'Urgency' : 'Severity' + const categoryLabel = + args.reportType === 'bug' + ? issueCategoryLabel(args.category) + : undefined + const link = projectLink( + args.source, + args.projectSemanticIdentifier, + args.pageUrl, + ) + + const subjectParts = [ + `[Freebuff ${reportLabel.toLowerCase()} ${severity}/10]`, + categoryLabel ?? args.source, + ] + const subject = subjectParts.join(' · ') + + const contextLines = [ + `Type: ${reportLabel}`, + categoryLabel ? `Category: ${categoryLabel}` : null, + `${scaleLabel}: ${severity}/10`, + `Source: ${args.source}`, + `Reply email: ${replyEmail}`, + `User: ${user.name || 'Unknown'} <${user.email || 'unknown'}>`, + args.projectSemanticIdentifier + ? `Project: ${args.projectSemanticIdentifier}` + : null, + link ? `Project link: ${link}` : null, + args.threadId ? `Thread: ${args.threadId}` : null, + args.pageUrl ? `Page URL: ${args.pageUrl}` : null, + args.userAgent ? `User agent: ${args.userAgent}` : null, + ].filter(Boolean) + + const detailSections = + args.reportType === 'bug' + ? [ + '', + 'What is the bug?', + issue, + '', + 'How to reproduce:', + reproductionSteps ?? '', + additionalLogs + ? '' + : null, + additionalLogs ? 'Additional logs / context:' : null, + additionalLogs ?? null, + ].filter((line): line is string => line !== null) + : ['', 'Request:', issue] + + const screenshotLines = + validScreenshotUrls.length > 0 + ? [ + '', + 'Screenshots:', + ...validScreenshotUrls.map((url, index) => `${index + 1}. ${url}`), + ] + : [] + + const text = [ + `New Freebuff ${reportLabel.toLowerCase()}`, + '', + ...contextLines, + ...detailSections, + ...screenshotLines, + ...formatThreadContext(recentMessages), + ].join('\n') + + const html = ` + <div style="font-family: Arial, sans-serif; line-height: 1.5; color: #111;"> + <h2 style="margin: 0 0 12px;">New Freebuff ${escapeHtml(reportLabel.toLowerCase())}</h2> + <table style="border-collapse: collapse; margin-bottom: 16px;"> + ${contextLines + .map((line) => { + const [label, ...rest] = String(line).split(': ') + const value = rest.join(': ') + const linkedValue = + label === 'Project link' && value + ? `<a href="${escapeHtml(value)}" style="color: #2563eb;">${escapeHtml(value)}</a>` + : escapeHtml(value) + return `<tr><td style="padding: 3px 12px 3px 0; color: #555; vertical-align: top;">${escapeHtml(label)}</td><td style="padding: 3px 0;">${linkedValue}</td></tr>` + }) + .join('')} + </table> + ${ + args.reportType === 'bug' + ? ` + <h3 style="margin: 0 0 8px;">What is the bug?</h3> + <div style="white-space: pre-wrap; border: 1px solid #ddd; border-radius: 8px; padding: 12px; background: #fafafa; margin-bottom: 16px;">${escapeHtml(issue)}</div> + <h3 style="margin: 0 0 8px;">How to reproduce</h3> + <div style="white-space: pre-wrap; border: 1px solid #ddd; border-radius: 8px; padding: 12px; background: #fafafa; margin-bottom: 16px;">${escapeHtml(reproductionSteps ?? '')}</div> + ${ + additionalLogs + ? `<h3 style="margin: 0 0 8px;">Additional logs / context</h3> + <div style="white-space: pre-wrap; border: 1px solid #ddd; border-radius: 8px; padding: 12px; background: #fafafa; margin-bottom: 16px;">${escapeHtml(additionalLogs)}</div>` + : '' + } + ` + : `<h3 style="margin: 0 0 8px;">Request</h3> + <div style="white-space: pre-wrap; border: 1px solid #ddd; border-radius: 8px; padding: 12px; background: #fafafa; margin-bottom: 16px;">${escapeHtml(issue)}</div>` + } + ${ + validScreenshotUrls.length > 0 + ? `<h3 style="margin: 0 0 8px;">Screenshots</h3> + <div style="display: grid; gap: 12px; margin-bottom: 16px;"> + ${validScreenshotUrls + .map( + (url) => + `<a href="${escapeHtml(url)}" target="_blank" rel="noreferrer"><img src="${escapeHtml(url)}" alt="Bug screenshot" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;" /></a>`, + ) + .join('')} + </div>` + : '' + } + ${renderThreadContextHtml(recentMessages)} + </div> + ` + + const resend = new Resend(apiKey) + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: replyEmail, + to: ISSUE_REPORT_RECIPIENTS, + subject, + text, + html, + }) + + if (error) { + await ctx.runMutation(internal.issue_reports.updateEmailStatus, { + reportId, + emailSendStatus: 'failed', + emailError: error.message, + }) + return { reportId, emailSent: false, emailError: error.message } + } + + await ctx.runMutation(internal.issue_reports.updateEmailStatus, { + reportId, + emailSendStatus: 'sent', + }) + return { reportId, emailSent: true } + }, +}) diff --git a/freebuff/web/convex/lib/axiom_log.ts b/freebuff/web/convex/lib/axiom_log.ts new file mode 100644 index 0000000000..c51e4e3d30 --- /dev/null +++ b/freebuff/web/convex/lib/axiom_log.ts @@ -0,0 +1,145 @@ +import { getAnalyticsEventId } from '@codebuff/common/util/analytics-log' +import { LOG_LEVEL_ORDER, serializeLogData } from '@codebuff/common/util/log-data' +import { shouldMirrorAnalyticsEvent } from '@codebuff/common/util/log-mirror' + +import type { LogLevel } from '@codebuff/common/types/contracts/logs' + +/** + * Direct Axiom ingest from Convex. + * + * Rows mirror the shape produced by the shared sink + * (packages/logging/src/sink.ts → toEvent), so Convex logs/events are + * queryable in the same `freebuff` dataset alongside server/CLI/browser rows + * (docs/logging.md), with `service: 'freebuff-convex'` marking the emitter. + * The contract pieces — `data` serialization/truncation, level ordering, the + * AXIOM_MIRROR_DENYLIST cost guard, and eventId→event promotion — are + * imported from `@codebuff/common`, so they can't drift from the sink. + * + * What ISN'T shared is `@codebuff/logging` itself: the sink assumes a + * long-lived Node process (background batching, shutdown-flush hooks, + * `@codebuff/common/env` validation), none of which hold in a Convex action. + * Here each call is a single awaited POST to Axiom's REST ingest API; + * callers should treat it as best-effort (it never throws). + * + * Env (Convex deployment; names match the backend convention in + * docs/logging.md — AXIOM_API_TOKEN ingests, AXIOM_QUERY_TOKEN reads): + * AXIOM_API_TOKEN ingest token; must cover the freebuff dataset + * AXIOM_ORG_ID only needed for a personal token + * AXIOM_DATASET dataset name (default: freebuff[-dev]) + * AXIOM_LOGS_ENABLED 'true' | 'false' (default: on in prod only) + * AXIOM_LOGS_MIN_LEVEL debug|info|warn|error|fatal (default: info) + */ + +const AXIOM_API_URL = 'https://api.axiom.co' + +// Bound how long a hung Axiom endpoint can hold a (billed) Convex action open. +const INGEST_TIMEOUT_MS = 5_000 + +export type AxiomLogParams = { + level: LogLevel + /** Human-readable message (the `message` column). */ + message: string + /** + * AnalyticsEvent name when this row is an analytics event — promoted to the + * top-level `event` column, which is what makes it an "event" to the query + * scripts. Values not in the AnalyticsEvent enum are NOT promoted (the + * column stays null, matching the loggers' getAnalyticsEventId behavior), + * so a typo can't mint a new value in the `event` column. + */ + eventId?: string + /** Canonical codebuff Postgres user id (the `user_id` pivot column). */ + userId?: string + /** Structured payload; serialized to the single string `data` field. */ + data?: Record<string, unknown> +} + +function isProd(): boolean { + return process.env.NODE_ENV === 'production' +} + +function enabled(): boolean { + const flag = process.env.AXIOM_LOGS_ENABLED + if (flag === 'true') return true + if (flag === 'false') return false + return isProd() +} + +function datasetName(): string { + return process.env.AXIOM_DATASET || (isProd() ? 'freebuff' : 'freebuff-dev') +} + +function minLevel(): number { + const configured = process.env.AXIOM_LOGS_MIN_LEVEL + return LOG_LEVEL_ORDER[configured as LogLevel] ?? LOG_LEVEL_ORDER.info +} + +/** + * Ship one log/event row to Axiom. Best-effort and never throws; failures go + * to the Convex console. Await it from an action (Convex may freeze the + * process once the handler returns, so fire-and-forget can drop the send). + */ +export async function logToAxiom(params: AxiomLogParams): Promise<void> { + if (!enabled()) return + if ((LOG_LEVEL_ORDER[params.level] ?? 0) < minLevel()) return + // Same authoritative cost guard the sink applies (sink.ts enqueueLogRow): + // denylisted high-volume events never ingest, from any producer. + if (!shouldMirrorAnalyticsEvent(params.eventId)) return + + const token = process.env.AXIOM_API_TOKEN + if (!token) { + // In production a missing token means Convex rows are silently absent + // from the Axiom-side queries — the exact gap this helper exists to + // close — so be loud. + if (isProd()) { + console.error( + '[axiom-log] AXIOM_API_TOKEN is unset in the Convex deployment env — Convex logs/events are not reaching Axiom. Set it with `npx convex env set` (an ingest-capable token for the freebuff dataset).', + ) + } + return + } + + const row = { + _time: new Date().toISOString(), + id: crypto.randomUUID(), + level: params.level, + source: 'server', + service: 'freebuff-convex', + env: isProd() ? 'prod' : 'dev', + event: params.eventId ? getAnalyticsEventId({ eventId: params.eventId }) : null, + message: params.message, + user_id: params.userId ?? null, + client_session_id: null, + client_request_id: null, + fingerprint_id: null, + data: serializeLogData(params.data), + } + + try { + const orgId = process.env.AXIOM_ORG_ID + const res = await fetch( + `${AXIOM_API_URL}/v1/datasets/${encodeURIComponent(datasetName())}/ingest`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + ...(orgId ? { 'X-Axiom-Org-Id': orgId } : {}), + }, + body: JSON.stringify([row]), + ...(typeof AbortSignal.timeout === 'function' + ? { signal: AbortSignal.timeout(INGEST_TIMEOUT_MS) } + : {}), + }, + ) + if (!res.ok) { + console.error( + `[axiom-log] Axiom ingest returned ${res.status} for ${params.eventId ?? params.message}` + + (res.status === 403 + ? ' — the AXIOM_API_TOKEN in the Convex env likely lacks ingest permission for this dataset' + : ''), + ) + } + } catch (error) { + console.error('[axiom-log] Axiom ingest failed', error) + } +} diff --git a/freebuff/web/convex/lib/convex_pricing.ts b/freebuff/web/convex/lib/convex_pricing.ts new file mode 100644 index 0000000000..4f2f003df0 --- /dev/null +++ b/freebuff/web/convex/lib/convex_pricing.ts @@ -0,0 +1,164 @@ +/** + * Convex Pricing Utilities (2025 Pricing Model) + * + * Professional Plan: $25/member/month includes: + * - 250 GB-hours Action Compute + * - 50 GB Database Storage + * - 100 GB File Storage + * - 50 GB File Bandwidth + * + * Overage Rates: + * - $2 per 1M function calls + * - $0.30 per GB-hour for compute + * - $0.20 per GB/month for database storage + * - $0.30 per GB for file bandwidth + */ + +export const CONVEX_PRICING = { + PROFESSIONAL: { + BASE_PRICE: 25, // per member/month + INCLUDED: { + COMPUTE_GB_HOURS: 250, + DATABASE_STORAGE_GB: 50, + FILE_STORAGE_GB: 100, + FILE_BANDWIDTH_GB: 50, + }, + OVERAGE: { + FUNCTION_CALLS_PER_MILLION: 2.0, + COMPUTE_PER_GB_HOUR: 0.3, + DATABASE_STORAGE_PER_GB: 0.2, + DATABASE_BANDWIDTH_PER_GB: 0.2, + FILE_BANDWIDTH_PER_GB: 0.3, + FILE_STORAGE_PER_GB: 0.2, + }, + }, + FREE: { + // Free tier has limits but no overage charges + // We'll show costs as if they were on Professional plan + }, +} as const; + +export interface UsageMetrics { + executionCount: number; + executionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; +} + +export interface CostBreakdown { + compute: { + gbHours: number; + cost: number; + }; + functionCalls: { + count: number; + cost: number; + }; + databaseBandwidth: { + gb: number; + cost: number; + }; + fileBandwidth: { + gb: number; + cost: number; + }; + total: number; +} + +/** + * Calculate compute GB-hours from aggregated memory, execution time, and count. + * Uses average memory per execution × total execution time to avoid + * over-counting when both values are pre-summed across multiple executions. + */ +export function calculateComputeGBHours( + memoryMb: number, + executionTimeMs: number, + executionCount?: number, +): number { + if (memoryMb <= 0 || executionTimeMs <= 0) return 0; + const avgMemoryMb = + executionCount && executionCount > 0 ? memoryMb / executionCount : memoryMb; + const memoryGB = avgMemoryMb / 1024; + const executionHours = executionTimeMs / 3_600_000; + return memoryGB * executionHours; +} + +/** + * Calculate cost breakdown from usage metrics + */ +export function calculateCosts(metrics: UsageMetrics): CostBreakdown { + const pricing = CONVEX_PRICING.PROFESSIONAL.OVERAGE; + + const computeGBHours = calculateComputeGBHours( + metrics.actionMemoryUsedMb, + metrics.executionTimeMs, + metrics.executionCount, + ); + const computeCost = computeGBHours * pricing.COMPUTE_PER_GB_HOUR; + + // 2. Function call cost + const functionCallCost = + (metrics.executionCount / 1_000_000) * pricing.FUNCTION_CALLS_PER_MILLION; + + // 3. Database bandwidth cost (reads + writes) + const dbBandwidthGB = + (metrics.dbReadBytes + metrics.dbWriteBytes) / 1024 ** 3; + const dbBandwidthCost = dbBandwidthGB * pricing.DATABASE_BANDWIDTH_PER_GB; + + // 4. File bandwidth cost + const fileBandwidthGB = + (metrics.fileStorageReadBytes + metrics.fileStorageWriteBytes) / 1024 ** 3; + const fileBandwidthCost = fileBandwidthGB * pricing.FILE_BANDWIDTH_PER_GB; + + // Total cost + const total = + computeCost + functionCallCost + dbBandwidthCost + fileBandwidthCost; + + return { + compute: { + gbHours: computeGBHours, + cost: computeCost, + }, + functionCalls: { + count: metrics.executionCount, + cost: functionCallCost, + }, + databaseBandwidth: { + gb: dbBandwidthGB, + cost: dbBandwidthCost, + }, + fileBandwidth: { + gb: fileBandwidthGB, + cost: fileBandwidthCost, + }, + total, + }; +} + +/** + * Format currency for display + */ +export function formatCurrency(amount: number): string { + if (amount < 0.01 && amount > 0) { + return `$${amount.toFixed(4)}`; + } + return `$${amount.toFixed(2)}`; +} + +/** + * Calculate projected monthly cost based on current usage rate + * @param costSoFar - Total cost accumulated so far + * @param daysElapsed - Number of days into the billing period + * @returns Projected monthly cost + */ +export function projectMonthlyCost( + costSoFar: number, + daysElapsed: number, +): number { + if (daysElapsed === 0) return 0; + const dailyRate = costSoFar / daysElapsed; + return dailyRate * 30; // Project to 30-day month +} diff --git a/freebuff/web/convex/lib/featureAccessControl.ts b/freebuff/web/convex/lib/featureAccessControl.ts new file mode 100644 index 0000000000..774939052f --- /dev/null +++ b/freebuff/web/convex/lib/featureAccessControl.ts @@ -0,0 +1,359 @@ +/** + * Feature Access Control for Convex Backend + * + * Resilient utilities for enforcing feature access in Convex functions. + * Uses a two-layer approach matching the client-side pattern: + * 1. Primary: Autumn SDK check (autumn.check) + * 2. Fallback: Autumn REST API → customer.features + plan-based tier check + * + * NOTE: This file maintains its own copy of feature IDs because Convex backend + * cannot import from frontend code. Keep in sync with autumn/constants.ts! + * + * SOURCE OF TRUTH: autumn/constants.ts + */ + +import type { QueryCtx, MutationCtx, ActionCtx } from '../_generated/server' + +/** + * Boolean feature IDs from autumn/constants.ts + * KEEP IN SYNC with the frontend source of truth! + * + * Tier structure: + * - Starter ($3.99): documentation_visualizer, database_preview, no_vlyai_branding, custom_domains + * - Hobby ($12): + team_collaboration, integrations_library, project_code_editor, agent_context_length + * - Business ($23): + github_integration, convex_logs, cli_agent_access, in_app_support, private_projects + * - Scale ($55): + claude_opus_access, ui_components_library, theme_customization, data_transfer + * - Priority ($92): + personal_phone_support + * - Ultra ($180): + hire_developers + * - Max ($350): (limited projects, no new boolean) + * - Unlimited ($600): + unlimited_projects + */ +export const BOOLEAN_FEATURE_IDS = { + // Starter tier ($3.99) + DOCUMENTATION_VISUALIZER: 'documentation_visualizer', + DATABASE_PREVIEW: 'database_preview', + NO_VLYAI_BRANDING: 'no_vlyai_branding', + CUSTOM_DOMAINS: 'custom_domains', + + // Hobby tier ($12) + TEAM_COLLABORATION: 'team_collaboration', + INTEGRATIONS_LIBRARY: 'integrations_library', + PROJECT_CODE_EDITOR: 'project_code_editor', + AGENT_CONTEXT_LENGTH: 'agent_context_length', + + // Business tier ($23) + GITHUB_INTEGRATION: 'github_integration', + CONVEX_LOGS: 'convex_logs', + CLI_AGENT_ACCESS: 'cli_agent_access', + IN_APP_SUPPORT: 'in_app_support', + PRIVATE_PROJECTS: 'private_projects', + + // Scale tier ($55) + CLAUDE_OPUS_ACCESS: 'claude_opus_access', + UI_COMPONENTS_LIBRARY: 'ui_components_library', + THEME_CUSTOMIZATION: 'theme_customization', + DATA_TRANSFER: 'data_transfer', + + // Priority tier ($92) - Hidden + PERSONAL_PHONE_SUPPORT: 'personal_phone_support', + + // Ultra tier ($180) - Hidden + HIRE_DEVELOPERS: 'hire_developers', + + // Max tier ($350) - Hidden + UNLIMITED_PROJECTS: 'unlimited_projects', +} as const + +export type BooleanFeatureId = + (typeof BOOLEAN_FEATURE_IDS)[keyof typeof BOOLEAN_FEATURE_IDS] + +/** + * Feature display names for error messages + */ +const FEATURE_DISPLAY_NAMES: Record<string, string> = { + documentation_visualizer: 'Documentation Visualizer', + database_preview: 'Database Access', + no_vlyai_branding: 'Remove vly.ai Branding', + custom_domains: 'Custom Domains', + team_collaboration: 'Team Collaboration', + integrations_library: 'Integrations Library', + project_code_editor: 'Project Code Editor', + agent_context_length: 'Extended Agent Context', + github_integration: 'GitHub Integration', + convex_logs: 'Backend Logs', + cli_agent_access: 'Run Claude Code, Gemini CLI and Codex', + in_app_support: 'In-app Support', + private_projects: 'Private Projects', + claude_opus_access: 'Claude Opus Access', + ui_components_library: 'UI Components & Templates', + theme_customization: 'Theme Customization', + data_transfer: 'Data Transfer (Dev ↔ Prod)', + personal_phone_support: 'Personal Phone Support', + hire_developers: 'Hire Developers On-Demand', + unlimited_projects: 'Unlimited Projects', +} + +/** + * Minimum required tier display name for error messages + */ +const FEATURE_MINIMUM_TIER: Record<string, string> = { + documentation_visualizer: 'Starter', + database_preview: 'Starter', + no_vlyai_branding: 'Starter', + custom_domains: 'Starter', + team_collaboration: 'Hobby', + integrations_library: 'Hobby', + project_code_editor: 'Hobby', + agent_context_length: 'Hobby', + github_integration: 'Business', + convex_logs: 'Business', + cli_agent_access: 'Business', + in_app_support: 'Business', + private_projects: 'Business', + claude_opus_access: 'Scale', + ui_components_library: 'Scale', + theme_customization: 'Scale', + data_transfer: 'Scale', + personal_phone_support: 'Priority', + hire_developers: 'Ultra', + unlimited_projects: 'Unlimited', +} + +// ============================================================================ +// Resilient Fallback Infrastructure +// ============================================================================ + +const TIER_ORDER = [ + 'free', + 'starter', + 'hobby', + 'business', + 'scale', + 'priority', + 'ultra', + 'max', + 'unlimited', + 'enterprise', +] + +const FEATURE_REQUIRED_TIER: Record<string, string> = { + documentation_visualizer: 'starter', + database_preview: 'starter', + no_vlyai_branding: 'starter', + custom_domains: 'starter', + team_collaboration: 'hobby', + integrations_library: 'hobby', + project_code_editor: 'hobby', + agent_context_length: 'hobby', + github_integration: 'business', + convex_logs: 'business', + cli_agent_access: 'business', + in_app_support: 'business', + private_projects: 'business', + claude_opus_access: 'scale', + ui_components_library: 'scale', + theme_customization: 'scale', + data_transfer: 'scale', + personal_phone_support: 'priority', + hire_developers: 'ultra', + unlimited_projects: 'unlimited', +} + +const PLAN_ID_TO_TIER: Record<string, string> = { + free_plan: 'free', + starter_plan: 'starter', + hobby_plan: 'hobby', + business_plan: 'business', + scale_plan: 'scale', + priority_plan: 'priority', + ultra_plan: 'ultra', + max_plan: 'max', + unlimited_plan: 'unlimited', + enterprise_plan: 'enterprise', + hobby_custom_plan: 'hobby', + pro_plan: 'business', + pro_custom_plan: 'business', + team_plan: 'scale', + team_custom_plan: 'scale', + enterprise_custom_plan: 'enterprise', +} + +function isTierAtOrAbove(userTier: string, requiredTier: string): boolean { + const userIndex = TIER_ORDER.indexOf(userTier) + const requiredIndex = TIER_ORDER.indexOf(requiredTier) + if (userIndex === -1 || requiredIndex === -1) return false + return userIndex >= requiredIndex +} + +function inferTierFromPlanName(name?: string): string | null { + if (!name) return null + const normalized = name.toLowerCase() + if (normalized.includes('enterprise')) return 'enterprise' + if (normalized.includes('unlimited')) return 'unlimited' + if (normalized.includes('max')) return 'max' + if (normalized.includes('ultra')) return 'ultra' + if (normalized.includes('priority')) return 'priority' + if (normalized.includes('scale') || normalized.includes('team')) + return 'scale' + if (normalized.includes('business') || normalized.includes('pro')) + return 'business' + if (normalized.includes('hobby')) return 'hobby' + if (normalized.includes('starter')) return 'starter' + if (normalized.includes('free')) return 'free' + return null +} + +function getTierFromProducts(products: any[] | undefined): string { + if (!products || !Array.isArray(products)) return 'free' + + const now = Date.now() + + const activePlans = products.filter((p: any) => { + if (p?.is_add_on) return false + if (p?.status === 'active' || p?.scenario === 'active') return true + return !!( + p?.canceled_at && + p?.current_period_end && + now < p.current_period_end + ) + }) + + const activePlan = activePlans.sort((a: any, b: any) => { + const aTier = PLAN_ID_TO_TIER[a.id] ?? inferTierFromPlanName(a.name) + const bTier = PLAN_ID_TO_TIER[b.id] ?? inferTierFromPlanName(b.name) + const aRank = aTier ? TIER_ORDER.indexOf(aTier) : -1 + const bRank = bTier ? TIER_ORDER.indexOf(bTier) : -1 + return bRank - aRank + })[0] + + if (!activePlan) { + const scheduledPlan = products.find( + (p: any) => + p?.scenario === 'scheduled' || + (p.canceled_at && p.current_period_end && now < p.current_period_end), + ) + if (scheduledPlan) return PLAN_ID_TO_TIER[scheduledPlan.id] || 'free' + return 'free' + } + + return PLAN_ID_TO_TIER[activePlan.id] || 'free' +} + +async function fetchCustomerData(customerId: string): Promise<any | null> { + void customerId + return null +} + +function getCustomerIdFromIdentity(identity: any): string | null { + if (!identity) return null + const clerkUserId = identity.subject + const organizationId = + identity?.org_id || + identity?.organizationId || + identity?.organization?.id || + identity?.activeOrganizationId + return organizationId || clerkUserId || null +} + +function checkFeatureInCustomerData( + customerData: any, + featureId: string, +): boolean { + if (customerData.features?.[featureId]) { + const feature = customerData.features[featureId] + if ('has_access' in feature) return feature.has_access === true + return true + } + + const tier = getTierFromProducts(customerData.products) + const requiredTier = FEATURE_REQUIRED_TIER[featureId] + if (!requiredTier) return false + return isTierAtOrAbove(tier, requiredTier) +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Resilient feature access check. + * + * 1. Tries Autumn SDK check (works in all Convex contexts) + * 2. If SDK returns allowed=true, returns true immediately + * 3. If SDK fails or returns false, falls back to REST API check + * (only available in action contexts; returns SDK result in query/mutation) + */ +export async function hasFeatureAccess( + ctx: QueryCtx | MutationCtx | ActionCtx, + featureId: BooleanFeatureId | string, +): Promise<boolean> { + void ctx + void featureId + return true +} + +/** + * Check feature access for a specific Autumn customer ID. + * Uses the REST API directly — no auth context needed. + * Only works in action contexts (requires fetch). + */ +export async function hasFeatureAccessForCustomer( + customerId: string, + featureId: string, +): Promise<boolean> { + void customerId + void featureId + return true +} + +/** + * Check feature access and return a result object with error details. + */ +export async function requireFeatureAccess( + ctx: QueryCtx | MutationCtx | ActionCtx, + featureId: BooleanFeatureId | string, +) { + const hasAccess = await hasFeatureAccess(ctx, featureId) + + if (hasAccess) { + return { success: true as const } + } + + const featureName = FEATURE_DISPLAY_NAMES[featureId] ?? featureId + const requiredPlan = FEATURE_MINIMUM_TIER[featureId] ?? 'a paid plan' + + return { + success: false as const, + featureId, + featureName, + requiredPlan, + message: `${featureName} is not available on your current plan. Upgrade to ${requiredPlan} or higher to access this feature.`, + } +} + +/** + * Check if user has access to all specified features + */ +export async function hasAllFeaturesAccess( + ctx: QueryCtx | MutationCtx | ActionCtx, + featureIds: (BooleanFeatureId | string)[], +): Promise<boolean> { + const results = await Promise.all( + featureIds.map((id) => hasFeatureAccess(ctx, id)), + ) + return results.every((hasAccess) => hasAccess) +} + +/** + * Get display name for a feature + */ +export function getFeatureDisplayName(featureId: string): string { + return FEATURE_DISPLAY_NAMES[featureId] ?? featureId +} + +/** + * Get minimum required tier for a feature + */ +export function getFeatureMinimumTier(featureId: string): string | null { + return FEATURE_MINIMUM_TIER[featureId] ?? null +} diff --git a/freebuff/web/convex/lib/self_hosted_deployment.ts b/freebuff/web/convex/lib/self_hosted_deployment.ts new file mode 100644 index 0000000000..71e43669f1 --- /dev/null +++ b/freebuff/web/convex/lib/self_hosted_deployment.ts @@ -0,0 +1,285 @@ +"use node"; + +import axios, { isAxiosError } from "axios"; + +const DEFAULT_CONVEX_DOMAIN = ".convex.cloud"; + +interface ConvexConnection { + dev_deployment_name?: string; + dev_deployment_url?: string; + prod_deployment_name?: string; + prod_deployment_url?: string; + dev_deploy_key?: string; + prod_deploy_key?: string; +} + +interface DeploymentInfo { + deploymentUrl: string; + region: string; + name: string; + deploymentType: string; +} + +export class SelfHostedDeployment { + private readonly deploymentName: string; + private readonly deploymentUrl: string; + private readonly deployKey: string; + + private constructor( + deploymentName: string, + deploymentUrl: string, + deployKey: string, + ) { + this.deploymentName = deploymentName; + this.deploymentUrl = deploymentUrl; + this.deployKey = deployKey; + } + + static fromConnection( + connection: ConvexConnection, + decryptedKey: string, + type: "dev" | "prod", + ): SelfHostedDeployment { + const name = + type === "dev" + ? connection.dev_deployment_name + : connection.prod_deployment_name; + + const url = + type === "dev" + ? connection.dev_deployment_url + : connection.prod_deployment_url; + + if (!name) { + throw new Error( + `No ${type} deployment name found in self-hosted connection`, + ); + } + + return new SelfHostedDeployment( + name, + url ?? SelfHostedDeployment.buildDefaultUrl(name), + decryptedKey, + ); + } + + static async resolveUrl( + deploymentName: string, + accessToken: string, + ): Promise<string> { + try { + const response = await axios.get<DeploymentInfo>( + `https://api.convex.dev/v1/deployments/${deploymentName}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (response.data.deploymentUrl) { + return response.data.deploymentUrl; + } + + return SelfHostedDeployment.buildDefaultUrl(deploymentName); + } catch (error) { + console.warn( + `Failed to resolve URL for deployment ${deploymentName}, using default:`, + error instanceof Error ? error.message : error, + ); + return SelfHostedDeployment.buildDefaultUrl(deploymentName); + } + } + + static buildDefaultUrl(deploymentName: string): string { + return `https://${deploymentName}${DEFAULT_CONVEX_DOMAIN}`; + } + + getDeploymentUrl(): string { + return this.deploymentUrl; + } + + getDeploymentName(): string { + return this.deploymentName; + } + + async getEnvVars(): Promise<Record<string, string>> { + try { + const response = await axios.get<{ + environmentVariables: Record<string, string>; + }>(`${this.deploymentUrl}/api/v1/list_environment_variables`, { + headers: { + Authorization: `Convex ${this.deployKey}`, + }, + }); + + const envVars = response.data?.environmentVariables; + + if (!envVars || typeof envVars !== "object") { + console.error( + `Unexpected response format from list_environment_variables:`, + JSON.stringify(response.data), + ); + return {}; + } + + console.log( + `Successfully fetched ${Object.keys(envVars).length} environment variables for ${this.deploymentName}`, + ); + + return envVars; + } catch (error) { + if (isAxiosError(error)) { + console.error( + `Failed to get environment variables for ${this.deploymentName}:`, + JSON.stringify(error.response?.data), + ); + throw new Error( + `Failed to get environment variables: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } + } + + async setEnvVars(variables: Record<string, string>): Promise<void> { + try { + const changes = Object.entries(variables).map(([name, value]) => ({ + name, + value, + })); + + await axios.post( + `${this.deploymentUrl}/api/v1/update_environment_variables`, + { changes }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${this.deployKey}`, + }, + }, + ); + + console.log( + `Successfully set environment variables for ${this.deploymentName}:`, + Object.keys(variables).join(", "), + ); + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + `Failed to set environment variables: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } + } + + async deleteEnvVar(variableName: string): Promise<void> { + try { + const changes = [{ name: variableName, value: null }]; + + await axios.post( + `${this.deploymentUrl}/api/v1/update_environment_variables`, + { changes }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${this.deployKey}`, + }, + }, + ); + + console.log( + `Successfully deleted environment variable ${variableName} for ${this.deploymentName}`, + ); + } catch (error) { + if (isAxiosError(error)) { + throw new Error( + `Failed to delete environment variable: ${JSON.stringify(error.response?.data)}`, + ); + } + throw error; + } + } + + async changeState( + newState: "paused" | "running", + ): Promise<{ success: boolean; message: string }> { + try { + await axios.post( + `${this.deploymentUrl}/api/change_deployment_state`, + { newState }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Convex ${this.deployKey}`, + }, + }, + ); + + console.log( + `Successfully changed deployment ${this.deploymentName} state to ${newState}`, + ); + + return { + success: true, + message: `Successfully changed deployment ${this.deploymentName} state to ${newState}`, + }; + } catch (error) { + if (isAxiosError(error)) { + if (error.response?.data?.code === "DeploymentAlreadyInState") { + return { + success: true, + message: `Deployment ${this.deploymentName} is already ${newState}`, + }; + } + + return { + success: false, + message: `Failed to change deployment state: ${JSON.stringify(error.response?.data)}`, + }; + } + + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + success: false, + message: `Failed to change deployment state: ${errorMessage}`, + }; + } + } + + async setEnvVarsOnProdDeployment( + envVars: Record<string, string>, + ): Promise<void> { + const strippedKey = this.deployKey.split(":")[1] ?? this.deployKey; + + try { + await axios.post( + `${this.deploymentUrl}/api/v1/update_environment_variables`, + { + changes: Object.entries(envVars).map(([key, value]) => ({ + name: key, + value, + })), + }, + { + headers: { + Authorization: `Convex ${strippedKey}`, + "Content-Type": "application/json", + }, + }, + ); + console.log( + "Successfully set environment variables on deployment", + this.deploymentName, + ); + } catch (error) { + console.error( + `Error setting environment variables on deployment ${this.deploymentName}:`, + error, + ); + throw error; + } + } +} diff --git a/freebuff/web/convex/lib/tierLimits.ts b/freebuff/web/convex/lib/tierLimits.ts new file mode 100644 index 0000000000..faeaf14d5b --- /dev/null +++ b/freebuff/web/convex/lib/tierLimits.ts @@ -0,0 +1,14 @@ +/** + * Max projects per tier for Convex backend checks. + * Keep in sync with autumn/constants.ts TIER_LIMITS.maxProjects. + * Convex users table only has tier "free" | "pro"; "pro" maps to paid tiers (e.g. business = 10). + */ +export const MAX_PROJECTS_BY_TIER: Record<string, number> = { + free: 2, + pro: 10, +}; + +export function getMaxProjectsForTier(tier: string | undefined): number { + if (!tier) return MAX_PROJECTS_BY_TIER.free; + return MAX_PROJECTS_BY_TIER[tier] ?? MAX_PROJECTS_BY_TIER.free; +} diff --git a/freebuff/web/convex/localEnvFix.ts b/freebuff/web/convex/localEnvFix.ts new file mode 100644 index 0000000000..67db5579a3 --- /dev/null +++ b/freebuff/web/convex/localEnvFix.ts @@ -0,0 +1,52 @@ +"use node"; + +import { internal } from "!/_generated/api"; +import { createDeployKey } from "!/convex_management"; +import { v } from "convex/values"; +import { CSBCodebase } from "../codebase-utils/codebase/CSBCodebase"; +import { internalAction } from "./_generated/server"; + +export const restoreEnvLocal = internalAction({ + args: { + projectId: v.id("project"), + devDeploymentName: v.string(), + }, + handler: async (ctx, args) => { + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + // Skip restoreEnvLocal for Daytona sandboxes - they don't need this + if (project.sandbox_id.startsWith("daytona:")) { + console.log( + "Skipping restoreEnvLocal for Daytona sandbox:", + project.sandbox_id, + ); + return; + } + + const codebase = await CSBCodebase.create(project.sandbox_id); + const result = await codebase.runCommand("ls .env.local"); + if (result.exitCode !== 0) { + await codebase.writeFile( + ".env.local", + `CONVEX_DEPLOYMENT=dev:${args.devDeploymentName}\nVITE_CONVEX_URL=https://${args.devDeploymentName}.convex.cloud\n`, + ); + } + + const keyResult = await codebase.runCommand("ls $HOME/.vly-convex/dev.key"); + if (keyResult.exitCode !== 0) { + console.log("No dev key found, creating one"); + const deploymentKey = await createDeployKey(args.devDeploymentName); + const result = await codebase.runCommand( + `mkdir -p $HOME/.vly-convex && echo "${deploymentKey}" > $HOME/.vly-convex/dev.key`, + ); + } + + await codebase.restartDevServer(); + }, +}); diff --git a/freebuff/web/convex/memory.ts b/freebuff/web/convex/memory.ts new file mode 100644 index 0000000000..ca255610a6 --- /dev/null +++ b/freebuff/web/convex/memory.ts @@ -0,0 +1,27 @@ +import { internalMutation } from "./_generated/server"; + +import { v } from "convex/values"; +// add new memory +export const addMemory = internalMutation({ + args: { + projectId: v.id("project"), + threadId: v.id("thread"), + type: v.union( + v.literal("error_solution"), + v.literal("problem_solution"), + v.literal("approved_component"), + v.literal("unsolved_problem"), + ), + content: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.insert("memory", { + project: args.projectId, + thread: args.threadId, + type: args.type, + content: args.content, + date: Date.now(), + status: "logged", + }); + }, +}); diff --git a/freebuff/web/convex/messages.ts b/freebuff/web/convex/messages.ts new file mode 100644 index 0000000000..b852f29025 --- /dev/null +++ b/freebuff/web/convex/messages.ts @@ -0,0 +1,1246 @@ +import schema from "!/schema"; +import { modelValidator } from "!/utils/registry_validators"; +import { typedV } from "convex-helpers/validators"; +import { v } from "convex/values"; +import { + internalMutation, + internalQuery, + mutation, + query, +} from "./_generated/server"; +import { internal } from "./_generated/api"; + +// Size limits to prevent exceeding Convex 1 MiB document limit +const MAX_THINKING_SIZE = 80 * 1024; // 80 KB - users don't see full reasoning +const MAX_CONTENT_SIZE = 300 * 1024; // 300 KB +const MAX_OBJECT_SIZE = 180 * 1024; // 180 KB +const MAX_RESULT_SIZE = 180 * 1024; // 180 KB +const MAX_ERROR_CHECK_SIZE = 180 * 1024; // 180 KB +const MAX_FILE_APPLY_RESULTS_SIZE = 60 * 1024; // 60 KB +const MAX_PROVIDER_METADATA_SIZE = 20 * 1024; // 20 KB +const MAX_USAGE_OTHER_SIZE = 20 * 1024; // 20 KB +const MAX_DEBUG_PROMPT_LOG_SIZE = 320 * 1024; // 320 KB debug-only prompt capture +const MAX_FAST_RETURN_PREVIEW_SIZE = 4 * 1024; // 4 KB + +/** + * Truncates a string to maxBytes, keeping the END (recent content). + * Uses TextEncoder for accurate byte counting. + */ +function truncateKeepEnd( + str: string, + maxBytes: number, + fieldName: string, +): string { + if (!str) return str; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + if (bytes.length <= maxBytes) { + return str; + } + + console.warn( + `[Messages] Truncating ${fieldName}: ${(bytes.length / 1024).toFixed(1)} KB -> ${(maxBytes / 1024).toFixed(1)} KB`, + ); + + // Keep the end of the string + const decoder = new TextDecoder(); + const truncated = decoder.decode(bytes.slice(-maxBytes)); + return "[...truncated...]\n\n" + truncated; +} + +function truncateKeepBoth( + str: string, + maxBytes: number, + fieldName: string, +): string { + if (!str) return str; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + if (bytes.length <= maxBytes) { + return str; + } + + console.warn( + `[Messages] Truncating ${fieldName}: ${(bytes.length / 1024).toFixed(1)} KB -> ${(maxBytes / 1024).toFixed(1)} KB`, + ); + + const decoder = new TextDecoder(); + const headBytes = Math.floor(maxBytes * 0.75); + const tailBytes = Math.floor(maxBytes * 0.25); + return [ + decoder.decode(bytes.slice(0, headBytes)), + `\n\n[${fieldName} truncated]\n\n`, + decoder.decode(bytes.slice(-tailBytes)), + ].join(""); +} + +function truncateKeepStart( + str: string, + maxBytes: number, + fieldName: string, +): string { + if (!str) return str; + + const encoder = new TextEncoder(); + const bytes = encoder.encode(str); + + if (bytes.length <= maxBytes) { + return str; + } + + console.warn( + `[Messages] Truncating ${fieldName}: ${(bytes.length / 1024).toFixed(1)} KB -> ${(maxBytes / 1024).toFixed(1)} KB`, + ); + + const decoder = new TextDecoder(); + const marker = `\n\n[${fieldName} truncated]\n`; + const markerBytes = encoder.encode(marker).length; + const availableBytes = Math.max(0, maxBytes - markerBytes); + + return decoder.decode(bytes.slice(0, availableBytes)) + marker; +} + +/** + * Truncates object JSON by removing old tool calls from the beginning. + */ +function truncateObjectJson(objectStr: string, maxBytes: number): string { + if (!objectStr) return objectStr; + + const encoder = new TextEncoder(); + if (encoder.encode(objectStr).length <= maxBytes) { + return objectStr; + } + + try { + const toolCalls = JSON.parse(objectStr); + if (!Array.isArray(toolCalls)) { + return truncateKeepEnd(objectStr, maxBytes, "object"); + } + + // Remove old tool calls until under limit + while (toolCalls.length > 1) { + toolCalls.shift(); + const newStr = JSON.stringify(toolCalls); + if (encoder.encode(newStr).length <= maxBytes) { + console.warn( + `[Messages] Truncated object: removed old tool calls, kept ${toolCalls.length}`, + ); + return newStr; + } + } + + // If single tool call still too large, truncate as string + return truncateKeepEnd(JSON.stringify(toolCalls), maxBytes, "object"); + } catch { + return truncateKeepEnd(objectStr, maxBytes, "object"); + } +} + +function truncateFileApplyResults( + fileApplyResults: + | { + path: string; + success: boolean; + error?: string; + }[] + | undefined, +) { + if (!fileApplyResults) { + return fileApplyResults; + } + + const sanitize = (results: typeof fileApplyResults) => + results.map((result) => ({ + path: truncateKeepBoth(result.path, 2 * 1024, "file_apply_path"), + success: result.success, + ...(result.error + ? { + error: truncateKeepBoth(result.error, 4 * 1024, "file_apply_error"), + } + : {}), + })); + + let sanitized = sanitize(fileApplyResults); + const encoder = new TextEncoder(); + + while ( + sanitized.length > 1 && + encoder.encode(JSON.stringify(sanitized)).length > + MAX_FILE_APPLY_RESULTS_SIZE + ) { + sanitized = sanitized.slice(Math.ceil(sanitized.length / 2)); + } + + return sanitized; +} + +function serializeMessageForSummary(message: any) { + return { + _id: message._id, + _creationTime: message._creationTime, + role: message.role, + content: message.content, + core_message: message.core_message, + date: message.date, + thread_id: message.thread_id, + }; +} + +export const get = internalQuery({ + args: { + messageId: v.id("messages"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.messageId); + }, +}); + +export const updateMessageSummary = internalMutation({ + args: { + messageId: v.id("messages"), + summarization: v.string(), + compactSummarization: v.string(), + codeSummarization: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Update the message with the summarization + await ctx.db.patch(args.messageId, { + summarization: args.summarization, + compact_summarization: args.compactSummarization, + code_summarization: args.codeSummarization, + }); + }, +}); + +export const updateMessageIntegrationReference = internalMutation({ + args: { + messageId: v.id("messages"), + integrationId: v.id("integration"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + if (!message) throw new Error("Message not found"); + + // Add to array if not already present + const existingRefs = message.integration_references || []; + if (!existingRefs.includes(args.integrationId)) { + await ctx.db.patch(args.messageId, { + integration_references: [...existingRefs, args.integrationId], + }); + } + return null; + }, +}); + +export const setProcessingResult = internalMutation({ + args: { + messageId: v.id("messages"), + typecheckResult: v.string(), + fileApplyResults: v.optional( + typedV(schema).doc("messages").fields["file_apply_results"], + ), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + error_check: truncateKeepBoth( + args.typecheckResult, + MAX_ERROR_CHECK_SIZE, + "error_check", + ), + file_apply_results: truncateFileApplyResults(args.fileApplyResults), + }); + }, +}); + +export const recordTokenUsage = internalMutation({ + args: { + messageId: v.id("messages"), + tokenUsage: v.object({ + input_tokens: v.number(), + output_tokens: v.number(), + model: modelValidator, + }), + cachedTokens: v.optional(v.number()), + providerMetadata: v.optional(v.string()), + totalCostUsd: v.optional(v.number()), + usageBreakdown: v.optional( + v.object({ + input_tokens: v.number(), + cached_input_tokens: v.number(), + output_tokens: v.number(), + reasoning_tokens: v.optional(v.number()), + cache_write_input_tokens: v.optional(v.number()), + other: v.optional(v.string()), + }), + ), + }, + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + if (!message) { + throw new Error("Message not found"); + } + + const priorUsage = message.usage_breakdown ?? { + input_tokens: 0, + cached_input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cache_write_input_tokens: 0, + other: undefined, + }; + const nextUsage = args.usageBreakdown ?? { + input_tokens: args.tokenUsage.input_tokens, + cached_input_tokens: args.cachedTokens ?? 0, + output_tokens: args.tokenUsage.output_tokens, + reasoning_tokens: 0, + cache_write_input_tokens: 0, + other: undefined, + }; + + await ctx.db.patch(args.messageId, { + token_usage: message.token_usage + ? [...message.token_usage, args.tokenUsage] + : [args.tokenUsage], + usage_breakdown: { + input_tokens: priorUsage.input_tokens + nextUsage.input_tokens, + cached_input_tokens: + priorUsage.cached_input_tokens + nextUsage.cached_input_tokens, + output_tokens: priorUsage.output_tokens + nextUsage.output_tokens, + ...(priorUsage.reasoning_tokens !== undefined || + nextUsage.reasoning_tokens !== undefined + ? { + reasoning_tokens: + (priorUsage.reasoning_tokens ?? 0) + + (nextUsage.reasoning_tokens ?? 0), + } + : {}), + ...(priorUsage.cache_write_input_tokens !== undefined || + nextUsage.cache_write_input_tokens !== undefined + ? { + cache_write_input_tokens: + (priorUsage.cache_write_input_tokens ?? 0) + + (nextUsage.cache_write_input_tokens ?? 0), + } + : {}), + ...(nextUsage.other || priorUsage.other + ? { + other: truncateKeepBoth( + [priorUsage.other, nextUsage.other].filter(Boolean).join("\n"), + MAX_USAGE_OTHER_SIZE, + "usage_other", + ), + } + : {}), + }, + ...(args.totalCostUsd !== undefined + ? { + total_cost_usd: (message.total_cost_usd ?? 0) + args.totalCostUsd, + } + : {}), + ...(args.providerMetadata !== undefined + ? { + provider_metadata: truncateKeepBoth( + args.providerMetadata, + MAX_PROVIDER_METADATA_SIZE, + "provider_metadata", + ), + } + : {}), + }); + }, +}); + +export const updateCreditsDeducted = internalMutation({ + args: { + messageId: v.id("messages"), + creditsDeducted: v.number(), + }, + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + if (!message) { + throw new Error("Message not found"); + } + // Accumulate credits if there are multiple model calls per message + const existingCredits = message.credits_deducted ?? 0; + await ctx.db.patch(args.messageId, { + credits_deducted: existingCredits + args.creditsDeducted, + }); + }, +}); + +export const getForProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project?.active_thread) { + return []; + } + + // Take 25 messages by date first, then filter out deactivated and streamed + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", project.active_thread).eq("streaming", false), + ) + .order("desc") + .take(25) + .then((msgs) => msgs.filter((msg) => msg.deactivated !== true)); + + return messages; + }, +}); + +export const getAllForProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project?.active_thread) { + return []; + } + + // Get ALL messages including deactivated ones - needed for operations like undo + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", project.active_thread).eq("streaming", false), + ) + .order("desc") + .take(50); // Take more messages since we need complete history for operations + + return messages; + }, +}); + +// NOT USED: for testing purposes +export const getRelevantMessages = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project?.active_thread) { + return []; + } + + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", project.active_thread).eq("streaming", false), + ) + .order("desc") + .filter((q) => q.neq(q.field("deactivated"), true)) + .filter((q) => q.neq(q.field("isFastReturn"), true)) + .take(40); + return messages; + }, +}); + +export const insertEmptyAssistantMessage = internalMutation({ + args: { + projectId: v.id("project"), + fastReturn: v.optional(v.boolean()), + modelSemanticName: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + console.log(Date.now()); + const assistantMessageId = await ctx.db.insert("messages", { + project_id: args.projectId, + role: "assistant", + content: "", + date: Date.now() + 1, // Add 1ms to ensure correct ordering + ...(project?.active_thread && { thread_id: project.active_thread }), + streaming: true, + deactivated: false, + ...(args.fastReturn && { isFastReturn: args.fastReturn }), + ...(args.modelSemanticName && { + model_semantic_name: args.modelSemanticName, + }), + }); + + return assistantMessageId; + }, +}); + +export const updateMessageToolResult = internalMutation({ + args: { + messageId: v.id("messages"), + toolResult: v.string(), + errorCheck: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + result: truncateKeepBoth(args.toolResult, MAX_RESULT_SIZE, "result"), + ...(args.errorCheck && { + error_check: truncateKeepBoth( + args.errorCheck, + MAX_ERROR_CHECK_SIZE, + "error_check", + ), + }), + }); + }, +}); + +export const updateMessageContent = internalMutation({ + args: { + messageId: v.id("messages"), + content: v.optional(v.string()), + coreMessage: v.optional(v.string()), + object: v.optional(v.string()), + result: v.optional(v.string()), + errorCheck: v.optional(v.string()), + fileApplyResults: v.optional( + typedV(schema).doc("messages").fields["file_apply_results"], + ), + toolCallName: v.optional(v.string()), + streaming: v.optional(v.boolean()), + fastReturn: v.optional(v.boolean()), + thinking: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + ...(args.content !== undefined && { + content: truncateKeepEnd(args.content, MAX_CONTENT_SIZE, "content"), + }), + ...(args.coreMessage !== undefined && { + core_message: truncateKeepEnd( + args.coreMessage, + MAX_CONTENT_SIZE, + "core_message", + ), + }), + ...(args.object !== undefined && { + object: truncateObjectJson(args.object, MAX_OBJECT_SIZE), + }), + ...(args.result !== undefined && { + result: truncateKeepBoth(args.result, MAX_RESULT_SIZE, "result"), + }), + ...(args.errorCheck !== undefined && { + error_check: truncateKeepBoth( + args.errorCheck, + MAX_ERROR_CHECK_SIZE, + "error_check", + ), + }), + ...(args.fileApplyResults !== undefined && { + file_apply_results: truncateFileApplyResults(args.fileApplyResults), + }), + ...(args.toolCallName !== undefined && { tool_call: args.toolCallName }), + ...(args.streaming !== undefined && { streaming: args.streaming }), + ...(args.fastReturn !== undefined && { isFastReturn: args.fastReturn }), + ...(args.thinking !== undefined && { + thinking: truncateKeepEnd(args.thinking, MAX_THINKING_SIZE, "thinking"), + }), + }); + }, +}); + +// OPTIMIZATION: Combine content + state updates to reduce mutations from 196k/day to ~60k/day +export const updateMessageContentAndState = internalMutation({ + args: { + messageId: v.id("messages"), + content: v.optional(v.string()), + coreMessage: v.optional(v.string()), + object: v.optional(v.string()), + result: v.optional(v.string()), + errorCheck: v.optional(v.string()), + fileApplyResults: v.optional( + typedV(schema).doc("messages").fields["file_apply_results"], + ), + toolCallName: v.optional(v.string()), + streaming: v.optional(v.boolean()), + fastReturn: v.optional(v.boolean()), + thinking: v.optional(v.string()), + status: v.optional( + v.union( + v.literal("complete"), + v.literal("error"), + v.literal("type_errors"), + v.literal("checking_errors"), + v.literal("processing_tools"), + v.literal("streaming"), + v.literal("thinking"), + ), + ), + statusMessage: v.optional(v.string()), + statusColor: v.optional( + v.union( + v.literal("green"), + v.literal("red"), + v.literal("yellow"), + v.literal("blue"), + v.literal("gray"), + v.literal("orange"), + ), + ), + }, + handler: async (ctx, args) => { + const updateData: any = {}; + + // Content updates with truncation + if (args.content !== undefined) + updateData.content = truncateKeepEnd( + args.content, + MAX_CONTENT_SIZE, + "content", + ); + if (args.coreMessage !== undefined) + updateData.core_message = truncateKeepEnd( + args.coreMessage, + MAX_CONTENT_SIZE, + "core_message", + ); + if (args.object !== undefined) + updateData.object = truncateObjectJson(args.object, MAX_OBJECT_SIZE); + if (args.result !== undefined) + updateData.result = truncateKeepBoth( + args.result, + MAX_RESULT_SIZE, + "result", + ); + if (args.errorCheck !== undefined) + updateData.error_check = truncateKeepBoth( + args.errorCheck, + MAX_ERROR_CHECK_SIZE, + "error_check", + ); + if (args.fileApplyResults !== undefined) + updateData.file_apply_results = truncateFileApplyResults( + args.fileApplyResults, + ); + if (args.toolCallName !== undefined) + updateData.tool_call = args.toolCallName; + if (args.streaming !== undefined) updateData.streaming = args.streaming; + if (args.fastReturn !== undefined) + updateData.isFastReturn = args.fastReturn; + if (args.thinking !== undefined) + updateData.thinking = truncateKeepEnd( + args.thinking, + MAX_THINKING_SIZE, + "thinking", + ); + + // State updates + if (args.status !== undefined) { + const stateData: any = { + status: args.status, + timestamp: Date.now(), + }; + + if (args.statusMessage !== undefined) { + stateData.message = args.statusMessage.substring(0, 250); + } + + if (args.statusColor !== undefined) { + stateData.color = args.statusColor; + } else { + // Auto-assign color based on status + switch (args.status) { + case "complete": + stateData.color = "green"; + break; + case "error": + stateData.color = "red"; + break; + case "type_errors": + stateData.color = "orange"; + break; + case "checking_errors": + case "processing_tools": + stateData.color = "yellow"; + break; + case "streaming": + stateData.color = "blue"; + break; + case "thinking": + stateData.color = "gray"; + break; + } + } + + updateData.message_state = stateData; + } + + await ctx.db.patch(args.messageId, updateData); + }, +}); + +export const updateModelSemanticName = internalMutation({ + args: { + messageId: v.id("messages"), + modelSemanticName: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + model_semantic_name: args.modelSemanticName, + }); + }, +}); + +export const appendDebugPromptLog = internalMutation({ + args: { + messageId: v.id("messages"), + log: v.string(), + }, + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + if (!message) { + throw new Error("Message not found"); + } + + await ctx.db.patch(args.messageId, { + debug_prompt_log: truncateKeepStart( + args.log, + MAX_DEBUG_PROMPT_LOG_SIZE, + "debug_prompt_log", + ), + }); + }, +}); + +export const updateCoreMessage = internalMutation({ + args: { + messageId: v.id("messages"), + coreMessage: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + core_message: args.coreMessage, + }); + }, +}); + +export const updateMessageSuggestions = internalMutation({ + args: { + messageId: v.id("messages"), + suggestions: v.array(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + suggestions: args.suggestions, + }); + }, +}); + +export const setFastReturnPreview = internalMutation({ + args: { + messageId: v.id("messages"), + preview: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + fast_return_preview: truncateKeepEnd( + args.preview, + MAX_FAST_RETURN_PREVIEW_SIZE, + "fast_return_preview", + ), + }); + }, +}); + +export const clearSuggestionsFromThread = internalMutation({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + // OPTIMIZATION: Only clear suggestions from recent messages (last 50) to reduce data reads + // This reduces data read from ~500KB to ~100KB + // Suggestions are typically only on the most recent messages anyway + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("desc") + .filter((q) => q.neq(q.field("deactivated"), true)) + .filter((q) => q.eq(q.field("role"), "assistant")) + .take(50); + + // Only patch messages that actually have suggestions + for (const message of messages) { + if (message.suggestions) { + await ctx.db.patch(message._id, { + suggestions: undefined, + }); + } + } + }, +}); + +export const updateMessageState = internalMutation({ + args: { + messageId: v.id("messages"), + status: v.union( + v.literal("complete"), + v.literal("error"), + v.literal("type_errors"), + v.literal("checking_errors"), + v.literal("processing_tools"), + v.literal("streaming"), + v.literal("thinking"), + v.literal("insufficient_credits"), + ), + message: v.optional(v.string()), + color: v.optional( + v.union( + v.literal("green"), + v.literal("red"), + v.literal("yellow"), + v.literal("blue"), + v.literal("gray"), + v.literal("orange"), + ), + ), + }, + handler: async (ctx, args) => { + const stateData: any = { + status: args.status, + timestamp: Date.now(), + }; + + if (args.message !== undefined) { + stateData.message = args.message.substring(0, 250); + } + + if (args.color !== undefined) { + stateData.color = args.color; + } else { + // Auto-assign color based on status + switch (args.status) { + case "complete": + stateData.color = "green"; + break; + case "error": + stateData.color = "red"; + break; + case "type_errors": + stateData.color = "orange"; + break; + case "checking_errors": + case "processing_tools": + stateData.color = "yellow"; + break; + case "streaming": + stateData.color = "blue"; + break; + case "thinking": + stateData.color = "gray"; + break; + case "insufficient_credits": + stateData.color = "yellow"; + break; + } + } + + await ctx.db.patch(args.messageId, { + message_state: stateData, + }); + }, +}); + +export const setCheckingErrorsAndInvalidate = internalMutation({ + args: { + messageId: v.id("messages"), + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + message_state: { + status: "checking_errors", + message: "Running type check", + color: "yellow", + timestamp: Date.now(), + }, + }); + + const unresolvedRuntime = await ctx.db + .query("runtime_error") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .filter((q) => + q.or( + q.eq(q.field("status"), "unresolved"), + q.and( + q.eq(q.field("status"), undefined), + q.eq(q.field("resolved"), undefined), + ), + ), + ) + .collect(); + for (const error of unresolvedRuntime) { + await ctx.db.patch(error._id, { resolved: true, status: "invalidated" }); + } + + const unresolvedBuild = await ctx.db + .query("build_error") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .filter((q) => + q.or( + q.eq(q.field("status"), "unresolved"), + q.and( + q.eq(q.field("status"), undefined), + q.eq(q.field("resolved"), undefined), + ), + ), + ) + .collect(); + for (const error of unresolvedBuild) { + await ctx.db.patch(error._id, { resolved: true, status: "invalidated" }); + } + }, +}); + +export const insertAssistantMessage = internalMutation({ + args: { + projectId: v.id("project"), + content: v.optional(v.string()), + object: v.optional(v.string()), + toolCallName: v.optional(v.string()), + result: v.optional(v.string()), + coreMessage: v.optional(v.string()), + streaming: v.optional(v.boolean()), + modelSemanticName: v.optional(v.string()), + excludeFromAgentHistory: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + const messageId = await ctx.db.insert("messages", { + project_id: args.projectId, + role: "assistant", + content: truncateKeepEnd(args.content ?? "", MAX_CONTENT_SIZE, "content"), + object: + args.object !== undefined + ? truncateObjectJson(args.object, MAX_OBJECT_SIZE) + : undefined, + tool_call: args.toolCallName, + result: + args.result !== undefined + ? truncateKeepBoth(args.result, MAX_RESULT_SIZE, "result") + : undefined, + core_message: + args.coreMessage !== undefined + ? truncateKeepEnd(args.coreMessage, MAX_CONTENT_SIZE, "core_message") + : undefined, + date: Date.now(), + streaming: args.streaming ?? false, + deactivated: false, + ...(project?.active_thread && { thread_id: project.active_thread }), + ...(args.excludeFromAgentHistory !== undefined && { + exclude_from_agent_history: args.excludeFromAgentHistory, + }), + ...(args.modelSemanticName && { + model_semantic_name: args.modelSemanticName, + }), + }); + return messageId; + }, +}); + +export const insertUserMessage = internalMutation({ + args: { + projectId: v.id("project"), + content: v.string(), + images: v.optional(v.array(v.id("_storage"))), + threadId: v.optional(v.id("thread")), + }, + handler: async (ctx, args) => { + const messageId = await ctx.db.insert("messages", { + project_id: args.projectId, + streaming: false, + role: "user", + content: truncateKeepEnd(args.content, MAX_CONTENT_SIZE, "content"), + date: Date.now(), + selected_entry_point_ids: [], + deactivated: false, + ...(args.images && { images: args.images }), + ...(args.threadId && { thread_id: args.threadId }), + }); + return messageId; + }, +}); + +export const updateCommitHash = internalMutation({ + args: { + messageId: v.id("messages"), + commitHash: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.messageId, { + commit_hash: args.commitHash, + }); + }, +}); + +/** + * a mutation used to mark messages as deactivated when code is reverted + * given a thread ID and a commit hash, this will mark all messages after + * that commit hash as deactivated + * + * Uses a new index to quickly find the commit, then schedules batch processing + * to avoid memory limits (messages can be several MB each) + */ +export const deactivateMessagesAfterCommit = internalMutation({ + args: { + threadId: v.id("thread"), + commitHash: v.string(), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Get the thread to validate it exists + const thread = await ctx.db.get(args.threadId); + if (!thread) { + throw new Error("Thread not found"); + } + + // Use the new index to directly find the message with this commit hash + const commitMessage = await ctx.db + .query("messages") + .withIndex("by_thread_and_commit", (q) => + q + .eq("thread_id", args.threadId) + .eq("commit_hash", args.commitHash) + .eq("streaming", false), + ) + .first(); + + if (!commitMessage) { + throw new Error("Commit hash not found in thread messages"); + } + + // Schedule the deactivation process to run in small batches + await ctx.scheduler.runAfter(0, internal.messages.deactivateMessagesBatch, { + threadId: args.threadId, + commitMessageTime: commitMessage._creationTime, + // Start directly at the rollback boundary so we only touch messages that + // can actually be deactivated. + lastCreationTime: commitMessage._creationTime - 1, + }); + + return null; + }, +}); + +/** + * Internal helper that deactivates messages in tiny batches + * Schedules itself recursively to process all messages without hitting memory limits + * Each message can be several MB (code content, reasoning, etc.), so we process 2-3 at a time + * + * OPTIMIZATION: Uses indexed range query on _creationTime (automatically appended to every + * Convex index) to efficiently skip already-processed messages. This reduces document reads + * from ~145k (table scan with .filter()) to ~100 (only messages actually processed). + */ +export const deactivateMessagesBatch = internalMutation({ + args: { + threadId: v.id("thread"), + commitMessageTime: v.number(), + lastCreationTime: v.number(), + }, + returns: v.null(), + handler: async (ctx, args) => { + // Process only 2 messages per batch to stay well under the 16MB limit + // Even with massive code content, 2 messages should fit + // OPTIMIZATION: Use range query on _creationTime (auto-appended to every index) + // instead of .filter() to avoid table scans. We start from the rollback + // boundary, so we only read the tail of the thread that needs patching. + const batch = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q + .eq("thread_id", args.threadId) + .eq("streaming", false) + .gt("_creationTime", args.lastCreationTime), + ) + .order("asc") + .take(2); + + if (batch.length === 0) { + // Done processing all messages + return null; + } + + // Every message in this batch is already at or after the rollback point. + for (const message of batch) { + await ctx.db.patch(message._id, { + deactivated: true, + }); + } + + // Schedule the next batch immediately + await ctx.scheduler.runAfter(0, internal.messages.deactivateMessagesBatch, { + threadId: args.threadId, + commitMessageTime: args.commitMessageTime, + lastCreationTime: batch[batch.length - 1]._creationTime, + }); + + return null; + }, +}); + +/** + * Deactivates a specific message and all messages after it in the thread + * Used when user wants to undo a message that doesn't have a checkpoint yet + */ +export const deactivateMessageAndAfter = mutation({ + args: { + messageId: v.id("messages"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const message = await ctx.db.get(args.messageId); + if (!message) { + throw new Error("Message not found"); + } + + // Terminate any active processing on the project + await ctx.db.patch(message.project_id, { + terminated: true, + state: "active", + }); + + // Use the message's creation time to deactivate all messages from this point onwards + const targetCreationTime = message._creationTime; + + // Deactivate this message and all messages after it using Convex pagination + let continueCursor: string | null = null; + let isDone = false; + + while (!isDone) { + const result = await ctx.db + .query("messages") + .withIndex("by_project_and_date", (q) => + q.eq("project_id", message.project_id), + ) + .order("asc") + .paginate({ numItems: 100, cursor: continueCursor }); + + for (const msg of result.page) { + if (msg._creationTime >= targetCreationTime) { + await ctx.db.patch(msg._id, { + deactivated: true, + }); + } + } + + isDone = result.isDone; + continueCursor = result.continueCursor; + } + + return null; + }, +}); + +// File upload functions for images +export const generateUploadUrl = mutation({ + handler: async (ctx) => { + return await ctx.storage.generateUploadUrl(); + }, +}); + +export const deleteImage = mutation({ + args: { storageId: v.id("_storage") }, + handler: async (ctx, args) => { + await ctx.storage.delete(args.storageId); + }, +}); + +// Batch-resolves stored images to their serving URLs, in the same order as the +// input. Used by the Freebuff chat server to hydrate message attachments at +// read time (one round-trip per thread load). An entry is null if that file no +// longer exists. +export const getImageUrls = query({ + args: { storageIds: v.array(v.id("_storage")) }, + returns: v.array(v.union(v.string(), v.null())), + handler: async (ctx, args) => { + return await Promise.all( + args.storageIds.map((id) => ctx.storage.getUrl(id)), + ); + }, +}); + +// Best-effort batch delete, used by the Freebuff chat server to clean up image +// blobs when a thread is deleted. Already-deleted ids are ignored so a partial +// prior cleanup can be retried safely. +export const deleteImages = mutation({ + args: { storageIds: v.array(v.id("_storage")) }, + returns: v.null(), + handler: async (ctx, args) => { + await Promise.all( + args.storageIds.map(async (id) => { + try { + await ctx.storage.delete(id); + } catch { + // File already gone; nothing to do. + } + }), + ); + return null; + }, +}); + +export const getMessages = internalQuery({ + args: { + threadId: v.optional(v.id("thread")), + }, + handler: async (ctx, args) => { + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("desc") + .filter((q) => q.neq(q.field("deactivated"), true)) + .filter((q) => q.neq(q.field("isFastReturn"), true)) + .filter((q) => q.neq(q.field("exclude_from_agent_history"), true)) + .take(40); + + return messages.map(serializeMessageForSummary); + }, +}); + +export const getLatestFastReturnMessage = internalQuery({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + const fastReturnMessage = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("desc") + .filter((q) => q.eq(q.field("isFastReturn"), true)) + .filter((q) => q.neq(q.field("deactivated"), true)) + .first(); + + return fastReturnMessage; + }, +}); + +export const getMostRecentAssistantMessage = internalQuery({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + const message = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("desc") + .filter((q) => q.eq(q.field("role"), "assistant")) + .filter((q) => q.neq(q.field("deactivated"), true)) + .filter((q) => q.neq(q.field("exclude_from_agent_history"), true)) + .first(); + + return message; + }, +}); diff --git a/freebuff/web/convex/migrations.ts b/freebuff/web/convex/migrations.ts new file mode 100644 index 0000000000..5ee30aa89d --- /dev/null +++ b/freebuff/web/convex/migrations.ts @@ -0,0 +1,642 @@ +import { + internalAction, + internalQuery, + internalMutation, + action, +} from "!/_generated/server.js"; +import { Migrations } from "@convex-dev/migrations"; +import { v } from "convex/values"; +import { getRootDomain } from "../lib/utils.js"; +import { components, internal } from "./_generated/api.js"; +import type { DataModel, Id } from "./_generated/dataModel.js"; +import { + allUsers, + usersByRole, + usersByTier, + usersByDay, + allProjects, + projectsByDay, + cloudProjectsByTypeDay, + allConvexInstances, + pausedProjectsByActive, + pausedUsersByActive, +} from "./aggregates/admin_aggregates.js"; + +export const migrations = new Migrations<DataModel>(components.migrations); + +export const setStreamingState = migrations.define({ + table: "messages", + migrateOne: async (ctx, doc) => { + if (doc.streaming === undefined) { + await ctx.db.patch(doc._id, { streaming: false }); + } + }, +}); + +// Old migration - replaced with custom implementation below due to 16MB document limit +// export const setDeactivatedDefault = migrations.define({ +// table: "messages", +// batchSize: 100, +// migrateOne: async (ctx, doc) => { +// if (doc.deactivated === undefined) { +// await ctx.db.patch(doc._id, { deactivated: false }); +// } +// }, +// }); + +// Custom migration to handle large messages without hitting 16MB read limit +export const setDeactivatedDefaultCustom = internalAction({ + args: { + cursor: v.union(v.string(), v.null()), + batchSize: v.optional(v.number()), + }, + returns: v.object({ + processed: v.number(), + updated: v.number(), + errors: v.number(), + isDone: v.boolean(), + nextCursor: v.union(v.string(), v.null()), + }), + handler: async (ctx, args) => { + const batchSize = args.batchSize ?? 100; + let processedCount = 0; + let updatedCount = 0; + let errorCount = 0; + const errors: string[] = []; + + try { + // Use pagination to process messages in batches + const result: { + messageIds: Array<any>; + isDone: boolean; + nextCursor: string | null; + } = await ctx.runQuery(internal.migrations.getMessagesForMigration, { + cursor: args.cursor, + batchSize, + }); + + for (const msgId of result.messageIds) { + try { + await ctx.runMutation(internal.migrations.updateMessageDeactivated, { + messageId: msgId, + }); + updatedCount++; + } catch (error: any) { + errorCount++; + errors.push(`${msgId}: ${error.message}`); + console.error(`Failed to update message ${msgId}:`, error); + } + processedCount++; + } + + console.log( + `Processed ${processedCount} messages, updated ${updatedCount}, errors ${errorCount}`, + ); + if (errors.length > 0) { + console.log("Errors:", errors.slice(0, 10)); // Log first 10 errors + } + + return { + processed: processedCount, + updated: updatedCount, + errors: errorCount, + isDone: result.isDone, + nextCursor: result.nextCursor, + }; + } catch (error: any) { + console.error("Migration batch failed:", error); + throw error; + } + }, +}); + +// Query to get message IDs that need migration (only reads _id and deactivated) +export const getMessagesForMigration = internalQuery({ + args: { + cursor: v.union(v.string(), v.null()), + batchSize: v.number(), + }, + handler: async (ctx, args) => { + const result = await ctx.db + .query("messages") + .paginate( + args.cursor + ? ({ numItems: args.batchSize, cursor: args.cursor } as any) + : { numItems: args.batchSize }, + ); + + // Filter to only get messages where deactivated is undefined + const messageIds = result.page + .filter((msg) => msg.deactivated === undefined) + .map((msg) => msg._id); + + return { + messageIds, + isDone: result.isDone, + nextCursor: result.continueCursor, + }; + }, +}); + +// Mutation to update a single message (minimal read/write) +export const updateMessageDeactivated = internalMutation({ + args: { + messageId: v.id("messages"), + }, + handler: async (ctx, args) => { + // Only patch the field, don't read the full document + await ctx.db.patch(args.messageId, { deactivated: false }); + }, +}); + +// Runner to process all messages to completion +export const runDeactivatedMigrationComplete = internalAction({ + args: {}, + returns: v.object({ + totalProcessed: v.number(), + totalUpdated: v.number(), + totalErrors: v.number(), + }), + handler: async (ctx) => { + let cursor: string | null = null; + let totalProcessed = 0; + let totalUpdated = 0; + let totalErrors = 0; + + console.log("🚀 Starting deactivated field migration..."); + + while (true) { + const result: { + processed: number; + updated: number; + errors: number; + isDone: boolean; + nextCursor: string | null; + } = await ctx.runAction(internal.migrations.setDeactivatedDefaultCustom, { + cursor: cursor, + batchSize: 100, + }); + + totalProcessed += result.processed; + totalUpdated += result.updated; + totalErrors += result.errors; + + console.log( + `📊 Progress: ${totalProcessed} processed, ${totalUpdated} updated, ${totalErrors} errors`, + ); + + if (result.isDone) { + console.log("✅ Migration complete!"); + break; + } + + cursor = result.nextCursor ?? null; + } + + return { + totalProcessed, + totalUpdated, + totalErrors, + }; + }, +}); + +/** + * The convex project creation logic incorrectly used to incorrectly set the + * prodDeploymentName to the devDeploymentName. This migration sets it to null. + * The prodDeploymentName should be filled in when the deployment is created (this + * happens when the user deploys the project for the first time). + */ +export const setProdDeploymentNameToNull = migrations.define({ + table: "project_convex_instance", + migrateOne: async (ctx, doc) => { + await ctx.db.patch(doc._id, { + prodDeploymentName: null, + }); + }, +}); + +export const migrateDeployKeys = internalAction({ + args: { + project: v.object({ + _id: v.id("project"), + _creationTime: v.string(), + semantic_identifier: v.string(), + sandbox_id: v.string(), + convex_url: v.optional(v.string()), + }), + skipSave: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const result = await fetch(`${process.env.MIGRATION_SERVER_URL}/migrate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(args.project), + }); + + if (args.skipSave) { + return; + } + + const body = await result.json(); + + // If migration server didn't return valid Convex data, skip saving + // VLY Convex is already created at project assignment time in create.ts + if (!body.convexProjectId || !body.devDeploymentName) { + console.log( + "[migrateDeployKeys] No existing Convex data found, skipping (VLY Convex already created in create.ts):", + args.project._id, + ); + return; + } + + await ctx.runMutation(internal.convex_instance.save, { + projectId: args.project._id, + convexProjectId: body.convexProjectId, + devDeploymentName: body.devDeploymentName, + prodDeploymentName: body.prodDeploymentName, + upsert: true, + }); + + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.project._id, + }); + + if (project && project.state !== "processing") { + await ctx.runMutation( + internal.coding_agent.trigger.internal_saveMessageAndStartWorkflow, + { + projectId: args.project._id, + message: "Please run the typecheck and fix any errors if present.", + agentMode: "POWERFUL", + }, + ); + } + }, +}); + +export const migrateDeployKeysPublic = action({ + args: { + projectId: v.optional(v.id("project")), + project: v.optional(v.any()), + skipSave: v.optional(v.boolean()), + }, + returns: v.null(), + handler: async (ctx, args) => { + const projectId = + args.projectId ?? + (args.project as { _id?: Id<"project"> } | undefined)?._id; + + if (!projectId) { + throw new Error("projectId (or project._id) is required"); + } + + const project = await ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + await ctx.runAction(internal.migrations.migrateDeployKeys, { + project: { + _id: project._id, + _creationTime: String(project._creationTime), + semantic_identifier: project.semantic_identifier, + sandbox_id: project.sandbox_id, + convex_url: project.convex_url, + }, + skipSave: args.skipSave, + }); + return null; + }, +}); + +export const setRootDomains = migrations.define({ + table: "domain", + migrateOne: async (ctx, doc) => { + await ctx.db.patch(doc._id, { + rootDomain: getRootDomain(doc.domain), + }); + }, +}); + +export const setWildcardVerified = migrations.define({ + table: "domain", + migrateOne: async (ctx, doc) => { + const rootDomain = getRootDomain(doc.domain); + const matchingRootDomainRecords = await ctx.db + .query("domain") + .withIndex("by_rootDomain", (q) => q.eq("rootDomain", rootDomain)) + .collect(); + if (matchingRootDomainRecords.some((d) => d.wildcard_cert_generated)) { + await ctx.db.patch(doc._id, { + wildcard_cert_generated: true, + }); + } + }, +}); + +// Refresh unified search_text for integrations so search indexes reindex consistently +export const refreshIntegrationSearchText = migrations.define({ + table: "integration", + migrateOne: async (ctx, doc) => { + const tags: Array<string> = Array.isArray((doc as any).tags) + ? ((doc as any).tags as Array<string>) + : []; + const title: string = (doc as any).title ?? ""; + const description: string = (doc as any).description ?? ""; + const computed = `${title} ${description} ${tags.join(" ")}`.toLowerCase(); + if ((doc as any).search_text !== computed) { + await ctx.db.patch(doc._id, { search_text: computed }); + } + }, +}); + +export const lowercaseDomains = migrations.define({ + table: "domain", + migrateOne: async (ctx, doc) => { + await ctx.db.patch(doc._id, { + domain: doc.domain.toLowerCase(), + }); + }, +}); + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +// Backfill referred_user_email snapshots so referral spin dedupe survives +// account deletion/re-creation for legacy rows. +export const backfillReferralSpinEmails = migrations.define({ + table: "referral_spins", + batchSize: 200, + migrateOne: async (ctx, doc) => { + if (doc.source !== "referral" || doc.referred_user_email !== undefined) { + return; + } + + if (!doc.referred_user_id) { + return; + } + + const referredUser = await ctx.db.get(doc.referred_user_id); + if (!referredUser) { + return; + } + + await ctx.db.patch(doc._id, { + referred_user_email: normalizeEmail(referredUser.email), + }); + }, +}); + +export const runIntegrations = migrations.runner( + internal.migrations.refreshIntegrationSearchText, +); + +export const runReferralSpinEmailBackfill = migrations.runner( + internal.migrations.backfillReferralSpinEmails, +); + +// Migrate every project's `pretty_preview_url` to the canonical +// `https://<semantic_identifier>.freebuff.dev` form. +// +// History: +// - Old code wrote `https://<slug>.vly.sh` (legacy data still in DB). +// - Current `assignProxy` writes `https://<slug>.freebuff.dev` +// (createProject.ts:40), so newly-created projects are already correct. + +export const migratePrettyPreviewUrlToFreebuffDev = migrations.define({ + table: "project", + batchSize: 200, + migrateOne: async (ctx, doc) => { + const desired = `https://${doc.semantic_identifier}.freebuff.dev`; + const current = doc.pretty_preview_url; + + // case A: already the canonical .freebuff.dev URL — skip. + if (current === desired) return; + + // case B/C: legacy .vly.sh URL or null/undefined — rewrite/backfill. + const isLegacyVlySh = + typeof current === "string" && current.endsWith(".vly.sh"); + const isEmpty = current === undefined || current === null || current === ""; + + if (!isLegacyVlySh && !isEmpty) return; + + await ctx.db.patch(doc._id, { + pretty_preview_url: desired, + }); + }, +}); + +export const runMigratePrettyPreviewUrlToFreebuffDev = migrations.runner( + internal.migrations.migratePrettyPreviewUrlToFreebuffDev, +); + +// Backfill migration for users aggregate +export const backfillUsersAggregate = migrations.define({ + table: "users", + batchSize: 100, + migrateOne: async (ctx, doc) => { + // Insert each user document into the aggregates + // The aggregates will automatically index by: + // - allUsers (no partitioning) + // - usersByRole (by role) + // - usersByTier (by tier) + // - usersByDay (by creation day) + + // Wrap in try-catch to handle duplicate key errors from concurrent migration workers + try { + await allUsers.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + + try { + await usersByRole.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + + try { + await usersByTier.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + + try { + await usersByDay.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + }, +}); + +// Backfill migration for projects aggregate +export const backfillProjectsAggregate = migrations.define({ + table: "project", + batchSize: 100, + migrateOne: async (ctx, doc) => { + // Wrap in try-catch to handle duplicate key errors from concurrent migration workers + try { + await allProjects.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + + try { + await projectsByDay.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + + try { + await cloudProjectsByTypeDay.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + }, +}); + +// Backfill migration for convex instances aggregate +export const backfillConvexInstancesAggregate = migrations.define({ + table: "project_convex_instance", + batchSize: 100, + migrateOne: async (ctx, doc) => { + // Wrap in try-catch to handle duplicate key errors from concurrent migration workers + try { + await allConvexInstances.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + }, +}); + +// Backfill migration for paused projects aggregate +export const backfillPausedProjectsAggregate = migrations.define({ + table: "paused_projects", + batchSize: 100, + migrateOne: async (ctx, doc) => { + // Wrap in try-catch to handle duplicate key errors from concurrent migration workers + try { + await pausedProjectsByActive.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + }, +}); + +// Backfill migration for paused users aggregate +export const backfillPausedUsersAggregate = migrations.define({ + table: "paused_users", + batchSize: 100, + migrateOne: async (ctx, doc) => { + // Wrap in try-catch to handle duplicate key errors from concurrent migration workers + try { + await pausedUsersByActive.insert(ctx, doc); + } catch (error: any) { + if (!error?.message?.includes("already exists")) throw error; + } + }, +}); + +// Clear all aggregate data structures (for resetting corrupt/stale data) +export const clearAllAggregates = internalAction({ + args: {}, + handler: async (ctx) => { + // Clear each aggregate to remove any corrupt/stale data + await ctx.runMutation( + internal.aggregates.admin_aggregates.clearAllAggregates, + {}, + ); + + console.log("✅ All aggregates cleared successfully"); + return { success: true }; + }, +}); + +// Run all backfill migrations in sequence (one batch at a time - for manual control) +export const backfillAllAggregatesManual = migrations.runner([ + internal.migrations.backfillUsersAggregate, + internal.migrations.backfillProjectsAggregate, + internal.migrations.backfillConvexInstancesAggregate, + internal.migrations.backfillPausedProjectsAggregate, + internal.migrations.backfillPausedUsersAggregate, +]); + +// Run all backfill migrations to completion automatically +export const backfillAllAggregates = internalAction({ + args: { + clearFirst: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const migrationRefs = [ + internal.migrations.backfillUsersAggregate, + internal.migrations.backfillProjectsAggregate, + internal.migrations.backfillConvexInstancesAggregate, + internal.migrations.backfillPausedProjectsAggregate, + internal.migrations.backfillPausedUsersAggregate, + ]; + + if (args.clearFirst) { + console.log("🗑️ Clearing all aggregates first..."); + await ctx.runAction(internal.migrations.clearAllAggregates, {}); + + console.log("🚀 Starting backfill from beginning (cursor: null)..."); + // Run each migration to completion with cursor: null to force restart + for (const migrationRef of migrationRefs) { + let cursor: string | null = null; + + while (true) { + const result = await migrations.runOne(ctx, migrationRef, { cursor }); + + if (result.isDone) { + console.log( + ` ✅ Completed migration, processed ${result.processed} records`, + ); + break; + } + + cursor = result.cursor ?? null; + } + } + } else { + console.log("🚀 Resuming backfill from saved cursor state..."); + + // Loop runSerially until all migrations complete + while (true) { + const result = await migrations.runSerially(ctx, migrationRefs); + + if (!result) { + throw new Error( + "runSerially returned undefined - no migrations provided?", + ); + } + + if (result.isDone) { + console.log(" ✅ All migrations in series complete"); + break; + } + + console.log( + ` 📊 Processed ${result.processed || 0} records, continuing...`, + ); + } + } + + console.log("✅ All aggregates backfilled successfully!"); + return { success: true }; + }, +}); + +// Old runner - replaced with runDeactivatedMigrationComplete +// export const runDeactivatedMigration = migrations.runner( +// internal.migrations.setDeactivatedDefault, +// ); + +export const run = migrations.runner(); diff --git a/freebuff/web/convex/monitoring.ts b/freebuff/web/convex/monitoring.ts new file mode 100644 index 0000000000..8c96201ab1 --- /dev/null +++ b/freebuff/web/convex/monitoring.ts @@ -0,0 +1,2438 @@ +"use node"; + +import { Axiom } from "@axiomhq/js"; +import { action, internalAction } from "./_generated/server"; +import { v } from "convex/values"; +import { api, internal } from "./_generated/api"; +import { getAuthUser } from "./users"; +import { + calculateCosts, + type CostBreakdown, + type UsageMetrics, +} from "./lib/convex_pricing"; +import { createDeployKey, ensureWebhookConfigured } from "./convex_management"; +import { initializeCodebase } from "../codebase-utils/codebase/initializeCodebase"; +import { + hasSandboxStats, + type SandboxStats, +} from "../codebase-utils/codebase/Codebase"; + +/** + * Sanitizes a string for safe use in APL queries by escaping single quotes. + * APL/KQL uses doubled single quotes ('') to represent a literal single quote within a string literal. + * Also validates that the deployment name matches expected Convex format. + * + * @param value - The string value to sanitize + * @returns The sanitized string safe for interpolation into APL queries + * @throws Error if the value contains suspicious patterns + */ +function sanitizeForAplQuery(value: string): string { + // Validate deployment name format (Convex deployment names are alphanumeric with hyphens) + // This is a defense-in-depth measure + if (!/^[a-z0-9-]+$/.test(value)) { + throw new Error( + `Invalid deployment name format: ${value}. Deployment names should only contain lowercase letters, numbers, and hyphens.`, + ); + } + + // Escape single quotes by doubling them (APL/KQL string literal escaping) + // This prevents breaking out of the string literal context + return value.replace(/'/g, "''"); +} + +/** + * Retry helper for transient errors (like 503) + */ +async function retryWithBackoff<T>( + fn: () => Promise<T>, + maxRetries: number = 3, + initialDelayMs: number = 1000, +): Promise<T> { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Check if this is a retryable error (503, network issues, etc.) + const isRetryable = + error instanceof Error && + (error.message.includes("503") || + error.message.includes("Service Unavailable") || + error.message.includes("ECONNREFUSED") || + error.message.includes("ETIMEDOUT") || + error.message.includes("network")); + + // If not retryable or out of retries, throw immediately + if (!isRetryable || attempt === maxRetries) { + throw lastError; + } + + // Calculate exponential backoff delay + const delayMs = initialDelayMs * Math.pow(2, attempt); + console.log( + `[Retry] Attempt ${attempt + 1}/${maxRetries} failed, retrying in ${delayMs}ms...`, + ); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw lastError!; +} + +function isAxiomMissingDatasetError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message.toLowerCase() + : String(error).toLowerCase(); + return ( + message.includes("unable to find dataset") || + message.includes("dataset not found") + ); +} + +/** + * Fetches metrics for a single deployment from Axiom with retry logic + */ +async function fetchDeploymentMetrics( + axiom: Axiom, + deploymentName: string, + options?: { startTime?: string; endTime?: string }, +) { + const sanitizedName = sanitizeForAplQuery(deploymentName); + + // Build time filter if provided + let timeFilter = ""; + if (options?.startTime || options?.endTime) { + const conditions = []; + if (options.startTime) { + const startTime = new Date(options.startTime); + if (!Number.isNaN(startTime.getTime())) { + conditions.push( + `todatetime(aggregated_at) >= datetime('${startTime.toISOString()}')`, + ); + } + } + if (options.endTime) { + const endTime = new Date(options.endTime); + if (!Number.isNaN(endTime.getTime())) { + conditions.push( + `todatetime(aggregated_at) <= datetime('${endTime.toISOString()}')`, + ); + } + } + timeFilter = + conditions.length > 0 ? `| where ${conditions.join(" and ")}` : ""; + } + + const apl = ` + ['convex-user-usage'] + | where deployment_name == '${sanitizedName}' + ${timeFilter} + | sort by aggregated_at desc + | limit 1000 + `; + + const result = await retryWithBackoff( + async () => await axiom.query(apl), + 3, // max 3 retries + 1000, // start with 1 second delay + ).catch((error) => { + if (isAxiomMissingDatasetError(error)) { + console.warn( + "[Monitoring] Dataset 'convex-user-usage' not found; returning empty metrics.", + ); + return { + matches: [], + }; + } + throw error; + }); + const matches = result.matches || []; + + // Calculate aggregated metrics for this deployment + let totalExecutions = 0; + let totalExecutionTime = 0; + let maxActionMemory = 0; + let totalDbReadBytes = 0; + let totalDbReadDocuments = 0; + let totalDbWriteBytes = 0; + let totalFileStorageReadBytes = 0; + let totalFileStorageWriteBytes = 0; + + const timeSeriesData = matches.map((match: any) => { + const data = match.data; + totalExecutions += data.execution_count || 0; + totalExecutionTime += data.execution_time_ms || 0; + maxActionMemory = Math.max( + maxActionMemory, + data.action_memory_used_mb || 0, + ); + totalDbReadBytes += data.db_read_bytes || 0; + totalDbReadDocuments += data.db_read_documents || 0; + totalDbWriteBytes += data.db_write_bytes || 0; + totalFileStorageReadBytes += data.file_storage_read_bytes || 0; + totalFileStorageWriteBytes += data.file_storage_write_bytes || 0; + + // Calculate costs for this time period + const periodCosts = calculateCosts({ + executionCount: data.execution_count || 0, + executionTimeMs: data.execution_time_ms || 0, + actionMemoryUsedMb: data.action_memory_used_mb || 0, + dbReadBytes: data.db_read_bytes || 0, + dbWriteBytes: data.db_write_bytes || 0, + fileStorageReadBytes: data.file_storage_read_bytes || 0, + fileStorageWriteBytes: data.file_storage_write_bytes || 0, + } satisfies UsageMetrics); + + return { + timestamp: data.aggregated_at, + executionCount: data.execution_count || 0, + executionTimeMs: data.execution_time_ms || 0, + avgExecutionTimeMs: data.avg_execution_time_ms || 0, + actionMemoryUsedMb: data.action_memory_used_mb || 0, + dbReadBytes: data.db_read_bytes || 0, + dbReadDocuments: data.db_read_documents || 0, + dbWriteBytes: data.db_write_bytes || 0, + fileStorageReadBytes: data.file_storage_read_bytes || 0, + fileStorageWriteBytes: data.file_storage_write_bytes || 0, + costs: periodCosts, + }; + }); + + return { + totalExecutions, + totalExecutionTime, + maxActionMemory, + totalDbReadBytes, + totalDbReadDocuments, + totalDbWriteBytes, + totalFileStorageReadBytes, + totalFileStorageWriteBytes, + timeSeriesData: timeSeriesData.reverse(), // Oldest to newest + lastUpdated: matches.length > 0 ? matches[0].data.aggregated_at : null, + }; +} + +/** + * Merges time series data from multiple deployments by timestamp + */ +function _mergeTimeSeries( + series1: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + }>, + series2: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + }>, +) { + // Create a map of timestamp to combined metrics + const timeMap = new Map< + string, + { + executionCount: number; + executionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + } + >(); + + // Add series1 data + for (const point of series1) { + timeMap.set(point.timestamp, { + executionCount: point.executionCount, + executionTimeMs: point.executionTimeMs, + actionMemoryUsedMb: point.actionMemoryUsedMb, + dbReadBytes: point.dbReadBytes, + dbReadDocuments: point.dbReadDocuments, + dbWriteBytes: point.dbWriteBytes, + fileStorageReadBytes: point.fileStorageReadBytes, + fileStorageWriteBytes: point.fileStorageWriteBytes, + }); + } + + // Merge series2 data + for (const point of series2) { + const existing = timeMap.get(point.timestamp); + if (existing) { + // Combine metrics for the same timestamp + existing.executionCount += point.executionCount; + existing.executionTimeMs += point.executionTimeMs; + existing.actionMemoryUsedMb = Math.max( + existing.actionMemoryUsedMb, + point.actionMemoryUsedMb, + ); + existing.dbReadBytes += point.dbReadBytes; + existing.dbReadDocuments += point.dbReadDocuments; + existing.dbWriteBytes += point.dbWriteBytes; + existing.fileStorageReadBytes += point.fileStorageReadBytes; + existing.fileStorageWriteBytes += point.fileStorageWriteBytes; + } else { + // New timestamp + timeMap.set(point.timestamp, { + executionCount: point.executionCount, + executionTimeMs: point.executionTimeMs, + actionMemoryUsedMb: point.actionMemoryUsedMb, + dbReadBytes: point.dbReadBytes, + dbReadDocuments: point.dbReadDocuments, + dbWriteBytes: point.dbWriteBytes, + fileStorageReadBytes: point.fileStorageReadBytes, + fileStorageWriteBytes: point.fileStorageWriteBytes, + }); + } + } + + // Convert back to array and sort by timestamp + const merged = Array.from(timeMap.entries()) + .map(([timestamp, metrics]) => { + const avgExecutionTimeMs = + metrics.executionCount > 0 + ? metrics.executionTimeMs / metrics.executionCount + : 0; + + const costs = calculateCosts({ + executionCount: metrics.executionCount, + executionTimeMs: metrics.executionTimeMs, + actionMemoryUsedMb: metrics.actionMemoryUsedMb, + dbReadBytes: metrics.dbReadBytes, + dbWriteBytes: metrics.dbWriteBytes, + fileStorageReadBytes: metrics.fileStorageReadBytes, + fileStorageWriteBytes: metrics.fileStorageWriteBytes, + } satisfies UsageMetrics); + + return { + timestamp, + executionCount: metrics.executionCount, + executionTimeMs: metrics.executionTimeMs, + avgExecutionTimeMs, + actionMemoryUsedMb: metrics.actionMemoryUsedMb, + dbReadBytes: metrics.dbReadBytes, + dbReadDocuments: metrics.dbReadDocuments, + dbWriteBytes: metrics.dbWriteBytes, + fileStorageReadBytes: metrics.fileStorageReadBytes, + fileStorageWriteBytes: metrics.fileStorageWriteBytes, + costs, + }; + }) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + return merged; +} + +// Merge time series while preserving deployment type for stacked charts +function mergeTimeSeriesWithDeploymentType( + devSeries: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + }>, + prodSeries: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + }>, +) { + // Collect all unique timestamps + const allTimestamps = new Set<string>(); + devSeries.forEach((point) => allTimestamps.add(point.timestamp)); + prodSeries.forEach((point) => allTimestamps.add(point.timestamp)); + + // Create a result array with separate entries for dev and prod + const result: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + deploymentType: "dev" | "prod"; + }> = []; + + // Add dev series data with deploymentType marker + devSeries.forEach((point) => { + result.push({ + ...point, + deploymentType: "dev" as const, + }); + }); + + // Add prod series data with deploymentType marker + prodSeries.forEach((point) => { + result.push({ + ...point, + deploymentType: "prod" as const, + }); + }); + + // Sort by timestamp (dev and prod entries with same timestamp will be adjacent) + result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + return result; +} + +export const getUsageMetrics = action({ + args: { + projectId: v.id("project"), + deploymentType: v.union( + v.literal("dev"), + v.literal("prod"), + v.literal("all"), + ), + startTime: v.optional(v.string()), + endTime: v.optional(v.string()), + }, + handler: async ( + ctx, + args, + ): Promise<{ + deploymentName: string; + deploymentType?: "dev" | "prod" | "all"; + devDeploymentName?: string; + prodDeploymentName?: string; + summary: { + totalExecutions: number; + avgExecutionTimeMs: number; + totalActionMemoryMb: number; + totalDbReadBytes: number; + totalDbReadDocuments: number; + totalDbWriteBytes: number; + totalFileStorageReadBytes: number; + totalFileStorageWriteBytes: number; + }; + costs: CostBreakdown; + timeSeries: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + deploymentType?: "dev" | "prod"; + }>; + lastUpdated: string | null; + }> => { + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN!, + }); + + try { + if (args.deploymentType === "all") { + // Fetch dev and prod separately and combine + const devDeploymentName = await ctx.runAction( + api.database.convex.getConvexDeploymentName, + { + projectId: args.projectId, + type: "dev", + }, + ); + const convexInstance = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!convexInstance) { + throw new Error("Convex instance not found"); + } + + const prodDeploymentName = convexInstance.prodDeploymentName; + + const timeOptions = + args.startTime || args.endTime + ? { startTime: args.startTime, endTime: args.endTime } + : undefined; + + if (!prodDeploymentName) { + const devMetrics = await fetchDeploymentMetrics( + axiom, + devDeploymentName, + timeOptions, + ); + + const totalCosts = calculateCosts({ + executionCount: devMetrics.totalExecutions, + executionTimeMs: devMetrics.totalExecutionTime, + actionMemoryUsedMb: devMetrics.maxActionMemory, + dbReadBytes: devMetrics.totalDbReadBytes, + dbWriteBytes: devMetrics.totalDbWriteBytes, + fileStorageReadBytes: devMetrics.totalFileStorageReadBytes, + fileStorageWriteBytes: devMetrics.totalFileStorageWriteBytes, + } satisfies UsageMetrics); + + return { + deploymentName: "All Deployments", + deploymentType: "all", + devDeploymentName, + summary: { + totalExecutions: devMetrics.totalExecutions, + avgExecutionTimeMs: + devMetrics.totalExecutions > 0 + ? devMetrics.totalExecutionTime / devMetrics.totalExecutions + : 0, + totalActionMemoryMb: devMetrics.maxActionMemory, + totalDbReadBytes: devMetrics.totalDbReadBytes, + totalDbReadDocuments: devMetrics.totalDbReadDocuments, + totalDbWriteBytes: devMetrics.totalDbWriteBytes, + totalFileStorageReadBytes: devMetrics.totalFileStorageReadBytes, + totalFileStorageWriteBytes: devMetrics.totalFileStorageWriteBytes, + }, + costs: totalCosts, + timeSeries: devMetrics.timeSeriesData.map((point) => ({ + ...point, + deploymentType: "dev" as const, + })), + lastUpdated: devMetrics.lastUpdated, + }; + } + + const [devMetrics, prodMetrics] = await Promise.all([ + fetchDeploymentMetrics(axiom, devDeploymentName, timeOptions), + fetchDeploymentMetrics(axiom, prodDeploymentName, timeOptions), + ]); + + // Combine metrics + const totalExecutions = + devMetrics.totalExecutions + prodMetrics.totalExecutions; + const totalExecutionTime = + devMetrics.totalExecutionTime + prodMetrics.totalExecutionTime; + const maxActionMemory = Math.max( + devMetrics.maxActionMemory, + prodMetrics.maxActionMemory, + ); + const totalDbReadBytes = + devMetrics.totalDbReadBytes + prodMetrics.totalDbReadBytes; + const totalDbReadDocuments = + devMetrics.totalDbReadDocuments + prodMetrics.totalDbReadDocuments; + const totalDbWriteBytes = + devMetrics.totalDbWriteBytes + prodMetrics.totalDbWriteBytes; + const totalFileStorageReadBytes = + devMetrics.totalFileStorageReadBytes + + prodMetrics.totalFileStorageReadBytes; + const totalFileStorageWriteBytes = + devMetrics.totalFileStorageWriteBytes + + prodMetrics.totalFileStorageWriteBytes; + + // Merge time series while preserving deployment type for stacked charts + const mergedTimeSeries = mergeTimeSeriesWithDeploymentType( + devMetrics.timeSeriesData, + prodMetrics.timeSeriesData, + ); + + // Calculate total costs + const totalCosts = calculateCosts({ + executionCount: totalExecutions, + executionTimeMs: totalExecutionTime, + actionMemoryUsedMb: maxActionMemory, + dbReadBytes: totalDbReadBytes, + dbWriteBytes: totalDbWriteBytes, + fileStorageReadBytes: totalFileStorageReadBytes, + fileStorageWriteBytes: totalFileStorageWriteBytes, + } satisfies UsageMetrics); + + const lastUpdated = + devMetrics.lastUpdated && prodMetrics.lastUpdated + ? devMetrics.lastUpdated > prodMetrics.lastUpdated + ? devMetrics.lastUpdated + : prodMetrics.lastUpdated + : devMetrics.lastUpdated || prodMetrics.lastUpdated; + + return { + deploymentName: "All Deployments", + deploymentType: "all", + devDeploymentName, + prodDeploymentName, + summary: { + totalExecutions, + avgExecutionTimeMs: + totalExecutions > 0 ? totalExecutionTime / totalExecutions : 0, + totalActionMemoryMb: maxActionMemory, + totalDbReadBytes, + totalDbReadDocuments, + totalDbWriteBytes, + totalFileStorageReadBytes, + totalFileStorageWriteBytes, + }, + costs: totalCosts, + timeSeries: mergedTimeSeries, + lastUpdated, + }; + } else { + // Fetch single deployment + const deploymentName = await ctx.runAction( + api.database.convex.getConvexDeploymentName, + { + projectId: args.projectId, + type: args.deploymentType, + }, + ); + + const timeOptions = + args.startTime || args.endTime + ? { startTime: args.startTime, endTime: args.endTime } + : undefined; + + const metrics = await fetchDeploymentMetrics( + axiom, + deploymentName, + timeOptions, + ); + + // Calculate total costs + const totalCosts = calculateCosts({ + executionCount: metrics.totalExecutions, + executionTimeMs: metrics.totalExecutionTime, + actionMemoryUsedMb: metrics.maxActionMemory, + dbReadBytes: metrics.totalDbReadBytes, + dbWriteBytes: metrics.totalDbWriteBytes, + fileStorageReadBytes: metrics.totalFileStorageReadBytes, + fileStorageWriteBytes: metrics.totalFileStorageWriteBytes, + } satisfies UsageMetrics); + + return { + deploymentName, + deploymentType: args.deploymentType as "dev" | "prod", + summary: { + totalExecutions: metrics.totalExecutions, + avgExecutionTimeMs: + metrics.totalExecutions > 0 + ? metrics.totalExecutionTime / metrics.totalExecutions + : 0, + totalActionMemoryMb: metrics.maxActionMemory, + totalDbReadBytes: metrics.totalDbReadBytes, + totalDbReadDocuments: metrics.totalDbReadDocuments, + totalDbWriteBytes: metrics.totalDbWriteBytes, + totalFileStorageReadBytes: metrics.totalFileStorageReadBytes, + totalFileStorageWriteBytes: metrics.totalFileStorageWriteBytes, + }, + costs: totalCosts, + timeSeries: metrics.timeSeriesData, + lastUpdated: metrics.lastUpdated, + }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error("[Monitoring] Error querying Axiom:", error); + + // Check if this is a service unavailability error + if ( + errorMessage.includes("503") || + errorMessage.includes("Service Unavailable") + ) { + throw new Error( + "Axiom monitoring service is temporarily unavailable. Please try again in a few moments.", + ); + } + + // Check for authentication errors + if ( + errorMessage.includes("401") || + errorMessage.includes("403") || + errorMessage.includes("Unauthorized") + ) { + throw new Error( + "Authentication error with Axiom. Please check your API token configuration.", + ); + } + + // Check for rate limiting + if (errorMessage.includes("429") || errorMessage.includes("rate limit")) { + throw new Error( + "Axiom rate limit exceeded. Please try again in a few minutes.", + ); + } + + // Generic error + throw new Error( + `Failed to fetch usage metrics: ${errorMessage}. If this persists, please contact support.`, + ); + } + }, +}); + +/** + * Validates and ensures webhooks are configured for a project's Convex deployments. + * This runs in the background when a project is loaded and does not block the user. + * + * For each deployment (dev and prod if it exists): + * 1. Creates a temporary deploy key + * 2. Checks if webhook is already configured + * 3. Configures webhook if missing or inactive + * + * All operations are logged to console and errors don't throw - they're just logged. + */ +export const ensureProjectWebhooks = internalAction({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + try { + // Get the project's convex instance info + const convexInstance = await ctx.runQuery(internal.convex_instance.get, { + projectId: args.projectId, + }); + + if (!convexInstance) { + return { + success: false, + message: "No convex instance found", + results: [], + }; + } + + const results: Array<{ + deploymentType: "dev" | "prod"; + deploymentName: string; + success: boolean; + action: "already_configured" | "configured" | "failed" | "skipped"; + message: string; + }> = []; + + // Validate dev deployment webhook + try { + // Create a temporary deploy key for validation + const devDeployKey = await createDeployKey( + convexInstance.devDeploymentName, + `webhook-validator-${Date.now()}`, + ); + + const devResult = await ensureWebhookConfigured( + convexInstance.devDeploymentName, + devDeployKey, + ); + + // Store the log stream ID if we got one + if (devResult.logStreamId) { + await ctx.runMutation(internal.convex_instance.updateLogStreamIds, { + projectId: args.projectId, + devLogStreamId: devResult.logStreamId, + }); + } + + results.push({ + deploymentType: "dev", + deploymentName: convexInstance.devDeploymentName, + ...devResult, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[WebhookValidator] Error validating dev deployment:`, + errorMessage, + ); + results.push({ + deploymentType: "dev", + deploymentName: convexInstance.devDeploymentName, + success: false, + action: "failed", + message: `Error: ${errorMessage}`, + }); + } + + // Validate prod deployment webhook if it exists + if (convexInstance.prodDeploymentName) { + try { + // Create a temporary deploy key for validation + const prodDeployKey = await createDeployKey( + convexInstance.prodDeploymentName, + `webhook-validator-${Date.now()}`, + ); + + const prodResult = await ensureWebhookConfigured( + convexInstance.prodDeploymentName, + prodDeployKey, + ); + + // Store the log stream ID if we got one + if (prodResult.logStreamId) { + await ctx.runMutation(internal.convex_instance.updateLogStreamIds, { + projectId: args.projectId, + prodLogStreamId: prodResult.logStreamId, + }); + } + + results.push({ + deploymentType: "prod", + deploymentName: convexInstance.prodDeploymentName, + ...prodResult, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[WebhookValidator] Error validating prod deployment:`, + errorMessage, + ); + results.push({ + deploymentType: "prod", + deploymentName: convexInstance.prodDeploymentName, + success: false, + action: "failed", + message: `Error: ${errorMessage}`, + }); + } + } else { + results.push({ + deploymentType: "prod", + deploymentName: "N/A", + success: true, + action: "skipped", + message: "No prod deployment exists", + }); + } + + const allSuccessful = results.every((r) => r.success); + + return { + success: allSuccessful, + message: allSuccessful + ? "All webhooks validated successfully" + : "Some webhooks failed validation (check logs)", + results, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + `[WebhookValidator] Unexpected error during webhook validation:`, + errorMessage, + ); + return { + success: false, + message: `Unexpected error: ${errorMessage}`, + results: [], + }; + } + }, +}); + +/** + * Fetches current sandbox resource stats (CPU, memory, disk usage, etc.) + * Only works for Daytona sandboxes that support the stats interface. + */ +export const getSandboxStats = action({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<SandboxStats | null> => { + try { + // Get the project to access sandbox ID + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + // Initialize codebase + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Check if this codebase supports stats + if (!hasSandboxStats(codebase)) { + return null; + } + + // Fetch stats + const stats = await codebase.getStats(); + return stats; + } catch (error) { + console.error("[SandboxStats] Error fetching sandbox stats:", error); + throw new Error( + `Failed to fetch sandbox stats: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, +}); + +export const getSandboxMetricsHistory = action({ + args: { + projectId: v.id("project"), + startTime: v.optional(v.string()), + endTime: v.optional(v.string()), + }, + handler: async ( + ctx, + args, + ): Promise<{ + sandboxId: string; + timeSeries: Array<{ + timestamp: string; + cpuUsagePercent: number; + cpuLimitCores: number; + memoryUsagePercent: number; + memoryUsedBytes: number; + memoryLimitBytes: number; + diskUsagePercent: number; + diskUsedBytes: number; + diskSizeBytes: number; + diskAvailableBytes: number; + load1min: number; + load5min: number; + load15min: number; + }>; + startTime: string; + endTime: string; + }> => { + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN!, + }); + + const now = new Date(); + const endTime = args.endTime ? new Date(args.endTime) : now; + const startTime = args.startTime + ? new Date(args.startTime) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago + + let sandboxId = ""; + + try { + // Get the project to access sandbox ID + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + throw new Error("Project not found"); + } + + sandboxId = project.sandbox_id; + + // Strip the "daytona:" prefix if present, as Axiom logs use just the UUID + const sandboxUuid = sandboxId.startsWith("daytona:") + ? sandboxId.replace("daytona:", "") + : sandboxId; + + // Build APL query for sandbox metrics + const apl = ` + ['sandbox-user-usage'] + | where sandbox_id == '${sandboxUuid}' + | sort by _time desc + | limit 1000 + `; + + const result = await retryWithBackoff( + async () => await axiom.query(apl), + 3, + 1000, + ); + + const matches = result.matches || []; + + // Transform and filter the data into time series format + const timeSeries = matches + .map((match: any) => { + const data = match.data; + return { + timestamp: data.timestamp, + cpuUsagePercent: data.cpu_usage_percent || 0, + cpuLimitCores: data.cpu_limit_cores || 0, + memoryUsagePercent: data.memory_usage_percent || 0, + memoryUsedBytes: data.memory_used_bytes || 0, + memoryLimitBytes: data.memory_limit_bytes || 0, + diskUsagePercent: data.disk_usage_percent || 0, + diskUsedBytes: data.disk_used_bytes || 0, + diskSizeBytes: data.disk_size_bytes || 0, + diskAvailableBytes: data.disk_available_bytes || 0, + load1min: data.load_1min || 0, + load5min: data.load_5min || 0, + load15min: data.load_15min || 0, + }; + }) + .filter((point) => { + const pointTime = new Date(point.timestamp).getTime(); + return ( + pointTime >= startTime.getTime() && pointTime <= endTime.getTime() + ); + }) + .sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + + return { + sandboxId, + timeSeries, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + } catch (error) { + if (isAxiomMissingDatasetError(error)) { + console.warn( + "[SandboxMetricsHistory] Dataset 'sandbox-user-usage' not found; returning empty time series.", + ); + return { + sandboxId, + timeSeries: [], + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + }; + } + + console.error("[SandboxMetricsHistory] Error fetching metrics:", error); + + // Check for authentication errors + if ( + error instanceof Error && + (error.message.includes("401") || error.message.includes("403")) + ) { + throw new Error( + "Authentication error with Axiom. Please check your API token configuration.", + ); + } + + throw new Error( + `Failed to fetch sandbox metrics history: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, +}); + +/** + * Get incident dashboard summary - error rates, affected users, top failing functions + */ +export const getIncidentDashboard = action({ + args: { + dataset: v.optional(v.string()), + timeRangeMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Verify god role + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + console.error(`[IncidentDashboard] Unauthorized access attempt`); + throw new Error("Unauthorized: God role required"); + } + + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN_ADMIN!, + }); + + try { + const dataset = args.dataset || "vly-convex"; + const timeRangeMs = args.timeRangeMs || 60 * 60 * 1000; // Default 1 hour + + const now = new Date(); + const startTime = new Date(now.getTime() - timeRangeMs); + + // convex-user-usage doesn't have individual failure events - return empty + if (dataset === "convex-user-usage") { + return { + timeRange: { + startTime: startTime.toISOString(), + endTime: now.toISOString(), + durationMs: timeRangeMs, + }, + summary: { + totalErrors: 0, + affectedDeploymentsCount: 0, + }, + topFailingFunctions: [], + affectedDeployments: [], + }; + } + + // vly-convex dataset - detailed failure tracking + // Query for total error count + const errorCountApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | where ['data.status'] == 'failure' + | summarize errorCount = count() + `; + + const errorCountResult = await retryWithBackoff( + async () => await axiom.query(errorCountApl), + 3, + 1000, + ); + + // Query for affected deployments + const affectedUsersApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend deploymentName = ensure_field("convex.deployment_name", typeof(string)) + | where ['data.status'] == 'failure' + | summarize errorCount = count() by deploymentName + | order by errorCount desc + | limit 10 + `; + + const affectedUsersResult = await retryWithBackoff( + async () => await axiom.query(affectedUsersApl), + 3, + 1000, + ); + + // Query for top failing functions + const topFailingApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | where ['data.status'] == 'failure' + | summarize failureCount = count() by function_path + | order by failureCount desc + | limit 10 + `; + + const topFailingResult = await retryWithBackoff( + async () => await axiom.query(topFailingApl), + 3, + 1000, + ); + + // Get total error count from buckets (summarize results go here, not in matches) + const totalErrors = + errorCountResult.buckets?.totals?.[0]?.aggregations?.find( + (agg: any) => agg.op === "errorCount", + )?.value || 0; + + // Format top failing functions - extract from buckets.totals where grouped results are stored + const topFailingFunctions = (topFailingResult.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const failureCount = total.aggregations?.find( + (agg: any) => agg.op === "failureCount", + )?.value; + + return { + functionPath: functionPath || "unknown", + failureCount: failureCount || 0, + }; + }) + .filter((fn: any) => fn.functionPath !== "unknown") + .sort((a: any, b: any) => b.failureCount - a.failureCount); + + // Format affected deployments - extract from buckets.totals where grouped results are stored + const affectedDeployments = (affectedUsersResult.buckets?.totals || []) + .map((total: any) => { + const deploymentName = total.group?.deploymentName; + const errorCount = total.aggregations?.find( + (agg: any) => agg.op === "errorCount", + )?.value; + + return { + deploymentName: deploymentName || "unknown", + errorCount: errorCount || 0, + }; + }) + .filter((dep: any) => dep.deploymentName !== "unknown") + .sort((a: any, b: any) => b.errorCount - a.errorCount); + + return { + timeRange: { + startTime: startTime.toISOString(), + endTime: now.toISOString(), + durationMs: timeRangeMs, + }, + summary: { + totalErrors, + affectedDeploymentsCount: affectedUsersResult.matches?.length || 0, + }, + topFailingFunctions, + affectedDeployments, + }; + } catch (error) { + console.error("[IncidentDashboard] Error querying Axiom:", error); + throw new Error( + `Failed to fetch incident dashboard: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, +}); + +/** + * Query Convex function logs from Axiom with filtering support + * Supports time range, log level, text search, and pagination + */ +export const queryConvexLogs = action({ + args: { + dataset: v.optional(v.string()), + startTime: v.optional(v.string()), + endTime: v.optional(v.string()), + logLevels: v.optional(v.array(v.string())), + searchText: v.optional(v.string()), + limit: v.optional(v.number()), + offset: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Verify god role + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + throw new Error("Unauthorized: God role required"); + } + + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN_ADMIN!, + }); + + try { + const dataset = args.dataset || "vly-convex"; + const limit = args.limit || 100; + const offset = args.offset || 0; + + // Calculate time range - default to last 24 hours if not specified + const now = new Date(); + const endTime = args.endTime ? new Date(args.endTime) : now; + const startTime = args.startTime + ? new Date(args.startTime) + : new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago + + // convex-user-usage contains aggregated metrics, not individual log entries + if (dataset === "convex-user-usage") { + return { + logs: [], + totalCount: 0, + offset: 0, + limit, + hasMore: false, + query: { + dataset, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + logLevels: args.logLevels, + searchText: args.searchText, + }, + }; + } + + // Build APL query for Convex logs (vly-convex dataset) + // Note: Field names contain dots (e.g., data.topic, data.log_level) and must be escaped with brackets + const aplFilters: string[] = []; + + // Time range filter using _time + aplFilters.push( + `| where ['_time'] >= datetime('${startTime.toISOString()}')`, + ); + aplFilters.push( + `| where ['_time'] <= datetime('${endTime.toISOString()}')`, + ); + + // Filter by log level + // Note: Convex logs use uppercase for log_level: "DEBUG", "INFO", "LOG", "WARN", "ERROR" + // The UI passes lowercase values, so we need to convert them + if (args.logLevels && args.logLevels.length > 0) { + // Map lowercase to Convex's uppercase format + // "info" -> "INFO", "LOG" (console.log also uses LOG) + // "warn" -> "WARN" + // "error" -> "ERROR" + // "debug" -> "DEBUG" + const levelMapping: Record<string, string[]> = { + info: ["INFO", "LOG"], + warn: ["WARN"], + error: ["ERROR"], + debug: ["DEBUG"], + }; + + const upperLevels = args.logLevels + .flatMap((l) => levelMapping[l.toLowerCase()] || []) + .map((l) => `'${l}'`) + .join(", "); + + if (upperLevels) { + // Build conditions for function_execution events based on selected levels + // - "error" level → status == 'failure' OR has error_message + // - "warn" level → has system_code + // - "info" level → status == 'success' (without error_message or system_code) + const functionExecutionConditions: string[] = []; + + if (args.logLevels.includes("error")) { + functionExecutionConditions.push( + "['data.status'] == 'failure' or isnotempty(['data.error_message'])", + ); + } + + if (args.logLevels.includes("warn")) { + functionExecutionConditions.push( + "isnotempty(['data.system_code'])", + ); + } + + if (args.logLevels.includes("info")) { + functionExecutionConditions.push( + "(['data.status'] == 'success' and isempty(['data.error_message']) and isempty(['data.system_code']))", + ); + } + + // Build the complete filter + const consoleFilter = `(['data.topic'] == 'console' and ['data.log_level'] in (${upperLevels}))`; + const functionExecutionFilter = + functionExecutionConditions.length > 0 + ? `(['data.topic'] == 'function_execution' and (${functionExecutionConditions.join(" or ")}))` + : ""; + + if (functionExecutionFilter) { + aplFilters.push( + `| where ${consoleFilter} or ${functionExecutionFilter}`, + ); + } else { + aplFilters.push(`| where ${consoleFilter}`); + } + } + } + + // Search filter - search in multiple fields + if (args.searchText && args.searchText.trim() !== "") { + const escapedSearch = args.searchText.replace(/'/g, "''"); + aplFilters.push( + `| where ['convex.deployment_name'] contains '${escapedSearch}' or ` + + `['data.function.path'] contains '${escapedSearch}' or ` + + `['data.message'] contains '${escapedSearch}' or ` + + `['data.error_message'] contains '${escapedSearch}'`, + ); + } + + // Build the APL query + // Note: Axiom doesn't support skip, so we use take with a larger limit and slice client-side + // For now, we'll just use limit and handle pagination by time ranges if needed + const apl = ` + ['${dataset}'] + ${aplFilters.join("\n ")} + | order by ['_time'] desc + | limit ${limit + offset} + `; + + // Execute query with retry logic + const result = await retryWithBackoff( + async () => await axiom.query(apl), + 3, + 1000, + ); + + const allMatches = result.matches || []; + + // Apply offset client-side since Axiom doesn't support skip + const matches = allMatches.slice(offset, offset + limit); + + // Get total count for pagination (run a separate count query) + const countApl = ` + ['${dataset}'] + ${aplFilters.join("\n ")} + | summarize count() + `; + + const countResult = await retryWithBackoff( + async () => await axiom.query(countApl), + 3, + 1000, + ); + + const totalCount = + countResult.matches?.[0]?.data?.["count_"] ?? + countResult.matches?.[0]?.data?.count ?? + matches.length; + + // Transform Convex logs to a consistent format + const logs = matches.map((match: any) => { + const record = match.data; + const convexMeta = record.convex || {}; + const eventData = record.data || {}; + + // Extract key fields from nested structure + const topic = eventData.topic || "unknown"; + const logLevel = eventData.log_level || "info"; + const functionPath = eventData.function?.path || "unknown"; + const status = eventData.status || "success"; + const execTime = eventData.execution_time_ms || 0; + const errorMessage = eventData.error_message || ""; + const logMessage = eventData.message || ""; + + // Determine final level + // Note: Convex uses uppercase log levels (ERROR, INFO, WARN, etc.) + // We need to convert to lowercase for frontend consistency + let level: "info" | "warn" | "error" | "debug" = "info"; + + // First convert the raw log level to lowercase + const normalizedLogLevel = logLevel.toLowerCase(); + if (["info", "warn", "error", "debug"].includes(normalizedLogLevel)) { + level = normalizedLogLevel as "info" | "warn" | "error" | "debug"; + } else if (normalizedLogLevel === "log") { + // Convex uses "LOG" for console.log, treat as info + level = "info"; + } + + // Override based on execution status + if (status === "failure" || errorMessage) { + level = "error"; + } else if (eventData.system_code) { + level = "warn"; + } + + // Build display message + let message = ""; + if (topic === "function_execution") { + message = `${functionPath} - ${status}`; + if (execTime > 0) { + message += ` (${execTime}ms)`; + } + if (errorMessage) { + message += ` - ${errorMessage}`; + } + } else if (topic === "console" || logMessage) { + message = logMessage; + if (functionPath !== "unknown") { + message = `[${functionPath}] ${message}`; + } + } else { + message = JSON.stringify(eventData); + } + + return { + timestamp: record._time || eventData.timestamp, + level, + functionName: functionPath, + deploymentName: convexMeta.deployment_name || "unknown", + message, + metadata: { + topic, + convex: convexMeta, + status, + execution_time_ms: execTime, + ...eventData, + }, + rawData: record, + }; + }); + + return { + logs, + totalCount, + offset, + limit, + hasMore: offset + matches.length < totalCount, + query: { + dataset, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + logLevels: args.logLevels, + searchText: args.searchText, + }, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + console.error("[QueryConvexLogs] Error querying logs:", error); + + // Check for service unavailability + if ( + errorMessage.includes("503") || + errorMessage.includes("Service Unavailable") + ) { + throw new Error( + "Axiom monitoring service is temporarily unavailable. Please try again in a few moments.", + ); + } + + // Check for authentication errors + if ( + errorMessage.includes("401") || + errorMessage.includes("403") || + errorMessage.includes("Unauthorized") + ) { + throw new Error( + "Authentication error with Axiom. Please check your API token configuration.", + ); + } + + // Check for rate limiting + if (errorMessage.includes("429") || errorMessage.includes("rate limit")) { + throw new Error( + "Axiom rate limit exceeded. Please try again in a few minutes.", + ); + } + + // Generic error + throw new Error( + `Failed to query logs: ${errorMessage}. If this persists, please contact support.`, + ); + } + }, +}); + +/** + * Get Performance Dashboard data from Axiom + * Includes: query cache hit rate, write conflicts, slowest functions, resource usage + */ +export const getPerformanceDashboard = action({ + args: { + dataset: v.optional(v.string()), + timeRangeMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Verify god role + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + throw new Error("Unauthorized: God role required"); + } + + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN_ADMIN!, + }); + + try { + const dataset = args.dataset || "vly-convex"; + const timeRangeMs = args.timeRangeMs || 60 * 60 * 1000; // Default 1 hour + + const now = new Date(); + const startTime = new Date(now.getTime() - timeRangeMs); + + if (dataset === "convex-user-usage") { + // convex-user-usage dataset - aggregated metrics only + // We can show slowest functions and database read metrics, but not cache hit rate or write conflicts + + const slowestFunctionsApl = ` + ['${dataset}'] + | where todatetime(aggregated_at) >= datetime('${startTime.toISOString()}') + | where todatetime(aggregated_at) <= datetime('${now.toISOString()}') + | where event_type == "usage_summary" + | summarize + avgTime = avg(todouble(avg_execution_time_ms)), + maxTime = max(todouble(avg_execution_time_ms)), + execCount = sum(todouble(execution_count)) + by deployment_name + | order by avgTime desc + | limit 10 + `; + + const mostDocumentsReadApl = ` + ['${dataset}'] + | where todatetime(aggregated_at) >= datetime('${startTime.toISOString()}') + | where todatetime(aggregated_at) <= datetime('${now.toISOString()}') + | where event_type == "usage_summary" + | summarize + totalDocs = sum(todouble(db_read_documents)), + totalBytes = sum(todouble(db_read_bytes)) + by deployment_name + | order by totalDocs desc + | limit 10 + `; + + const mostDataReadApl = ` + ['${dataset}'] + | where todatetime(aggregated_at) >= datetime('${startTime.toISOString()}') + | where todatetime(aggregated_at) <= datetime('${now.toISOString()}') + | where event_type == "usage_summary" + | summarize + totalDocs = sum(todouble(db_read_documents)), + totalBytes = sum(todouble(db_read_bytes)) + by deployment_name + | order by totalBytes desc + | limit 10 + `; + + const [slowestFunctionsResult, mostDocumentsResult, mostDataResult] = + await Promise.all([ + retryWithBackoff(() => axiom.query(slowestFunctionsApl), 3, 1000), + retryWithBackoff(() => axiom.query(mostDocumentsReadApl), 3, 1000), + retryWithBackoff(() => axiom.query(mostDataReadApl), 3, 1000), + ]); + + // Parse results for convex-user-usage + const parseSlowItemsUserUsage = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.deployment_name; + const avgTime = total.aggregations?.find( + (agg: any) => agg.op === "avgTime", + )?.value; + const maxTime = total.aggregations?.find( + (agg: any) => agg.op === "maxTime", + )?.value; + const execCount = total.aggregations?.find( + (agg: any) => agg.op === "execCount", + )?.value; + + return { + functionPath: functionPath || "unknown", + avgExecutionTimeMs: avgTime || 0, + maxExecutionTimeMs: maxTime || 0, + executionCount: execCount || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.avgExecutionTimeMs - a.avgExecutionTimeMs, + ); + }; + + const parseResourceItemsUserUsage = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.deployment_name; + const totalDocs = total.aggregations?.find( + (agg: any) => agg.op === "totalDocs", + )?.value; + const totalBytes = total.aggregations?.find( + (agg: any) => agg.op === "totalBytes", + )?.value; + + return { + functionPath: functionPath || "unknown", + totalDocumentsRead: totalDocs || 0, + totalDataReadBytes: totalBytes || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.totalDocumentsRead - a.totalDocumentsRead, + ); + }; + + const parseBandwidthItemsUserUsage = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.deployment_name; + const totalDocs = total.aggregations?.find( + (agg: any) => agg.op === "totalDocs", + )?.value; + const totalBytes = total.aggregations?.find( + (agg: any) => agg.op === "totalBytes", + )?.value; + + return { + functionPath: functionPath || "unknown", + totalDocumentsRead: totalDocs || 0, + totalDataReadBytes: totalBytes || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.totalDataReadBytes - a.totalDataReadBytes, + ); + }; + + // Return results with empty arrays for unavailable metrics + return { + timeRange: { + startTime: startTime.toISOString(), + endTime: now.toISOString(), + durationMs: timeRangeMs, + }, + queryCacheHitRate: { + hitRate: 0, + totalQueries: 0, + cacheHits: 0, + cacheMisses: 0, + }, + writeConflicts: [], + slowestQueries: parseSlowItemsUserUsage(slowestFunctionsResult), + slowestMutations: [], + slowestActions: [], + mostDocumentsRead: parseResourceItemsUserUsage(mostDocumentsResult), + mostDataRead: parseBandwidthItemsUserUsage(mostDataResult), + }; + } + + // vly-convex dataset - detailed event data + const cacheHitRateApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | where ['data.function.type'] == "query" + | where isnotempty(['data.function.cached']) + | summarize + totalQueries = count(), + cacheHits = countif(['data.function.cached']), + cacheMisses = countif(not(['data.function.cached'])) + `; + + // Query 2: Write Conflicts (detected via OCC info) + // NOTE: Use dcount(requestId) to count unique conflicts, not retry attempts. + // Without deduplication, the same conflict retried 5 times would count as 5 conflicts. + const writeConflictsApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend documentId = ensure_field("data.occ_info.document_id", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | extend requestId = ensure_field("data.request_id", typeof(string)) + | where isnotnull(documentId) + | summarize conflictCount = dcount(requestId) by function_path, table_name = ['data.occ_info.table_name'] + | order by conflictCount desc + | limit 10 + `; + + // Query 3: Slowest Queries + const slowestQueriesApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | where ['data.function.type'] == "query" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | extend exec_time = todouble(['data.execution_time_ms']) + | summarize avgTime = avg(exec_time), maxTime = max(exec_time), execCount = count() by function_path + | order by avgTime desc + | limit 10 + `; + + // Query 4: Slowest Mutations + const slowestMutationsApl = slowestQueriesApl.replace( + '"query"', + '"mutation"', + ); + + // Query 5: Slowest Actions + const slowestActionsApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | where ['data.function.type'] != "query" and ['data.function.type'] != "mutation" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | extend exec_time = todouble(['data.execution_time_ms']) + | summarize avgTime = avg(exec_time), maxTime = max(exec_time), execCount = count() by function_path + | order by avgTime desc + | limit 10 + `; + + // Query 6: Most Documents Read + const mostDocumentsReadApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | summarize totalDocs = sum(todouble(['data.usage.database_read_documents'])) + by function_path + | order by totalDocs desc + | limit 10 + `; + + // Query 7: Most Data Read (Bandwidth) + const mostDataReadApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | summarize totalBytes = sum(todouble(['data.usage.database_read_bytes'])) + by function_path + | order by totalBytes desc + | limit 10 + `; + + // Execute all queries with retry logic + const [ + cacheHitResult, + writeConflictsResult, + slowestQueriesResult, + slowestMutationsResult, + slowestActionsResult, + mostDocumentsResult, + mostDataResult, + ] = await Promise.all([ + retryWithBackoff(() => axiom.query(cacheHitRateApl), 3, 1000), + retryWithBackoff(() => axiom.query(writeConflictsApl), 3, 1000), + retryWithBackoff(() => axiom.query(slowestQueriesApl), 3, 1000), + retryWithBackoff(() => axiom.query(slowestMutationsApl), 3, 1000), + retryWithBackoff(() => axiom.query(slowestActionsApl), 3, 1000), + retryWithBackoff(() => axiom.query(mostDocumentsReadApl), 3, 1000), + retryWithBackoff(() => axiom.query(mostDataReadApl), 3, 1000), + ]); + + // Parse cache hit rate results + const aggregations = + cacheHitResult.buckets?.totals?.[0]?.aggregations || []; + const totalQueries = + aggregations.find((agg: any) => agg.op === "totalQueries")?.value || 0; + const cacheHits = + aggregations.find((agg: any) => agg.op === "cacheHits")?.value || 0; + const cacheMisses = + aggregations.find((agg: any) => agg.op === "cacheMisses")?.value || 0; + + const queryCacheHitRate = { + hitRate: totalQueries > 0 ? (cacheHits / totalQueries) * 100 : 0, + totalQueries, + cacheHits, + cacheMisses, + }; + + const _parseTopItems = (result: any, countField: string) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const count = total.aggregations?.find( + (agg: any) => agg.op === countField, + )?.value; + + return { + functionPath: functionPath || "unknown", + count: count || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort((a: any, b: any) => b.count - a.count); + }; + + const parseWriteConflictItems = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const tableName = total.group?.table_name; + const conflictCount = total.aggregations?.find( + (agg: any) => agg.op === "conflictCount", + )?.value; + + // Format as "function_path (table_name)" to show which table has conflicts + const displayPath = + functionPath && tableName + ? `${functionPath} (${tableName})` + : functionPath || "unknown"; + + return { + functionPath: displayPath, + conflictCount: conflictCount || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort((a: any, b: any) => b.conflictCount - a.conflictCount); + }; + + const parseSlowItems = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const avgTime = total.aggregations?.find( + (agg: any) => agg.op === "avgTime", + )?.value; + const maxTime = total.aggregations?.find( + (agg: any) => agg.op === "maxTime", + )?.value; + const execCount = total.aggregations?.find( + (agg: any) => agg.op === "execCount", + )?.value; + + return { + functionPath: functionPath || "unknown", + avgExecutionTimeMs: avgTime || 0, + maxExecutionTimeMs: maxTime || 0, + executionCount: execCount || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.avgExecutionTimeMs - a.avgExecutionTimeMs, + ); + }; + + const parseDocumentReadItems = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const totalDocs = total.aggregations?.find( + (agg: any) => agg.op === "totalDocs", + )?.value; + + return { + functionPath: functionPath || "unknown", + totalDocumentsRead: totalDocs || 0, + totalDataReadBytes: 0, // Not queried in this result set + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.totalDocumentsRead - a.totalDocumentsRead, + ); + }; + + const parseDataReadItems = (result: any) => { + return (result.buckets?.totals || []) + .map((total: any) => { + const functionPath = total.group?.function_path; + const totalBytes = total.aggregations?.find( + (agg: any) => agg.op === "totalBytes", + )?.value; + + return { + functionPath: functionPath || "unknown", + totalDocumentsRead: 0, // Not queried in this result set + totalDataReadBytes: totalBytes || 0, + }; + }) + .filter((item: any) => item.functionPath !== "unknown") + .sort( + (a: any, b: any) => b.totalDataReadBytes - a.totalDataReadBytes, + ); + }; + + return { + timeRange: { + startTime: startTime.toISOString(), + endTime: now.toISOString(), + durationMs: timeRangeMs, + }, + queryCacheHitRate, + writeConflicts: parseWriteConflictItems(writeConflictsResult), + slowestQueries: parseSlowItems(slowestQueriesResult), + slowestMutations: parseSlowItems(slowestMutationsResult), + slowestActions: parseSlowItems(slowestActionsResult), + mostDocumentsRead: parseDocumentReadItems(mostDocumentsResult), + mostDataRead: parseDataReadItems(mostDataResult), + }; + } catch (error) { + console.error("[PerformanceDashboard] Error querying Axiom:", error); + throw new Error( + `Failed to fetch performance dashboard: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, +}); + +/** + * Get Cost Dashboard data from Axiom + * Includes: total executions, bandwidth, execution time, top cost contributors + */ +export const getCostDashboard = action({ + args: { + dataset: v.optional(v.string()), + timeRangeMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Verify god role + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + throw new Error("Unauthorized: God role required"); + } + + // Initialize Axiom client + const axiom = new Axiom({ + token: process.env.AXIOM_API_TOKEN_ADMIN!, + }); + + try { + const dataset = args.dataset || "vly-convex"; + const timeRangeMs = args.timeRangeMs || 60 * 60 * 1000; // Default 1 hour + + const now = new Date(); + const startTime = new Date(now.getTime() - timeRangeMs); + + // Different queries based on dataset type + let summaryApl: string; + let topCostFunctionsApl: string; + + if (dataset === "convex-user-usage") { + // convex-user-usage has aggregated metrics at root level + summaryApl = ` + ['${dataset}'] + | where todatetime(aggregated_at) >= datetime('${startTime.toISOString()}') + | where todatetime(aggregated_at) <= datetime('${now.toISOString()}') + | where event_type == "usage_summary" + | extend _computeGBH = iff(todouble(execution_count) > 0, todouble(action_memory_used_mb) / todouble(execution_count) * todouble(execution_time_ms) / 1024.0 / 3600000.0, 0.0) + | summarize + totalExec = sum(todouble(execution_count)), + totalTime = sum(todouble(execution_time_ms)), + totalDbRead = sum(todouble(db_read_bytes)), + totalDbWrite = sum(todouble(db_write_bytes)), + totalFileRead = sum(todouble(file_storage_read_bytes)), + totalFileWrite = sum(todouble(file_storage_write_bytes)), + totalComputeGBHours = sum(_computeGBH) + `; + + topCostFunctionsApl = ` + ['${dataset}'] + | where todatetime(aggregated_at) >= datetime('${startTime.toISOString()}') + | where todatetime(aggregated_at) <= datetime('${now.toISOString()}') + | where event_type == "usage_summary" + | extend _computeGBH = iff(todouble(execution_count) > 0, todouble(action_memory_used_mb) / todouble(execution_count) * todouble(execution_time_ms) / 1024.0 / 3600000.0, 0.0) + | summarize + execCount = sum(todouble(execution_count)), + totalTime = sum(todouble(execution_time_ms)), + totalDbRead = sum(todouble(db_read_bytes)), + totalDbWrite = sum(todouble(db_write_bytes)), + totalFileRead = sum(todouble(file_storage_read_bytes)), + totalFileWrite = sum(todouble(file_storage_write_bytes)), + totalComputeGBHours = sum(_computeGBH) + by deployment_name + | order by execCount desc + | limit 10 + `; + } else { + // vly-convex has nested structure under data.* + summaryApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | summarize + totalExec = count(), + totalTime = sum(todouble(['data.execution_time_ms'])), + totalDbRead = sum(todouble(['data.usage.database_read_bytes'])), + totalDbWrite = sum(todouble(['data.usage.database_write_bytes'])), + totalFileRead = sum(todouble(['data.usage.file_storage_read_bytes'])), + totalFileWrite = sum(todouble(['data.usage.file_storage_write_bytes'])), + totalComputeGBHours = sum(todouble(['data.usage.memory_used_mb']) * todouble(['data.execution_time_ms']) / 1024.0 / 3600000.0) + `; + + topCostFunctionsApl = ` + ['${dataset}'] + | where ['_time'] >= datetime('${startTime.toISOString()}') + | where ['_time'] <= datetime('${now.toISOString()}') + | where ['data.topic'] == "function_execution" + | extend normalized_component_path = ensure_field("data.function.component_path", typeof(string)) + | extend function_path = strcat(iff(isnull(normalized_component_path), "", + strcat(normalized_component_path, "|")), + ['data.function.path']) + | summarize + execCount = count(), + totalTime = sum(todouble(['data.execution_time_ms'])), + totalDbRead = sum(todouble(['data.usage.database_read_bytes'])), + totalDbWrite = sum(todouble(['data.usage.database_write_bytes'])), + totalFileRead = sum(todouble(['data.usage.file_storage_read_bytes'])), + totalFileWrite = sum(todouble(['data.usage.file_storage_write_bytes'])), + totalComputeGBHours = sum(todouble(['data.usage.memory_used_mb']) * todouble(['data.execution_time_ms']) / 1024.0 / 3600000.0) + by function_path + | order by execCount desc + | limit 10 + `; + } + + // Execute queries with retry logic + const [summaryResult, topCostResult] = await Promise.all([ + retryWithBackoff(() => axiom.query(summaryApl), 3, 1000), + retryWithBackoff(() => axiom.query(topCostFunctionsApl), 3, 1000), + ]); + + // Parse summary results with full usage metrics + const summaryData = + summaryResult.buckets?.totals?.[0]?.aggregations || []; + const totalExecutions = + summaryData.find((agg: any) => agg.op === "totalExec")?.value || 0; + const totalExecutionTimeMs = + summaryData.find((agg: any) => agg.op === "totalTime")?.value || 0; + const totalDbReadBytes = + summaryData.find((agg: any) => agg.op === "totalDbRead")?.value || 0; + const totalDbWriteBytes = + summaryData.find((agg: any) => agg.op === "totalDbWrite")?.value || 0; + const totalFileReadBytes = + summaryData.find((agg: any) => agg.op === "totalFileRead")?.value || 0; + const totalFileWriteBytes = + summaryData.find((agg: any) => agg.op === "totalFileWrite")?.value || 0; + const totalComputeGBHours = + summaryData.find((agg: any) => agg.op === "totalComputeGBHours") + ?.value || 0; + + // Calculate total bandwidth (database + file storage) + const totalBandwidthBytes = + totalDbReadBytes + + totalDbWriteBytes + + totalFileReadBytes + + totalFileWriteBytes; + + // Calculate actual costs using Convex pricing + const pricing = { + FUNCTION_CALLS_PER_MILLION: 2.0, + COMPUTE_PER_GB_HOUR: 0.3, + DATABASE_BANDWIDTH_PER_GB: 0.2, + FILE_BANDWIDTH_PER_GB: 0.3, + }; + + const functionCallCost = + (totalExecutions / 1_000_000) * pricing.FUNCTION_CALLS_PER_MILLION; + const computeCost = totalComputeGBHours * pricing.COMPUTE_PER_GB_HOUR; + const dbBandwidthGB = (totalDbReadBytes + totalDbWriteBytes) / 1024 ** 3; + const dbBandwidthCost = dbBandwidthGB * pricing.DATABASE_BANDWIDTH_PER_GB; + const fileBandwidthGB = + (totalFileReadBytes + totalFileWriteBytes) / 1024 ** 3; + const fileBandwidthCost = fileBandwidthGB * pricing.FILE_BANDWIDTH_PER_GB; + const estimatedTotalCost = + functionCallCost + computeCost + dbBandwidthCost + fileBandwidthCost; + + // Parse top cost functions with full metrics and cost calculations + const topCostFunctions = (topCostResult.buckets?.totals || []) + .map((total: any) => { + // Handle different field names based on dataset + const functionPath = + dataset === "convex-user-usage" + ? total.group?.deployment_name + : total.group?.function_path; + const execCount = + total.aggregations?.find((agg: any) => agg.op === "execCount") + ?.value || 0; + const totalTime = + total.aggregations?.find((agg: any) => agg.op === "totalTime") + ?.value || 0; + const dbReadBytes = + total.aggregations?.find((agg: any) => agg.op === "totalDbRead") + ?.value || 0; + const dbWriteBytes = + total.aggregations?.find((agg: any) => agg.op === "totalDbWrite") + ?.value || 0; + const fileReadBytes = + total.aggregations?.find((agg: any) => agg.op === "totalFileRead") + ?.value || 0; + const fileWriteBytes = + total.aggregations?.find((agg: any) => agg.op === "totalFileWrite") + ?.value || 0; + const computeGBHours = + total.aggregations?.find( + (agg: any) => agg.op === "totalComputeGBHours", + )?.value || 0; + + // Calculate bandwidth + const totalBandwidth = + dbReadBytes + dbWriteBytes + fileReadBytes + fileWriteBytes; + + // Calculate costs for this function using actual usage data + const functionCallCost = + (execCount / 1_000_000) * pricing.FUNCTION_CALLS_PER_MILLION; + const computeCost = computeGBHours * pricing.COMPUTE_PER_GB_HOUR; + const dbBandwidthGB = (dbReadBytes + dbWriteBytes) / 1024 ** 3; + const dbBandwidthCost = + dbBandwidthGB * pricing.DATABASE_BANDWIDTH_PER_GB; + const fileBandwidthGB = (fileReadBytes + fileWriteBytes) / 1024 ** 3; + const fileBandwidthCost = + fileBandwidthGB * pricing.FILE_BANDWIDTH_PER_GB; + const estimatedCost = + functionCallCost + + computeCost + + dbBandwidthCost + + fileBandwidthCost; + + return { + functionPath: functionPath || "unknown", + executionCount: execCount, + totalExecutionTimeMs: totalTime, + totalBandwidthBytes: totalBandwidth, + estimatedCost, + }; + }) + .filter((fn: any) => fn.functionPath !== "unknown") + .sort((a: any, b: any) => b.estimatedCost - a.estimatedCost); + + return { + timeRange: { + startTime: startTime.toISOString(), + endTime: now.toISOString(), + durationMs: timeRangeMs, + }, + summary: { + totalExecutions, + totalBandwidthBytes, + totalExecutionTimeMs, + estimatedTotalCost, + costBreakdown: { + functionCallsCost: functionCallCost, + computeCost, + computeGBHours: totalComputeGBHours, + dbBandwidthCost, + dbBandwidthGB, + fileBandwidthCost, + fileBandwidthGB, + }, + }, + topCostFunctions, + }; + } catch (error) { + console.error("[CostDashboard] Error querying Axiom:", error); + throw new Error( + `Failed to fetch cost dashboard: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, +}); + +/** + * List available sandbox sessions for a project + * Returns all active sessions and their commands + * Only accessible to god-role users + */ +export const listSandboxSessions = action({ + args: { + projectId: v.id("project"), + }, + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + sessions: Array<{ + sessionId: string; + commands: Array<{ + commandId: string; + command: string; + isRunning: boolean; + }>; + }>; + error?: string; + }> => { + // Check if user is a god + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + throw new Error("Unauthorized: God role required"); + } + + try { + // Get the project + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + return { + success: false, + sessions: [], + error: "Project not found", + }; + } + + // Initialize codebase to access sandbox + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Check if this is a Daytona sandbox that supports process/session APIs + if (!("sandbox" in codebase) || !codebase.sandbox) { + return { + success: false, + sessions: [], + error: "Sandbox does not support sessions", + }; + } + + const sandbox = codebase.sandbox as any; + + // Check if sandbox has process API + if (!sandbox.process || !sandbox.process.listSessions) { + return { + success: false, + sessions: [], + error: "Sandbox does not support listing sessions", + }; + } + + // Fetch all sessions + const sessions = await sandbox.process.listSessions(); + + // Transform to include session details + const sessionDetails = sessions.map((session: any) => ({ + sessionId: session.sessionId, + commands: (session.commands || []).map((cmd: any) => ({ + commandId: cmd.cmdId || cmd.id || "unknown", + command: cmd.command || "unknown", + isRunning: cmd.isRunning ?? true, + })), + })); + + return { + success: true, + sessions: sessionDetails, + }; + } catch (error) { + console.error("[listSandboxSessions] Error listing sessions:", error); + return { + success: false, + sessions: [], + error: + error instanceof Error ? error.message : "Failed to list sessions", + }; + } + }, +}); + +/** + * Get sandbox session logs for a project + * Fetches command logs from Daytona sandbox sessions + * Only accessible to god-role users + */ +export const getSandboxSessionLogs = action({ + args: { + projectId: v.id("project"), + sessionId: v.optional(v.string()), + commandId: v.optional(v.string()), + }, + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + logs: Array<{ + sessionId: string; + commandId: string; + stdout: string; + stderr: string; + combined: string; + timestamp: number; + }>; + error?: string; + }> => { + // Check if user is a god + const currentUser = await getAuthUser(ctx); + + if (!currentUser || currentUser.role !== "god") { + throw new Error("Unauthorized: God role required"); + } + + try { + // Get the project + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + return { + success: false, + logs: [], + error: "Project not found", + }; + } + + // Initialize codebase to access sandbox + const codebase = await initializeCodebase( + project.sandbox_id, + project.packageManager, + ); + + // Check if this is a Daytona sandbox that supports process/session APIs + if (!("sandbox" in codebase) || !codebase.sandbox) { + return { + success: false, + logs: [], + error: "Sandbox does not support session logs", + }; + } + + const sandbox = codebase.sandbox as any; + + // Check if sandbox has process API + if (!sandbox.process || !sandbox.process.getSessionCommandLogs) { + return { + success: false, + logs: [], + error: "Sandbox does not support session command logs", + }; + } + + // Fetch session logs + // If specific sessionId and commandId provided, get those logs + // Otherwise, we'd need to list sessions first - for now just return specific logs + if (args.sessionId && args.commandId) { + const logs = await sandbox.process.getSessionCommandLogs( + args.sessionId, + args.commandId, + ); + + return { + success: true, + logs: [ + { + sessionId: args.sessionId, + commandId: args.commandId, + stdout: logs.stdout || "", + stderr: logs.stderr || "", + combined: (logs.stdout || "") + (logs.stderr || ""), + timestamp: Date.now(), + }, + ], + }; + } + + // If no specific session/command provided, return empty for now + // Could be enhanced to list all sessions and their commands + return { + success: true, + logs: [], + error: "Please provide sessionId and commandId to fetch logs", + }; + } catch (error) { + console.error("[getSandboxSessionLogs] Error fetching logs:", error); + return { + success: false, + logs: [], + error: error instanceof Error ? error.message : "Failed to fetch logs", + }; + } + }, +}); diff --git a/freebuff/web/convex/org_security.ts b/freebuff/web/convex/org_security.ts new file mode 100644 index 0000000000..290da63b37 --- /dev/null +++ b/freebuff/web/convex/org_security.ts @@ -0,0 +1,125 @@ +import { QueryCtx, ActionCtx } from "./_generated/server"; +import { OrgRole, OrgPermission } from "./schema"; + +/** + * Organization role-based permission matrix + */ +const ORG_PERMISSIONS: Record<OrgRole, OrgPermission[]> = { + "org:admin": ["read", "write", "admin", "billing"], + "org:member": ["read", "write"], + "org:viewer": ["read"], +}; + +/** + * Extract organization context from Clerk JWT token + */ +export async function getOrganizationContext(ctx: QueryCtx | ActionCtx) { + const identity = await ctx.auth.getUserIdentity(); + + if (!identity) { + return null; + } + + // Extract organization info from Clerk JWT token + // Clerk includes organization info when user is in organization context + const organizationId = + (identity as any)?.org_id || + (identity as any)?.organizationId || + (identity as any)?.organization?.id || + (identity as any)?.activeOrganizationId; + + const orgRole = (identity as any)?.org_role as OrgRole | undefined; + const orgSlug = (identity as any)?.org_slug; + const orgName = (identity as any)?.org_name; + + if (!organizationId) { + return null; // User not in organization context + } + + return { + organizationId, + role: orgRole || "org:member", // Default to member if role not specified + slug: orgSlug, + name: orgName, + }; +} + +/** + * Check if user has required permission in organization + */ +export function hasOrganizationPermission( + userRole: OrgRole, + requiredPermission: OrgPermission, +): boolean { + const userPermissions = ORG_PERMISSIONS[userRole] || []; + return userPermissions.includes(requiredPermission); +} + +/** + * Verify organization access for a project + * @param orgContext - Optional pre-fetched org context to avoid duplicate JWT fetches + */ +export async function verifyOrganizationAccess( + ctx: QueryCtx | ActionCtx, + projectOrganizationId: string, + requiredPermission: OrgPermission = "read", + orgContext?: Awaited<ReturnType<typeof getOrganizationContext>>, +): Promise<boolean> { + // Use provided context or fetch it + const context = orgContext ?? (await getOrganizationContext(ctx)); + + if (!context) { + return false; // User not in any organization + } + + // Check if user is in the same organization as the project + if (context.organizationId !== projectOrganizationId) { + return false; // User not in project's organization + } + + // Check if user has required permission + return hasOrganizationPermission(context.role, requiredPermission); +} + +/** + * Security audit log entry + */ +export interface SecurityAuditLog { + userId: string; + organizationId: string | null; + action: string; + resource: string; + permitted: boolean; + timestamp: number; + clientIP?: string; +} + +/** + * Log security-relevant actions for audit trail + */ +export async function logSecurityAction( + ctx: QueryCtx | ActionCtx, + action: string, + resource: string, + permitted: boolean, + additionalData?: Record<string, any>, +) { + const identity = await ctx.auth.getUserIdentity(); + const orgContext = await getOrganizationContext(ctx); + + const logEntry: SecurityAuditLog = { + userId: identity?.subject || "anonymous", + organizationId: orgContext?.organizationId || null, + action, + resource, + permitted, + timestamp: Date.now(), + ...additionalData, + }; + + // Only log security-critical events (access denied, suspicious activity) + // Comment out for now to reduce noise - uncomment when needed for debugging + // console.log("[SECURITY AUDIT]", JSON.stringify(logEntry)); + + // TODO: Implement proper audit logging to database or external service +} diff --git a/freebuff/web/convex/paused_users.ts b/freebuff/web/convex/paused_users.ts new file mode 100644 index 0000000000..f85cd1f814 --- /dev/null +++ b/freebuff/web/convex/paused_users.ts @@ -0,0 +1,86 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; +import { PAUSE_REASON_VALIDATOR } from "./constants"; +import { pausedUsersByActive } from "./aggregates/admin_aggregates"; + +/** + * Create a new pause record (mutation) + */ +export const createPauseRecord = internalMutation({ + args: { + userId: v.id("users"), + pauseReason: PAUSE_REASON_VALIDATOR, + autoUnpauseEnabled: v.boolean(), + pausedBy: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const pauseId = await ctx.db.insert("paused_users", { + userId: args.userId, + pauseReason: args.pauseReason, + pausedAt: Date.now(), + pausedBy: args.pausedBy, + autoUnpauseEnabled: args.autoUnpauseEnabled, + active: true, + }); + + // Update aggregates for new paused user + const newPausedUser = await ctx.db.get(pauseId); + if (newPausedUser) { + await pausedUsersByActive.insert(ctx, newPausedUser); + } + }, +}); + +/** + * Update an existing pause record (mutation) + */ +export const updatePauseRecord = internalMutation({ + args: { + pauseId: v.id("paused_users"), + pauseReason: PAUSE_REASON_VALIDATOR, + autoUnpauseEnabled: v.boolean(), + pausedBy: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + // Get the old record before updating + const oldRecord = await ctx.db.get(args.pauseId); + + await ctx.db.patch(args.pauseId, { + pauseReason: args.pauseReason, + pausedAt: Date.now(), + pausedBy: args.pausedBy, + autoUnpauseEnabled: args.autoUnpauseEnabled, + active: true, + }); + + // Update aggregates if active status changed + const newRecord = await ctx.db.get(args.pauseId); + if (oldRecord && newRecord) { + await pausedUsersByActive.replace(ctx, oldRecord, newRecord); + } + }, +}); + +/** + * Deactivate a pause record (mutation) + */ +export const deactivatePauseRecord = internalMutation({ + args: { + pauseId: v.id("paused_users"), + }, + handler: async (ctx, args) => { + // Get the old record before deactivating + const oldRecord = await ctx.db.get(args.pauseId); + + await ctx.db.patch(args.pauseId, { + active: false, + unpausedAt: Date.now(), + }); + + // Update aggregates since active status changed + const newRecord = await ctx.db.get(args.pauseId); + if (oldRecord && newRecord) { + await pausedUsersByActive.replace(ctx, oldRecord, newRecord); + } + }, +}); diff --git a/freebuff/web/convex/pool_management.ts b/freebuff/web/convex/pool_management.ts new file mode 100644 index 0000000000..9721c01c99 --- /dev/null +++ b/freebuff/web/convex/pool_management.ts @@ -0,0 +1,217 @@ +import { internal } from "!/_generated/api"; +import { internalAction, internalMutation } from "!/_generated/server"; +import { v } from "convex/values"; +import { allProjects, cloudProjectsByTypeDay, projectsByDay } from "./aggregates/admin_aggregates"; + +const MIN_PROJECT_POOL_SIZE = 10; + +// Spacing between sandbox creations during a pool flush. Daytona throttles its +// control-plane API (429 ThrottlerException) when many sandboxes are created in +// a tight burst, so we deliberately pace the loop. Overridable via env. +function getPoolCreateDelayMs(): number { + const configured = process.env.POOL_CREATE_DELAY_MS + ? Number.parseInt(process.env.POOL_CREATE_DELAY_MS, 10) + : Number.NaN; + return Number.isFinite(configured) && configured >= 0 ? configured : 1500; +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getProjectPoolSize() { + const configuredPoolSize = process.env.MIN_POOL_SIZE + ? Number.parseInt(process.env.MIN_POOL_SIZE, 10) + : MIN_PROJECT_POOL_SIZE; + + if (!Number.isFinite(configuredPoolSize)) { + return MIN_PROJECT_POOL_SIZE; + } + + return Math.max(MIN_PROJECT_POOL_SIZE, configuredPoolSize); +} + +function getPoolSnapshotIds(): string[] { + const fullSnapshotId = process.env.DAYTONA_SNAPSHOT_ID; + if (!fullSnapshotId) { + throw new Error("DAYTONA_SNAPSHOT_ID is not configured"); + } + + const smallSnapshotId = process.env.DAYTONA_SNAPSHOT_SMALL_ID; + if (!smallSnapshotId || smallSnapshotId === fullSnapshotId) { + return [fullSnapshotId]; + } + + return [fullSnapshotId, smallSnapshotId]; +} + +export const flushProjectPoolAndInitializeNew = internalAction({ + args: { + newPoolSize: v.optional(v.number()), + skipFlushing: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + // Get IDs of existing uninitialized projects to delete later + const oldProjectIds = !args.skipFlushing + ? await ctx.runMutation( + internal.pool_management.getUninitializedProjectIds, + ) + : []; + + const newPoolSize = Math.max( + MIN_PROJECT_POOL_SIZE, + args.newPoolSize ?? getProjectPoolSize(), + ); + const snapshotIds = getPoolSnapshotIds(); + const createDelayMs = getPoolCreateDelayMs(); + + // Create new projects first to avoid having an empty pool. Pace the loop so + // we don't trip Daytona's API throttle, and don't let one failed creation + // abort the whole flush — log it and keep going. + let created = 0; + for (let i = 0; i < newPoolSize; i++) { + const snapshotId = snapshotIds[i % snapshotIds.length]; + try { + await ctx.runAction( + internal.codesandbox.createProject.initializeUnassignedProject, + { snapshotId }, + ); + created += 1; + console.log("Initialized unassigned project", i, { snapshotId }); + } catch (error) { + console.error("Failed to initialize unassigned project", i, { + snapshotId, + error: error instanceof Error ? error.message : String(error), + }); + } + + if (createDelayMs > 0 && i < newPoolSize - 1) { + await sleep(createDelayMs); + } + } + + console.log("Pool flush created projects", { created, newPoolSize }); + + // Delete only the old projects after new ones are created + if (!args.skipFlushing && oldProjectIds.length > 0 && created > 0) { + // First, clean up Daytona sandbox instances + try { + await ctx.runAction( + internal.cleanup.deleteSandboxInstances.deleteDaytonaSandboxes, + { + projectIds: oldProjectIds, + }, + ); + } catch (error) { + console.error("Failed to delete some sandboxes:", error); + // Continue with database deletion even if sandbox cleanup fails + } + + // Then delete the database records + await ctx.runMutation(internal.pool_management.deleteProjectsByIds, { + projectIds: oldProjectIds, + }); + } + }, +}); + +export const getUninitializedProjectIds = internalMutation({ + args: {}, + handler: async (ctx, args) => { + const projects = await ctx.db + .query("project") + .withIndex("by_state", (q) => q.eq("state", "unassigned")) + .collect(); + + return projects.map((p) => p._id); + }, +}); + +export const deleteProjectsByIds = internalMutation({ + args: { + projectIds: v.array(v.id("project")), + }, + handler: async (ctx, args) => { + for (const projectId of args.projectIds) { + // Get the project before deleting for aggregate sync + const project = await ctx.db.get(projectId); + + await ctx.db.delete(projectId); + + // Remove from aggregates + if (project) { + await allProjects.delete(ctx, project); + await projectsByDay.delete(ctx, project); + await cloudProjectsByTypeDay.delete(ctx, project); + } + } + }, +}); + +export const deleteUninitializedProjects = internalMutation({ + args: {}, + handler: async (ctx, args) => { + const projects = await ctx.db + .query("project") + .withIndex("by_state", (q) => q.eq("state", "unassigned")) + .collect(); + + for (const project of projects) { + await ctx.db.delete(project._id); + + // Remove from aggregates + await allProjects.delete(ctx, project); + await projectsByDay.delete(ctx, project); + await cloudProjectsByTypeDay.delete(ctx, project); + } + }, +}); + +export const replenishPoolIfEmpty = internalMutation({ + args: {}, + handler: async (ctx, args) => { + const unassignedProjects = await ctx.db + .query("project") + .withIndex("by_state", (q) => q.eq("state", "unassigned")) + .collect(); + + const minPoolSize = getProjectPoolSize(); + const snapshotIds = getPoolSnapshotIds(); + + const fullSnapshotId = snapshotIds[0]; + const snapshotCounts = new Map<string, number>(); + for (const snapshotId of snapshotIds) { + snapshotCounts.set(snapshotId, 0); + } + for (const project of unassignedProjects) { + const snapshotId = project.template_id ?? fullSnapshotId; + if (snapshotCounts.has(snapshotId)) { + snapshotCounts.set(snapshotId, (snapshotCounts.get(snapshotId) ?? 0) + 1); + } + } + + if (unassignedProjects.length <= minPoolSize) { + const targetPerSnapshot = Math.ceil(minPoolSize / snapshotIds.length); + const snapshotsToCreate: string[] = []; + + for (const snapshotId of snapshotIds) { + const currentCount = snapshotCounts.get(snapshotId) ?? 0; + const missingCount = Math.max(0, targetPerSnapshot - currentCount); + for (let i = 0; i < missingCount; i++) { + snapshotsToCreate.push(snapshotId); + } + } + + // Stagger scheduled creations so they don't all hit Daytona's API at + // once and trip the 429 throttle. + const createDelayMs = getPoolCreateDelayMs(); + for (let i = 0; i < snapshotsToCreate.length; i++) { + await ctx.scheduler.runAfter( + i * createDelayMs, + internal.codesandbox.createProject.initializeUnassignedProject, + { snapshotId: snapshotsToCreate[i] }, + ); + } + } + }, +}); diff --git a/freebuff/web/convex/project.ts b/freebuff/web/convex/project.ts new file mode 100644 index 0000000000..c8acc6aab3 --- /dev/null +++ b/freebuff/web/convex/project.ts @@ -0,0 +1,2302 @@ +import { paginationOptsValidator } from "convex/server"; +import { v } from "convex/values"; +import { Crons } from "@convex-dev/crons"; +import { components, internal } from "./_generated/api"; +import { Doc, Id } from "./_generated/dataModel"; +import { + getOrganizationContext, + verifyOrganizationAccess, + logSecurityAction, +} from "./org_security"; +import { OrgPermission } from "./schema"; +import { + ActionCtx, + internalMutation, + internalQuery, + mutation, + MutationCtx, + query, + QueryCtx, +} from "./_generated/server"; +import { getAuthUser } from "./users"; +import { allProjects, cloudProjectsByTypeDay, projectsByDay } from "./aggregates/admin_aggregates"; + +// Used to tear down a deleted project's scheduled automation crons. (We avoid +// importing the automations module here to prevent a circular import.) +const crons = new Crons(components.crons); + +function serializeThreadMessage(message: Doc<"messages">) { + return { + _id: message._id, + _creationTime: message._creationTime, + project_id: message.project_id, + thread_id: message.thread_id, + role: message.role, + content: message.content, + date: message.date, + streaming: message.streaming, + isFastReturn: message.isFastReturn, + images: message.images, + object: message.object, + result: message.result, + tool_call: message.tool_call, + thinking: message.thinking, + error_check: message.error_check, + file_apply_results: message.file_apply_results, + core_message: message.core_message, + fast_return_preview: message.fast_return_preview, + token_usage: message.token_usage, + credits_deducted: message.credits_deducted, + total_cost_usd: message.total_cost_usd, + usage_breakdown: message.usage_breakdown, + model_semantic_name: message.model_semantic_name, + commit_hash: message.commit_hash, + deactivated: message.deactivated, + suggestions: message.suggestions, + message_state: message.message_state, + integration_references: message.integration_references, + }; +} + +/** Slimmer payload for live chat subscriptions (omits assistant core_message — UI uses content). */ +function serializeThreadMessageForClientList(message: Doc<"messages">) { + const full = serializeThreadMessage(message); + if (message.role === "assistant") { + const { core_message: _omit, ...rest } = full; + return rest; + } + return full; +} + +/** + * Lightweight serializer for list subscriptions — strips heavy fields + * (thinking, object, result, error_check, tool_call, token_usage, etc.) + * to cut per-message payload from ~250KB to ~10-20KB. + * Frontend lazy-loads heavy fields via getMessageExecutionDetails on expand. + */ +function serializeThreadMessageLight(message: Doc<"messages">) { + return { + _id: message._id, + _creationTime: message._creationTime, + project_id: message.project_id, + thread_id: message.thread_id, + role: message.role, + content: message.content, + date: message.date, + streaming: message.streaming, + isFastReturn: message.isFastReturn, + images: message.images, + core_message: message.role === "user" ? message.core_message : undefined, + fast_return_preview: message.fast_return_preview, + model_semantic_name: message.model_semantic_name, + commit_hash: message.commit_hash, + deactivated: message.deactivated, + suggestions: message.suggestions, + message_state: message.message_state, + integration_references: message.integration_references, + has_thinking: !!message.thinking, + has_execution_details: !!( + message.tool_call?.trim() || + message.error_check?.trim() || + message.result?.trim() || + (message.object?.trim() && + message.object !== "[]" && + message.object !== "{}") + ), + has_usage: !!( + message.token_usage || + message.usage_breakdown || + (message.credits_deducted !== undefined && + message.credits_deducted > 0) || + message.total_cost_usd + ), + }; +} + +type ThreadMessageForClient = ReturnType< + typeof serializeThreadMessageForClientList +>; + +type ThreadMessageForClientLight = ReturnType< + typeof serializeThreadMessageLight +>; + +type DaytonaServer = "legacy" | "new"; +type MigrationStatus = + | "idle" + | "queued" + | "copying" + | "validating" + | "cutting_over" + | "done" + | "failed"; + +type ProjectWithDaytonaMigration = Doc<"project"> & { + daytona_server?: DaytonaServer; + migration_status?: MigrationStatus; + migration_error?: string; + legacy_sandbox_id?: string; + migration_target_sandbox_id?: string; + migration_started_at?: number; + migration_completed_at?: number; +}; + +async function getProjectDaytonaMigrationByProjectId( + ctx: QueryCtx | MutationCtx, + projectId: Id<"project">, +) { + return await ctx.db + .query("daytona_migration") + .withIndex("by_project_id", (q) => q.eq("project_id", projectId)) + .unique(); +} + +async function attachDaytonaMigrationFields( + ctx: QueryCtx | MutationCtx, + project: Doc<"project">, +): Promise<ProjectWithDaytonaMigration> { + const migration = await getProjectDaytonaMigrationByProjectId(ctx, project._id); + if (!migration) { + return project as ProjectWithDaytonaMigration; + } + + return { + ...project, + daytona_server: migration.daytona_server, + migration_status: migration.migration_status, + migration_error: migration.migration_error, + legacy_sandbox_id: migration.legacy_sandbox_id, + migration_target_sandbox_id: migration.migration_target_sandbox_id, + migration_started_at: migration.migration_started_at, + migration_completed_at: migration.migration_completed_at, + }; +} + +async function upsertProjectDaytonaMigration( + ctx: MutationCtx, + projectId: Id<"project">, + patch: { + daytona_server?: DaytonaServer; + migration_status?: MigrationStatus; + migration_error?: string; + legacy_sandbox_id?: string; + migration_target_sandbox_id?: string; + migration_started_at?: number; + migration_completed_at?: number; + updated_at?: number; + }, +): Promise<void> { + const existing = await getProjectDaytonaMigrationByProjectId(ctx, projectId); + if (existing) { + await ctx.db.patch(existing._id, patch); + return; + } + + await ctx.db.insert("daytona_migration", { + project_id: projectId, + ...patch, + }); +} + +export const getProjectDaytonaMigration = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await getProjectDaytonaMigrationByProjectId(ctx, args.projectId); + }, +}); + +// Internal cacheable version - accepts userId to avoid JWT lookup on every call +export const getProjectDataInternal = internalQuery({ + args: { + semanticIdentifier: v.string(), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const projectData = await getVerifiedAccessProject( + ctx, + args.userId, + args.semanticIdentifier, + ); + + if (!projectData) { + return null; + } + + return await attachDaytonaMigrationFields(ctx, projectData); + }, +}); + +// client side - main project data only +export const getProjectData = query({ + // Validators for arguments. + args: { + semanticIdentifier: v.string(), + }, + + // Query implementation. + handler: async (ctx, args): Promise<ProjectWithDaytonaMigration | null> => { + // verify user + const user = await getAuthUser(ctx); + + if (!user) { + return null; + } + + // Delegate to internal cached version + return await ctx.runQuery(internal.project.getProjectDataInternal, { + semanticIdentifier: args.semanticIdentifier, + userId: user._id, + }); + }, +}); + +/** + * Persist the Freebuff Cloud onboarding-checklist dismissal at the project + * level so it never reappears once closed (any project member can dismiss it). + */ +export const setCloudOnboardingDismissed = mutation({ + args: { + semanticIdentifier: v.string(), + dismissed: v.boolean(), + }, + returns: v.object({ ok: v.boolean() }), + handler: async (ctx, args): Promise<{ ok: boolean }> => { + const user = await getAuthUser(ctx); + if (!user) return { ok: false }; + + const project = await ctx.runQuery( + internal.project.getVerifiedAccessProjectInternal, + { + userId: user._id, + semanticIdentifier: args.semanticIdentifier, + }, + ); + if (!project) return { ok: false }; + + await ctx.db.patch(project._id, { + cloud_onboarding_dismissed: args.dismissed, + }); + return { ok: true }; + }, +}); + +// client side - main project data only +export const getProjectDataById = query({ + // Validators for arguments. + args: { + projectId: v.id("project"), + }, + + // Query implementation. + handler: async (ctx, args) => { + // verify user + const user = await getAuthUser(ctx); + + if (!user) { + return null; + } + + const projectData = await ctx.db.get(args.projectId); + + if (!projectData) { + return null; + } + + return await attachDaytonaMigrationFields(ctx, projectData); + }, +}); + +// Get thread messages for a project by semantic identifier +export const getThreadMessages = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const projectData = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + + if (!projectData) { + return []; + } + + if (!projectData.active_thread) { + return []; + } + + // PERFORMANCE FIX: Use by_thread index to avoid scanning all messages + // Instead, we return ALL messages (including rare deactivated ones) + // The client filters out deactivated messages after receiving results + // This reduces document reads from potentially thousands to just 60 + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", projectData.active_thread).eq("streaming", false), + ) + .order("desc") + .take(60); + + return messages.map(serializeThreadMessageLight); + }, +}); + +// Internal cacheable version for verified project lookup +// Pagination must stay in main query, but we can cache the project verification +export const getVerifiedProjectForMessagesInternal = internalQuery({ + args: { + semanticIdentifier: v.string(), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + return await getVerifiedAccessProject( + ctx, + args.userId, + args.semanticIdentifier, + ); + }, +}); + +// Paginated thread messages for infinite scroll +export const listThreadMessages = query({ + args: { + semanticIdentifier: v.string(), + threadId: v.optional(v.id("thread")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args): Promise<any> => { + const user = await getAuthUser(ctx); + if (!user) { + return { page: [], isDone: true, continueCursor: null } as any; + } + + // Delegate project verification to internal cached version + const projectData = await ctx.runQuery( + internal.project.getVerifiedProjectForMessagesInternal, + { + semanticIdentifier: args.semanticIdentifier, + userId: user._id, + }, + ); + + if (!projectData) { + return { page: [], isDone: true, continueCursor: null } as any; + } + + // Use provided threadId or fall back to project's active_thread + const threadId = args.threadId ?? projectData.active_thread; + + if (!threadId) { + return { page: [], isDone: true, continueCursor: null } as any; + } + + // NOTE: Pagination must stay in the main query (cannot delegate to internal query) + // because Convex cursors are bound to the specific query function + try { + // PERFORMANCE FIX: Use by_thread index (thread_id + streaming only) + // This matches the query exactly, avoiding scanning 3000+ documents + // The client filters out deactivated messages after receiving the page + // This reduces document reads from ~3,061 to ~10-100 (page size) + const page = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", threadId).eq("streaming", false), + ) + .order("desc") + .paginate(args.paginationOpts); + + return { + ...page, + page: page.page.map(serializeThreadMessageLight), + }; + } catch (error: any) { + // Handle invalid cursor errors gracefully by returning empty results + // This allows the frontend to restart pagination from the beginning + if (error?.message?.includes("InvalidCursor")) { + console.warn( + `Invalid cursor for thread ${threadId}, resetting pagination`, + ); + return { page: [], isDone: true, continueCursor: null } as any; + } + throw error; + } + }, +}); + +// Lazy-loaded heavy fields for a single message. +// Called by frontend when user expands thinking/execution details/usage. +export const getMessageExecutionDetails = query({ + args: { + messageId: v.id("messages"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) return null; + + const message = await ctx.db.get(args.messageId); + if (!message) return null; + + return { + _id: message._id, + thinking: message.thinking, + object: message.object, + result: message.result, + error_check: message.error_check, + tool_call: message.tool_call, + file_apply_results: message.file_apply_results, + token_usage: message.token_usage, + credits_deducted: message.credits_deducted, + total_cost_usd: message.total_cost_usd, + usage_breakdown: message.usage_breakdown, + }; + }, +}); + +// Internal query to get ALL thread messages including deactivated ones +// Used for operations like undo/revert that need complete message history +export const getAllThreadMessages = internalQuery({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + // Get ALL messages including deactivated ones + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("asc") + .collect(); + + return messages; + }, +}); + +// Internal cacheable version - accepts projectId and activeThread directly to avoid auth overhead +export const getStreamedMessagesInternal = internalQuery({ + args: { + projectId: v.id("project"), + activeThread: v.optional(v.id("thread")), + }, + handler: async (ctx, args) => { + if (!args.activeThread) { + return []; + } + + // PERFORMANCE FIX: Removed .filter() to avoid scanning messages + // Streamed messages are always recent (max 2), so deactivated filtering + // can be done client-side if needed. This improves query performance. + const messages = await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.activeThread).eq("streaming", true), + ) + .order("desc") + .take(2); + + return messages; + }, +}); + +// Internal cacheable version for verified project lookup +// Must stay separate from getStreamedMessagesInternal to allow caching of verification +export const getVerifiedProjectForStreamedMessagesInternal = internalQuery({ + args: { + semanticIdentifier: v.string(), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + return await getVerifiedAccessProject( + ctx, + args.userId, + args.semanticIdentifier, + ); + }, +}); + +// Get streamed messages for a project by semantic identifier +export const getStreamedMessages = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args): Promise<ThreadMessageForClientLight[]> => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + // Delegate project verification to internal cached version + const projectData = await ctx.runQuery( + internal.project.getVerifiedProjectForStreamedMessagesInternal, + { + semanticIdentifier: args.semanticIdentifier, + userId: user._id, + }, + ); + + if (!projectData) { + return []; + } + + // Delegate to internal cached version to avoid redundant auth checks on subsequent calls + const messages = await ctx.runQuery( + internal.project.getStreamedMessagesInternal, + { + projectId: projectData._id, + activeThread: projectData.active_thread, + }, + ); + return messages.map(serializeThreadMessageLight); + }, +}); + +// Internal cacheable version - accepts userId to avoid JWT lookup +export const getEntryPointsInternal = internalQuery({ + args: { + semanticIdentifier: v.string(), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const projectData = await getVerifiedAccessProject( + ctx, + args.userId, + args.semanticIdentifier, + ); + + if (!projectData) { + return []; + } + + const entryPoints = await ctx.db + .query("entry_point") + .withIndex("by_project", (q) => q.eq("project", projectData._id)) + .collect(); + + return entryPoints; + }, +}); + +// Get entry points for a project by semantic identifier +export const getEntryPoints = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args): Promise<Doc<"entry_point">[]> => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + // Delegate to internal cached version + return await ctx.runQuery(internal.project.getEntryPointsInternal, { + semanticIdentifier: args.semanticIdentifier, + userId: user._id, + }); + }, +}); + +/** + * Verify that the user has access to the project + */ +export async function getVerifiedAccessProject( + ctx: QueryCtx | ActionCtx, + userId: Id<"users">, + projectSemanticIdentifier?: string, + projectId?: Id<"project">, + requiredPermission: OrgPermission = "read", + user?: Doc<"users"> | null, +): Promise<Doc<"project"> | null> { + let project: Doc<"project"> | null = null; + if (projectId) { + project = await ctx.runQuery(internal.project.getProject, { projectId }); + } else if (projectSemanticIdentifier) { + project = await ctx.runQuery(internal.project.getProjectFromIdentifier, { + semanticIdentifier: projectSemanticIdentifier, + }); + } + if (!project) { + return null; + } + + // Only fetch user if not already provided + if (!user) { + user = await ctx.runQuery(internal.users.get, { + userId, + }); + } + + if (!user) { + return null; + } + + // godmode can access all projects (even deleted ones for recovery) + if (user.role === "god") { + return project; + } + + // Filter out deleted projects for non-god users + if (project.deleted) { + return null; + } + + // For organization-owned projects, verify organization membership + if (project.organization_id) { + // SECURITY FIX: Verify organization membership through JWT token + const hasOrgAccess = await verifyOrganizationAccess( + ctx, + project.organization_id, + requiredPermission, + ); + + if (hasOrgAccess) { + return project; + } + + // Fallback: Check individual project membership for org projects + const isMember: boolean = await ctx.runQuery( + internal.project.isUserMemberOfProject, + { projectId: project._id, userId }, + ); + + if (isMember) { + return project; + } + + // Log only denied access attempts for security monitoring + await logSecurityAction( + ctx, + "project_access_denied", + `project:${project._id}`, + false, + { + reason: "not_organization_member", + organizationId: project.organization_id, + }, + ); + return null; + } + + // For personal projects, check project membership + const isMember: boolean = await ctx.runQuery( + internal.project.isUserMemberOfProject, + { projectId: project._id, userId }, + ); + + if (!isMember) { + // Log only denied access attempts for security monitoring + await logSecurityAction( + ctx, + "project_access_denied", + `project:${project._id}`, + false, + { + reason: "not_project_member", + }, + ); + return null; + } + + return project; +} + +export const isUserMemberOfProject = internalQuery({ + args: { + projectId: v.id("project"), + userId: v.id("users"), + }, + returns: v.boolean(), + handler: async (ctx, args) => { + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", args.userId), + ) + .first(); + return !!projectMember; + }, +}); + +export const getProject = internalQuery({ + args: { + projectId: v.id("project"), + }, + + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + return null; + } + return await attachDaytonaMigrationFields(ctx, project); + }, +}); + +export const setProjectSpec = internalMutation({ + args: { + projectId: v.id("project"), + spec: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { spec: args.spec }); + }, +}); + +export const setPrettyPreviewUrl = internalMutation({ + args: { + projectId: v.id("project"), + prettyPreviewUrl: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + pretty_preview_url: args.prettyPreviewUrl, + }); + }, +}); + +export const setLastDistBuildAt = internalMutation({ + args: { + projectId: v.id("project"), + lastDistBuildAt: v.number(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + last_dist_build_at: args.lastDistBuildAt, + }); + }, +}); + +/** + * Update project's Convex URL (used during self-hosting migration) + */ +export const updateProjectConvexUrl = internalMutation({ + args: { + projectId: v.id("project"), + convexUrl: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + convex_url: args.convexUrl, + }); + }, +}); + +/** + * Get project data by project ID + */ +// Internal cacheable version - accepts userId instead of calling getAuthUser +export const getProjectByIdInternal = internalQuery({ + args: { + projectId: v.id("project"), + userId: v.id("users"), + userRole: v.optional(v.string()), + }, + handler: async (ctx, args) => { + // Verify user has access to the project + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", args.userId), + ) + .first(); + + if (!projectMember) return null; + + const project = await ctx.db.get(args.projectId); + if (!project) return null; + + // Filter out deleted projects (unless user is god mode) + if (project.deleted && args.userRole !== "god") return null; + + return { + _id: project._id, + name: project.name, + semantic_identifier: project.semantic_identifier, + github_url: project.github_url, + pretty_preview_url: project.pretty_preview_url, + last_opened: project.last_opened, + _creationTime: project._creationTime, + }; + }, +}); + +export const getProjectById = query({ + args: { + projectId: v.id("project"), + }, + handler: async ( + ctx, + args, + ): Promise<{ + _id: Id<"project">; + name?: string; + semantic_identifier: string; + github_url?: string; + pretty_preview_url?: string; + last_opened?: number; + _creationTime: number; + } | null> => { + const user = await getAuthUser(ctx); + if (!user) return null; + + return await ctx.runQuery(internal.project.getProjectByIdInternal, { + projectId: args.projectId, + userId: user._id, + userRole: user.role, + }); + }, +}); + +export const getProjectFromIdentifier = internalQuery({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const project: Doc<"project"> | null = await ctx.db + .query("project") + .withIndex("by_semantic_identifier", (q) => + q.eq("semantic_identifier", args.semanticIdentifier), + ) + .unique(); + + if (!project) { + return null; + } + + return await attachDaytonaMigrationFields(ctx, project); + }, +}); + +export const getUserProjects = query({ + args: { + surface: v.optional(v.union(v.literal("web"), v.literal("cloud"))), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + return null; + } + + // SECURITY FIX: Get organization context from JWT token instead of client + // Fetch this ONCE to avoid duplicate JWT validations + const orgContext = await getOrganizationContext(ctx); + const identity = await ctx.auth.getUserIdentity(); + const hideConnectedRepoProjects = + (identity as Record<string, unknown> | null)?.access_tier === "limited"; + + // Get all project memberships for the user + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", user._id)) + .collect(); + + // Get all projects with screenshot URLs + const projects = await Promise.all( + projectMembers.map(async (pm) => { + const project = await ctx.db.get(pm.project); + if (!project) return null; + + if (hideConnectedRepoProjects && project.project_type === "connected_repo") { + return null; + } + + if (args.surface === "web" && project.project_type === "connected_repo") { + return null; + } + if (args.surface === "cloud" && project.project_type !== "connected_repo") { + return null; + } + + // Filter out deleted projects + if (project.deleted) return null; + + // SECURITY FIX: Filter by verified organization context + if (orgContext?.organizationId) { + // In organization workspace - only show org projects user has access to + if (project.organization_id !== orgContext.organizationId) { + return null; + } + + // Verify user has access to this organization project + // Pass orgContext to avoid re-fetching it (optimization) + if (project.organization_id) { + const hasAccess = await verifyOrganizationAccess( + ctx, + project.organization_id, + "read", + orgContext, // Pass pre-fetched context + ); + if (!hasAccess) { + return null; + } + } + } else { + // In personal workspace - only show personal projects (no organization_id) + if (project.organization_id) { + return null; + } + } + + // Get screenshot URL if storage ID exists + let screenshotUrl = null; + if (project.screenshot_r2_url) { + screenshotUrl = project.screenshot_r2_url; + } + + return { + ...project, + screenshotUrl, + }; + }), + ); + + // Filter out null projects and sort by last_opened + return projects + .filter((p): p is NonNullable<typeof p> => p !== null) + .sort((a, b) => { + const aTime = a.last_opened ?? 0; + const bTime = b.last_opened ?? 0; + return bTime - aTime; + }); + }, +}); + +export const updateProjectCustomInstructions = mutation({ + args: { + projectId: v.id("project"), + customInstructions: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // Verify user has proper access to project (including organization validation) + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + "write", // Require write permission for updating + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + // Additional check: Only allow project members to update custom instructions + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if (!projectMember && user.role !== "god") { + throw new Error("Access denied"); + } + + // Update the project's custom instructions + await ctx.db.patch(args.projectId, { + custom_instructions: args.customInstructions, + }); + + return { success: true }; + }, +}); + +export const getProjectMembers = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify user has access to project + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + // Filter out deleted projects (unless user is god mode) + if (project.deleted && user.role !== "god") { + throw new Error("Project not found"); + } + + const projectMember = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if (!projectMember && user.role !== "god") { + throw new Error("Access denied"); + } + + // Get all project members with their user info + const members = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + const membersWithInfo = await Promise.all( + members.map(async (member) => { + const userInfo = await ctx.db.get(member.user); + return { + ...member, + userInfo, + }; + }), + ); + + return membersWithInfo; + }, +}); + +/** + * Internal version of getProjectMembers for use in actions + * Does not require authentication - used for checking limits + */ +export const getProjectMembersInternal = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + // Get all project members + const members = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + return members; + }, +}); + +export const transferProjectOwnershipInternal = internalMutation({ + args: { + projectId: v.id("project"), + newOwnerUserId: v.id("users"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + const memberships = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .collect(); + + const currentOwnerMemberships = memberships.filter( + (membership) => membership.project_role === "owner", + ); + const existingTargetMembership = memberships.find( + (membership) => membership.user === args.newOwnerUserId, + ); + + let targetMembershipId = existingTargetMembership?._id; + + if (!targetMembershipId) { + targetMembershipId = await ctx.db.insert("project_member", { + project: args.projectId, + user: args.newOwnerUserId, + project_role: "owner", + }); + } + + for (const membership of currentOwnerMemberships) { + if (membership.user === args.newOwnerUserId) { + continue; + } + + await ctx.db.patch(membership._id, { + project_role: "member", + }); + } + + if (existingTargetMembership?.project_role !== "owner") { + await ctx.db.patch(targetMembershipId, { + project_role: "owner", + }); + } + + return { + success: true, + projectId: args.projectId, + newOwnerUserId: args.newOwnerUserId, + createdMembership: !existingTargetMembership, + }; + }, +}); + +export const removeProjectMember = mutation({ + args: { + projectId: v.id("project"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify user has admin access to project + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + const requesterMembership = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .filter((q) => + q.or( + q.eq(q.field("project_role"), "admin"), + q.eq(q.field("project_role"), "owner"), + ), + ) + .first(); + + const hasPlatformAdminAccess = user.role === "god" || user.role === "admin"; + + if (!requesterMembership && !hasPlatformAdminAccess) { + throw new Error("Not authorized to remove members"); + } + + // Don't allow removing the owner + const memberToRemove = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", args.userId), + ) + .first(); + + if (!memberToRemove) throw new Error("Member not found"); + if (memberToRemove.project_role === "owner") { + throw new Error("Cannot remove project owner"); + } + + await ctx.db.delete(memberToRemove._id); + }, +}); + +export const transferProjectOwnership = mutation({ + args: { + projectId: v.id("project"), + newOwnerUserId: v.id("users"), + }, + handler: async ( + ctx, + args, + ): Promise<{ + success: boolean; + projectId: Id<"project">; + newOwnerUserId: Id<"users">; + createdMembership: boolean; + }> => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + const requesterMembership = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + const hasPlatformAdminAccess = user.role === "god" || user.role === "admin"; + + if ( + !hasPlatformAdminAccess && + requesterMembership?.project_role !== "owner" + ) { + throw new Error("Not authorized to transfer ownership"); + } + + return await ctx.runMutation( + internal.project.transferProjectOwnershipInternal, + { + projectId: args.projectId, + newOwnerUserId: args.newOwnerUserId, + }, + ); + }, +}); + +export const setStateProcessing = internalMutation({ + args: { + projectId: v.id("project"), + activeWorkflowId: v.optional(v.string()), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + terminated: false, + state: "processing", + active_workflow_id: args.activeWorkflowId, + }); + }, +}); + +/** Mark that a checkpoint revert has begun (see `reverting_since` in schema). */ +export const setReverting = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + reverting_since: Date.now(), + }); + }, +}); + +/** Clear the revert marker once a revert finishes (success or failure). */ +export const clearReverting = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + reverting_since: undefined, + }); + }, +}); + +export const setStateTerminated = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + terminated: true, + state: "active", + }); + }, +}); + +export const setStateDone = internalMutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + terminated: false, + state: "active", + active_workflow_id: undefined, + }); + }, +}); + +export const setActiveThread = mutation({ + args: { + projectId: v.id("project"), + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + active_thread: args.threadId, + active_agent_thread: undefined, // Clear agent thread to switch to old chat UI + }); + }, +}); + +// Set active agent thread (for new agent-based system) +export const setActiveAgentThread = mutation({ + args: { + projectId: v.id("project"), + threadId: v.id("agent_thread"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // Verify user has access to project + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + // Verify user has access (check project_member) + const membership = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .filter((q) => q.eq(q.field("user"), user._id)) + .first(); + + if (!membership) { + throw new Error("Access denied"); + } + + return await ctx.db.patch(args.projectId, { + active_agent_thread: args.threadId, + active_thread: undefined, // Clear old thread to switch to new chat UI + }); + }, +}); + +export const setProdDeploymentSlug = mutation({ + args: { + projectId: v.id("project"), + slug: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.projectId, { + prod_deployment_slug: args.slug, + }); + }, +}); + +// Internal cacheable version - accepts userRole to avoid JWT lookup +export const getProdDeploymentSlugInternal = internalQuery({ + args: { + projectId: v.id("project"), + userRole: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + // Filter out deleted projects (unless user is god mode) + if (project.deleted && args.userRole !== "god") { + throw new Error("Project not found"); + } + + return project.prod_deployment_slug; + }, +}); + +export const getProdDeploymentSlug = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<string | undefined | null> => { + const user = await getAuthUser(ctx); + + // Delegate to internal cached version + return await ctx.runQuery(internal.project.getProdDeploymentSlugInternal, { + projectId: args.projectId, + userRole: user?.role, + }); + }, +}); + +// Internal cacheable version - accepts userRole instead of calling getAuthUser +export const getProjectDomainsInternal = internalQuery({ + args: { + projectId: v.id("project"), + userRole: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) throw new Error("Project not found"); + + // Filter out deleted projects (unless user is god mode) + if (project.deleted && args.userRole !== "god") { + throw new Error("Project not found"); + } + + const domains = await ctx.db + .query("project_domain") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + + return ( + await Promise.all( + domains + .map((domain) => domain.domainId) + .map((domainId) => ctx.db.get(domainId)), + ) + ).filter((d) => d !== null); + }, +}); + +export const getProjectDomains = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<Doc<"domain">[]> => { + const user = await getAuthUser(ctx); + + return await ctx.runQuery(internal.project.getProjectDomainsInternal, { + projectId: args.projectId, + userRole: user?.role, + }); + }, +}); + +export const getActiveProjectDomains = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args): Promise<Doc<"domain">[]> => { + const user = await getAuthUser(ctx); + + // Call internal version directly to avoid double auth check + const projectDomains = await ctx.runQuery( + internal.project.getProjectDomainsInternal, + { + projectId: args.projectId, + userRole: user?.role, + }, + ); + + return projectDomains.filter( + (d: any) => + d.ownership_verified && + d.wildcard_cert_generated && + d.pointing_verified, + ); + }, +}); + +// Returns the total number of projects (using aggregates for real-time counting) +export const getProjectCount = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + // Use aggregates for efficient, real-time counting + return await allProjects.count(ctx, { bounds: {} }); + }, +}); + +export const setProjectName = mutation({ + args: { + projectId: v.id("project"), + name: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + name: args.name, + }); + + await ctx.scheduler.runAfter( + 0, + internal.codesandbox.management.setHTMLTitle, + { + projectId: args.projectId, + title: args.name, + }, + ); + + await ctx.scheduler.runAfter( + 0, + internal.codesandbox.management.setEnvVarTitle, + { + projectId: args.projectId, + title: args.name, + }, + ); + }, +}); + +export const updatePackageManager = mutation({ + args: { + projectId: v.id("project"), + packageManager: v.union(v.literal("pnpm"), v.literal("bun")), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + packageManager: args.packageManager, + }); + }, +}); + +export const deleteUninitializedProjects = internalMutation({ + args: {}, + handler: async (ctx) => { + const projects = await ctx.db + .query("project") + .withIndex("by_state", (q) => q.eq("state", "unassigned")) + .collect(); + + for (const project of projects) { + const migrationRecord = await getProjectDaytonaMigrationByProjectId( + ctx, + project._id, + ); + if (migrationRecord) { + await ctx.db.delete(migrationRecord._id); + } + + await ctx.db.delete(project._id); + + // Remove from aggregates + await allProjects.delete(ctx, project); + await projectsByDay.delete(ctx, project); + await cloudProjectsByTypeDay.delete(ctx, project); + } + }, +}); + +export const getProjectInspectorData = query({ + args: { semanticIdentifier: v.string() }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: god role required"); + } + // Find the project by semantic_identifier + const project = await ctx.db + .query("project") + .withIndex("by_semantic_identifier", (q) => + q.eq("semantic_identifier", args.semanticIdentifier), + ) + .first(); + if (!project) return { error: "Project not found" }; + + // Fetch entry points + const entryPoints = await ctx.db + .query("entry_point") + .withIndex("by_project", (q) => q.eq("project", project._id)) + .collect(); + + // Fetch messages + const messages = await ctx.db + .query("messages") + .withIndex("by_project_and_date", (q) => q.eq("project_id", project._id)) + .collect(); + + return { + projectData: project, + entryPoints, + messages, + }; + }, +}); + +export const csbDaytonaMigrationUpdate = internalMutation({ + args: { + projectId: v.id("project"), + daytonaSandboxId: v.string(), + previewUrl: v.string(), + templateId: v.string(), + sandboxSize: v.optional( + v.union(v.literal("small"), v.literal("medium"), v.literal("large")), + ), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + await ctx.db.patch(args.projectId, { + sandbox_id: `daytona:${args.daytonaSandboxId}`, + preview_url: args.previewUrl, + template_id: args.templateId, + ...(args.sandboxSize && { sandbox_size: args.sandboxSize }), + }); + + await upsertProjectDaytonaMigration(ctx, args.projectId, { + daytona_server: "legacy", + legacy_sandbox_id: project.sandbox_id, + updated_at: Date.now(), + }); + }, +}); + +export const updateDaytonaMigrationState = internalMutation({ + args: { + projectId: v.id("project"), + migrationStatus: v.union( + v.literal("idle"), + v.literal("queued"), + v.literal("copying"), + v.literal("validating"), + v.literal("cutting_over"), + v.literal("done"), + v.literal("failed"), + ), + migrationError: v.optional(v.string()), + targetSandboxId: v.optional(v.string()), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + await upsertProjectDaytonaMigration(ctx, args.projectId, { + migration_status: args.migrationStatus, + migration_error: args.migrationError, + migration_target_sandbox_id: args.targetSandboxId, + migration_started_at: args.startedAt, + migration_completed_at: args.completedAt, + updated_at: Date.now(), + }); + }, +}); + +export const setProjectDaytonaServer = internalMutation({ + args: { + projectId: v.id("project"), + daytonaServer: v.union(v.literal("legacy"), v.literal("new")), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + await upsertProjectDaytonaMigration(ctx, args.projectId, { + daytona_server: args.daytonaServer, + updated_at: Date.now(), + }); + }, +}); + +export const finalizeDaytonaServerMigration = internalMutation({ + args: { + projectId: v.id("project"), + newSandboxId: v.string(), + previewUrl: v.string(), + templateId: v.optional(v.string()), + newServer: v.union(v.literal("legacy"), v.literal("new")), + packageManager: v.union(v.literal("bun"), v.literal("pnpm")), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + await ctx.db.patch(args.projectId, { + sandbox_id: `daytona:${args.newSandboxId}`, + packageManager: args.packageManager, + preview_url: args.previewUrl, + template_id: args.templateId, + }); + + await upsertProjectDaytonaMigration(ctx, args.projectId, { + daytona_server: args.newServer, + migration_status: "done", + migration_error: undefined, + legacy_sandbox_id: project.sandbox_id, + migration_target_sandbox_id: args.newSandboxId, + migration_completed_at: Date.now(), + updated_at: Date.now(), + }); + }, +}); + +// Internal mutation to update project screenshot +export const updateProjectScreenshot = internalMutation({ + args: { + projectId: v.id("project"), + screenshotR2Url: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + screenshot_r2_url: args.screenshotR2Url, + }); + }, +}); + +// clear project screenshot reference +export const clearProjectScreenshot = internalMutation({ + args: { + projectId: v.id("project"), + }, + returns: v.null(), + handler: async (ctx, args) => { + await ctx.db.patch(args.projectId, { + screenshot_r2_url: undefined, + }); + return null; + }, +}); + +// get screenshot metadata +export const getScreenshotMetadata = internalQuery({ + args: { + storageId: v.id("_storage"), + }, + returns: v.union( + v.object({ + _id: v.id("_storage"), + _creationTime: v.number(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const metadata = await ctx.db.system.get(args.storageId); + if (!metadata) { + return null; + } + return { + _id: metadata._id, + _creationTime: metadata._creationTime, + }; + }, +}); + +// Get project screenshot storage ID +export const getProjectScreenshot = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + "read", + user, + ); + if (!project) { + throw new Error("Project not found or access denied"); + } + + return project.screenshot_r2_url; + }, +}); +export const updateLastOpened = mutation({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Get project by semantic identifier and verify access + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) throw new Error("Project not found or access denied"); + + // Update the last_opened timestamp + await ctx.db.patch(project._id, { + last_opened: Date.now(), + }); + + // Schedule background webhook validation (non-blocking) + // This ensures usage logging webhooks are configured for both dev and prod deployments + await ctx.scheduler.runAfter(0, internal.monitoring.ensureProjectWebhooks, { + projectId: project._id, + }); + }, +}); + +/** + * Soft-delete a project (preserves all project data) + * + * MODIFIED BEHAVIOR: This function marks the project as deleted instead of + * actually deleting it. All project data is preserved. + * + * Current behavior: + * - Sets the project's deleted field to true + * - Preserves all project data, files, and configurations + * - All project members retain their access (though the project is hidden) + * + * Data that is PRESERVED: + * - All child database records (members, threads, messages, logs, etc.) + * - Storage files (screenshots, message images) + * - Related integration and deployment data + * - All conversations and AI-generated content + * - All metadata and analytics + * + * NOTE: The previous full deletion implementation has been commented out + * in the handler below to preserve all project data. If full deletion is + * needed in the future, the commented code can be restored. + */ +export const deleteProject = mutation({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // Verify user has access to project + const project = await getVerifiedAccessProject( + ctx, + user._id, + undefined, + args.projectId, + "read", + user, + ); + if (!project) { + throw new Error("Project not found or access denied"); + } + + // Mark the project as deleted (soft delete - preserves all project data) + await ctx.db.patch(args.projectId, { + deleted: true, + }); + + // Tear down any scheduled automations for this project so they stop firing. + // Soft-delete keeps the rows (for a potential restore) but removes the live + // component crons and disables them. + const automations = await ctx.db + .query("automation") + .withIndex("by_project", (q) => q.eq("project_id", args.projectId)) + .collect(); + for (const automation of automations) { + if (automation.cron_component_id) { + await crons.delete(ctx, { id: automation.cron_component_id }); + } + if (automation.enabled || automation.cron_component_id) { + await ctx.db.patch(automation._id, { + enabled: false, + cron_component_id: undefined, + }); + } + } + + // Track sandbox deletion to decrement quota + const sandboxSize = project.sandbox_size || "small"; // Default to small for legacy projects + await ctx.scheduler.runAfter( + 0, + internal.codesandbox.projectCrud.trackSandboxDeletion, + { + sandboxSize, + }, + ); + + return { success: true }; + + // ==================== COMMENTED OUT: FULL PROJECT DELETION ==================== + // The code below has been commented out to preserve all project data + // Only user access is now removed (see above) + // ============================================================================== + + // // Delete all related child records first to maintain data consistency + // // TODO: Consider using scheduled functions or batched operations for large datasets + // // TODO: Add transaction support when available to ensure atomicity + + // // Delete project members + // const projectMembers = await ctx.db + // .query("project_member") + // .withIndex("by_project", (q) => q.eq("project", args.projectId)) + // .collect(); + // for (const member of projectMembers) { + // await ctx.db.delete(member._id); + // } + + // // Delete project threads + // const threads = await ctx.db + // .query("thread") + // .withIndex("by_project_by_entry_point", (q) => + // q.eq("project", args.projectId), + // ) + // .collect(); + // for (const thread of threads) { + // await ctx.db.delete(thread._id); + // } + + // // Delete entry points + // const entryPoints = await ctx.db + // .query("entry_point") + // .withIndex("by_project", (q) => q.eq("project", args.projectId)) + // .collect(); + // for (const entryPoint of entryPoints) { + // await ctx.db.delete(entryPoint._id); + // } + + // // Delete messages and their storage files (images) + // const messages = await ctx.db + // .query("messages") + // .withIndex("by_project_and_date", (q) => + // q.eq("project_id", args.projectId), + // ) + // .collect(); + // for (const message of messages) { + // // Clean up any stored images associated with messages + // if (message.images) { + // for (const storageId of message.images) { + // try { + // await ctx.storage.delete(storageId); + // } catch (error) { + // console.warn(`Failed to delete storage file ${storageId}:`, error); + // } + // } + // } + // await ctx.db.delete(message._id); + // } + + // // Delete message logs + // // const messageLogs = await ctx.db + // // .query("message_logs") + // // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // // .collect(); + // // for (const log of messageLogs) { + // // await ctx.db.delete(log._id); + // // } + + // // Delete memory records + // const memories = await ctx.db + // .query("memory") + // .filter((q) => q.eq(q.field("project"), args.projectId)) + // .collect(); + // for (const memory of memories) { + // await ctx.db.delete(memory._id); + // } + + // // Delete search logs + // const searchLogs = await ctx.db + // .query("search_logs") + // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // .collect(); + // for (const searchLog of searchLogs) { + // await ctx.db.delete(searchLog._id); + // } + + // // Delete integration flow states + // const integrationFlowStates = await ctx.db + // .query("integration_flow_state") + // .withIndex("by_project_thread", (q) => q.eq("project_id", args.projectId)) + // .collect(); + // for (const flowState of integrationFlowStates) { + // await ctx.db.delete(flowState._id); + // } + + // // Delete runtime errors + // const runtimeErrors = await ctx.db + // .query("runtime_error") + // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // .collect(); + // for (const error of runtimeErrors) { + // await ctx.db.delete(error._id); + // } + + // // Delete build errors + // const buildErrors = await ctx.db + // .query("build_error") + // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // .collect(); + // for (const error of buildErrors) { + // await ctx.db.delete(error._id); + // } + + // // Delete deployments + // const deployments = await ctx.db + // .query("deployments") + // .withIndex("by_project_and_state", (q) => q.eq("project", args.projectId)) + // .collect(); + // for (const deployment of deployments) { + // await ctx.db.delete(deployment._id); + // } + + // // Delete invites + // const invites = await ctx.db + // .query("invites") + // .withIndex("by_project", (q) => q.eq("project", args.projectId)) + // .collect(); + // for (const invite of invites) { + // await ctx.db.delete(invite._id); + // } + + // // Delete project integrations + // const projectIntegrations = await ctx.db + // .query("project_integration") + // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // .collect(); + // for (const integration of projectIntegrations) { + // await ctx.db.delete(integration._id); + // } + + // // Delete project domains + // const projectDomains = await ctx.db + // .query("project_domain") + // .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + // .collect(); + // for (const domain of projectDomains) { + // await ctx.db.delete(domain._id); + // } + + // // Clean up project storage files (screenshots) + // if (project.screenshot_storage_id) { + // try { + // await ctx.storage.delete(project.screenshot_storage_id); + // } catch (error) { + // console.warn( + // `Failed to delete project screenshot ${project.screenshot_storage_id}:`, + // error, + // ); + // } + // } + + // // Delete the project (parent record) + // await ctx.db.delete(args.projectId); + + // return { success: true }; + }, +}); + +// Internal query version of getVerifiedAccessProject for use by actions +export const getVerifiedAccessProjectInternal = internalQuery({ + args: { + userId: v.id("users"), + semanticIdentifier: v.optional(v.string()), + projectId: v.optional(v.id("project")), + }, + handler: async (ctx, args) => { + return await getVerifiedAccessProject( + ctx, + args.userId, + args.semanticIdentifier, + args.projectId, + ); + }, +}); + +/** + * Look up project by Convex deployment name + * Searches both dev and prod deployment names + */ +export const getProjectByDeploymentName = internalQuery({ + args: { + deploymentName: v.string(), + }, + handler: async (ctx, args) => { + // Try to find by dev deployment name first (using index for efficiency) + const devInstance = await ctx.db + .query("project_convex_instance") + .withIndex("by_dev_deployment_name", (q) => + q.eq("devDeploymentName", args.deploymentName), + ) + .first(); + + if (devInstance) { + const project = await ctx.db.get(devInstance.projectId); + return project; + } + + // If not found, try prod deployment name (using index) + const prodInstance = await ctx.db + .query("project_convex_instance") + .withIndex("by_prod_deployment_name", (q) => + q.eq("prodDeploymentName", args.deploymentName), + ) + .first(); + + if (prodInstance) { + const project = await ctx.db.get(prodInstance.projectId); + return project; + } + + // Not found + return null; + }, +}); + +/** + * Get project owner information + * Returns user details for personal projects or organization ID for org projects + */ +export const getProjectOwner = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + return null; + } + + // Check if project is organization-owned + if (project.organization_id) { + return { + type: "organization" as const, + organization_id: project.organization_id, + }; + } + + // For personal projects, find the owner from project_member + const owner = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", args.projectId)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .first(); + + if (!owner) { + return null; + } + + // Get user details + const user = await ctx.db.get(owner.user); + if (!user) { + return null; + } + + return { + type: "user" as const, + user: { + name: user.name, + email: user.email, + clerk_id: user.clerk_id, + }, + }; + }, +}); + +export const getConvexInstance = query({ + args: { + projectId: v.id("project"), + }, + handler: async ( + ctx, + args, + ): Promise<Doc<"project_convex_instance"> | null> => { + return await ctx.runQuery(internal.project.getConvexInstanceInternal, { + projectId: args.projectId, + }); + }, +}); + +export const getConvexInstanceInternal = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const convexInstance = await ctx.db + .query("project_convex_instance") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .first(); + + return convexInstance; + }, +}); + +/** + * Remove excess members when a user downgrades their plan. + * Members are removed in LIFO order (most recently added first). + * Project owners are never removed. + * + * @param userId - The ID of the user whose plan is being downgraded + * @param newMemberLimit - The new total member limit for the user + * @returns Object with removed count and details + */ +export const removeExcessMembersOnDowngrade = internalMutation({ + args: { + userId: v.id("users"), + newMemberLimit: v.number(), + }, + handler: async (ctx, args) => { + // Get all projects where this user is the owner + const ownedProjectMemberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", args.userId)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .collect(); + + // Collect all members from all owned projects with their creation time + const allMembers: Array<{ + membershipId: (typeof ownedProjectMemberships)[0]["_id"]; + projectId: (typeof ownedProjectMemberships)[0]["project"]; + userId: (typeof ownedProjectMemberships)[0]["user"]; + role: (typeof ownedProjectMemberships)[0]["project_role"]; + creationTime: number; + }> = []; + + for (const ownership of ownedProjectMemberships) { + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", ownership.project)) + .collect(); + + for (const member of projectMembers) { + allMembers.push({ + membershipId: member._id, + projectId: member.project, + userId: member.user, + role: member.project_role, + creationTime: member._creationTime, + }); + } + } + + const currentTotal = allMembers.length; + + if (currentTotal <= args.newMemberLimit) { + return { + removedCount: 0, + removedMembers: [], + message: "No members need to be removed", + }; + } + + // Sort by creation time descending (most recent first) for LIFO removal + // Filter out owners - they can never be removed + const removableMembersLIFO = allMembers + .filter((m) => m.role !== "owner") + .sort((a, b) => b.creationTime - a.creationTime); + + const membersToRemove = currentTotal - args.newMemberLimit; + const membersToDelete = removableMembersLIFO.slice(0, membersToRemove); + + const removedMembers: Array<{ projectId: string; userId: string }> = []; + + for (const member of membersToDelete) { + await ctx.db.delete(member.membershipId); + removedMembers.push({ + projectId: member.projectId, + userId: member.userId, + }); + } + + return { + removedCount: removedMembers.length, + removedMembers, + message: `Removed ${removedMembers.length} member(s) to comply with new limit of ${args.newMemberLimit}`, + }; + }, +}); + +/** + * Get the total member count across all projects owned by a user + */ +export const getTotalMemberCountForUser = internalQuery({ + args: { + userId: v.id("users"), + }, + handler: async (ctx, args) => { + // Get all projects where this user is the owner + const ownedProjectMemberships = await ctx.db + .query("project_member") + .withIndex("by_user", (q) => q.eq("user", args.userId)) + .filter((q) => q.eq(q.field("project_role"), "owner")) + .collect(); + + // For each owned project, count all members + let totalMemberCount = 0; + for (const ownership of ownedProjectMemberships) { + const projectMembers = await ctx.db + .query("project_member") + .withIndex("by_project", (q) => q.eq("project", ownership.project)) + .collect(); + totalMemberCount += projectMembers.length; + } + + return totalMemberCount; + }, +}); diff --git a/freebuff/web/convex/recommended_integrations.ts b/freebuff/web/convex/recommended_integrations.ts new file mode 100644 index 0000000000..a375b4b49f --- /dev/null +++ b/freebuff/web/convex/recommended_integrations.ts @@ -0,0 +1,2042 @@ +import { internalMutation } from "./_generated/server"; + +// Function to create recommended integrations +export const createRecommendedIntegrationsData = internalMutation({ + args: {}, + handler: async (ctx) => { + // Check if recommended integrations already exist + const existingResend = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "resend")) + .first(); + + const existingAutumn = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "autumn_payments")) + .first(); + + const existingOpenRouter = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "openrouter")) + .first(); + + const existingTwilio = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "twilio_sms")) + .first(); + + const existingTwitter = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "twitter_api")) + .first(); + + const existingConvexGeospatial = await ctx.db + .query("integration") + .filter((q) => q.eq(q.field("reference_id"), "convex_geospatial")) + .first(); + + // Create Resend integration if it doesn't exist + if (!existingResend) { + await ctx.db.insert("integration", { + cover_image: "https://resend.com/static/favicons/favicon-32x32.png", + reference_id: "resend", + title: "Resend", + description: + "A straightforward API for sending emails from your application.", + tags: ["Email", "API", "Transactional", "SMTP"], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://resend.com/docs/introduction", + "https://resend.com/docs/send-with-nodejs", + "https://resend.com/docs/api-reference/emails/send-email", + ], + llm_instructions: `### 1. Overview + +Resend is a modern email API that provides a simple, developer-friendly interface for sending transactional emails. It's designed to be a reliable alternative to traditional email services with better deliverability and easier integration. + +### 2. Installation + +Install the Resend SDK using your package manager: + +\`\`\`bash +# Install the resend package +npm install resend +\`\`\` + +### 3. Basic Setup + +Initialize the Resend client with your API key: + +\`\`\`typescript +import { Resend } from 'resend'; + +const resend = new Resend(process.env.RESEND_API_KEY); +\`\`\` + +### 4. Sending Emails + +#### Basic Email + +\`\`\`typescript +async function sendEmail(to: string, subject: string, html: string) { + try { + const { data, error } = await resend.emails.send({ + from: 'Your App <noreply@yourdomain.com>', + to: [to], + subject: subject, + html: html, + }); + + if (error) { + console.error('Error sending email:', error); + return { success: false, error }; + } + + console.log('Email sent successfully:', data); + return { success: true, data }; + } catch (error) { + console.error('Error sending email:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Email with Text and HTML + +\`\`\`typescript +async function sendEmailWithTextAndHtml(to: string, subject: string, text: string, html: string) { + try { + const { data, error } = await resend.emails.send({ + from: 'Your App <noreply@yourdomain.com>', + to: [to], + subject: subject, + text: text, + html: html, + }); + + if (error) { + return { success: false, error }; + } + + return { success: true, data }; + } catch (error) { + return { success: false, error }; + } +} +\`\`\` + +### 5. Email Templates + +#### Welcome Email + +\`\`\`typescript +async function sendWelcomeEmail(to: string, userName: string) { + const html = \` + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1 style="color: #333;">Welcome to Our App!</h1> + <p>Hi \${userName},</p> + <p>Thank you for signing up. We're excited to have you on board!</p> + <p>Get started by exploring our features and let us know if you have any questions.</p> + <p>Best regards,<br>The Team</p> + </div> + \`; + + return await sendEmail(to, 'Welcome to Our App!', html); +} +\`\`\` + +#### Password Reset Email + +\`\`\`typescript +async function sendPasswordResetEmail(to: string, resetLink: string) { + const html = \` + <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> + <h1 style="color: #333;">Password Reset Request</h1> + <p>You requested a password reset. Click the button below to reset your password:</p> + <a href="\${resetLink}" + style="display: inline-block; padding: 12px 24px; background-color: #007cba; color: white; text-decoration: none; border-radius: 4px; margin: 16px 0;"> + Reset Password + </a> + <p>This link will expire in 1 hour.</p> + <p>If you didn't request this, please ignore this email.</p> + </div> + \`; + + return await sendEmail(to, 'Password Reset Request', html); +} +\`\`\` + +### 6. Bulk Email + +\`\`\`typescript +async function sendBulkEmail(recipients: string[], subject: string, html: string) { + try { + const { data, error } = await resend.emails.send({ + from: 'Your App <noreply@yourdomain.com>', + to: recipients, + subject: subject, + html: html, + }); + + if (error) { + return { success: false, error }; + } + + return { success: true, data }; + } catch (error) { + return { success: false, error }; + } +} +\`\`\` + +### 7. Email with Attachments + +\`\`\`typescript +async function sendEmailWithAttachment(to: string, subject: string, html: string, attachmentPath: string) { + try { + const fs = require('fs'); + const path = require('path'); + + const attachment = fs.readFileSync(attachmentPath); + const filename = path.basename(attachmentPath); + + const { data, error } = await resend.emails.send({ + from: 'Your App <noreply@yourdomain.com>', + to: [to], + subject: subject, + html: html, + attachments: [ + { + filename: filename, + content: attachment, + }, + ], + }); + + if (error) { + return { success: false, error }; + } + + return { success: true, data }; + } catch (error) { + return { success: false, error }; + } +} +\`\`\` + +### 8. React Email Integration + +Resend works great with React Email for creating beautiful, responsive email templates: + +\`\`\`bash +# Install React Email packages +npm install react-email @react-email/components +\`\`\` + +\`\`\`tsx +// emails/WelcomeEmail.tsx +import { Html, Head, Body, Container, Text, Button } from '@react-email/components'; + +interface WelcomeEmailProps { + userName: string; + loginUrl: string; +} + +export function WelcomeEmail({ userName, loginUrl }: WelcomeEmailProps) { + return ( + <Html> + <Head /> + <Body style={{ fontFamily: 'Arial, sans-serif' }}> + <Container> + <Text style={{ fontSize: '24px', fontWeight: 'bold' }}> + Welcome, {userName}! + </Text> + <Text> + Thank you for joining our platform. We're excited to have you on board. + </Text> + <Button href={loginUrl} style={{ backgroundColor: '#007cba', color: 'white', padding: '12px 24px' }}> + Get Started + </Button> + </Container> + </Body> + </Html> + ); +} +\`\`\` + +\`\`\`typescript +import { render } from '@react-email/render'; +import { WelcomeEmail } from './emails/WelcomeEmail'; + +async function sendWelcomeEmailWithReact(to: string, userName: string, loginUrl: string) { + const html = render(WelcomeEmail({ userName, loginUrl })); + + return await sendEmail(to, 'Welcome to Our App!', html); +} +\`\`\` + +### 9. Environment Variables + +Set the following environment variable: + +- \`RESEND_API_KEY\`: Your Resend API key + +### 10. Domain Verification + +To send emails from your own domain, you need to: +1. Add and verify your domain in the Resend dashboard +2. Configure DNS records (SPF, DKIM, DMARC) +3. Use your verified domain in the 'from' field + +### 11. Error Handling + +\`\`\`typescript +async function sendEmailWithErrorHandling(to: string, subject: string, html: string) { + try { + const { data, error } = await resend.emails.send({ + from: 'Your App <noreply@yourdomain.com>', + to: [to], + subject, + html, + }); + + if (error) { + if (error.name === 'validation_error') { + return { success: false, error: 'Invalid email parameters' }; + } else if (error.name === 'missing_required_field') { + return { success: false, error: 'Missing required email field' }; + } else { + return { success: false, error: 'Failed to send email' }; + } + } + + return { success: true, data }; + } catch (error) { + console.error('Unexpected error:', error); + return { success: false, error: 'Unexpected error occurred' }; + } +} +\`\`\` + +### 12. Best Practices + +1. **Domain Verification**: Always verify your sending domain for better deliverability +2. **Unsubscribe Links**: Include unsubscribe links in marketing emails +3. **Rate Limiting**: Respect rate limits and implement proper retry logic +4. **Email Validation**: Validate email addresses before sending +5. **Templates**: Use React Email or HTML templates for consistent branding +6. **Testing**: Test emails thoroughly across different email clients`, + human_added_notes: + "This is a recommended integration for implementing email functionality with better deliverability than traditional SMTP.", + env_variables: [ + { + id: "RESEND_API_KEY", + description: + "Your Resend API key for authenticating requests. You can create a new API key by navigating to the API Keys section in your Resend dashboard.", + }, + ], + user_instructions: `1. Create a Resend account at https://resend.com + +2. Navigate to the API Keys section in the dashboard at https://resend.com/api-keys + +3. Click "Create API Key", give it a name, and select "Sending access" for the permission + +4. Copy the generated API key and set it as an environment variable named RESEND_API_KEY + +5. (Optional) Add and verify your domain: + - Go to Domains section in the dashboard + - Add your domain and configure the required DNS records + - Wait for verification to complete + +6. Update your email 'from' address to use your verified domain`, + images: [], + last_updated: Date.now(), + }); + } + + // Create Autumn Payments integration if it doesn't exist + if (!existingAutumn) { + await ctx.db.insert("integration", { + cover_image: + "https://framerusercontent.com/images/F6dnECLFzPUx2q6IzjL3rfjoAxg.png", + reference_id: "autumn_payments", + title: "Autumn Payments", + description: + "Payments layer over Stripe that simplifies implementing pricing models, managing entitlements, and handling payment flows.", + tags: [ + "Payments", + "Billing", + "Subscriptions", + "Stripe", + "Entitlements", + ], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://docs.useautumn.com/introduction", + "https://docs.useautumn.com/quickstart", + "https://github.com/useautumn/autumn", + ], + llm_instructions: `### 1. Overview + +Autumn provides a simplified interface for managing complex billing logic such as subscriptions, credit systems, and usage-based models on top of Stripe. This integration involves setting up a backend handler to communicate with Autumn's API and a frontend provider to make customer and subscription data available to your React application. + +### 2. Installation + +Install the \`autumn-js\` library in both your frontend and backend directories: + +\`\`\`bash +# Install the autumn-js package +npm install autumn-js +\`\`\` + +### 3. Backend Setup (Convex with Node.js) + +You will need to mount the Autumn handler on your server. This handler exposes API routes that the frontend hooks will use to interact with Autumn. + +The documentation provides an example for an Express server. You will adapt this for your Convex HTTP action. + +**Example from Documentation (for Express):** + +\`\`\`typescript +// From Autumn Documentation for Express +import { autumnHandler } from "autumn-js/express"; + +// This example assumes an Express app \`app\` is already initialized. +// app.use(express.json()); // Ensure the request body is parsed before the autumnHandler + app.use( + "/api/autumn", + autumnHandler({ + identify: async (req: any) => { + // The following is an example and needs to be adapted to your auth system. + if (!req.isAuthenticated() || !req.user?.claims?.sub) { + throw new Error("User not authenticated"); + } + + const user = await storage.getUser(req.user.claims.sub); + return { + customerId: req.user.claims.sub, + customerData: { + name: user?.firstName && user?.lastName + ? \`\${user.firstName} \${user.lastName}\` + : user?.email?.split('@')[0] || "User", + email: user?.email || req.user.claims.email, + }, + }; + }, + }) + ); +\`\`\` + +**Note on \`identify\` function:** The \`identify\` function is crucial. It's an async function that should return the \`customerId\`. This ID is typically the internal user ID from your authentication provider. Autumn will create a new customer if the \`customerId\` is new. You can also pass \`customerData\` to pre-fill customer information in Autumn. + +### 4. Frontend Setup (React + Vite) + +Wrap your main application component with the \`<AutumnProvider />\` + +\`\`\`tsx +// In your main application entry file (e.g., main.tsx or App.tsx) +import { AutumnProvider } from "autumn-js/react"; +import App from "./App"; + +// ... other imports + +// Wrap your App component with AutumnProvider +<AutumnProvider + backendUrl="YOUR_BACKEND_URL" // Replace with the URL of your Convex backend +> + <App /> +</AutumnProvider> +\`\`\` + +If you are using a full-stack framework where the frontend and backend are on the same domain, you might be able to use an empty string for \`backendUrl\`. However, with a separate Convex backend, you will need to provide the full URL to your backend where the Autumn handler is exposed. + +### 5. Usage + +To verify the integration, you can use the \`useCustomer\` hook in any of your React components to fetch and display the current customer's data from Autumn. + +\`\`\`tsx +import { useCustomer } from "autumn-js/react"; + +function MyComponent() { + const { customer } = useCustomer(); + + console.log("Autumn Customer", customer); + + // You can now use the customer object in your component + return ( + <div> + {customer ? ( + <p>Welcome, {customer.name || 'user'}!</p> + ) : ( + <p>Loading customer data...</p> + )} + </div> + ); +} +\`\`\` + +The \`useCustomer\` hook returns the current customer object. The value will be \`undefined\` if the customer is not yet known or logged in. + +### 6. Required Secrets + +The following secret key needs to be set as an environment variable in your Convex backend: + +* \`AUTUMN_SECRET_KEY\`: Your secret API key from the Autumn dashboard.`, + human_added_notes: + "This is a recommended integration for implementing payments and billing functionality.", + env_variables: [ + { + id: "AUTUMN_SECRET_KEY", + description: + "To get your Autumn Secret API Key, create an Autumn account and then generate the key from the developer section of the dashboard. This key is used to authenticate with the Autumn API from your backend.", + }, + ], + user_instructions: `Create an Autumn account at https://useautumn.com/. Once you have created an account, navigate to the "Dev" or "API Keys" section of the sandbox environment at https://app.useautumn.com/sandbox/dev to generate your \`AUTUMN_SECRET_KEY\`.`, + images: [ + "https://harmless-tapir-303.convex.cloud/api/storage/7c81b4ea-d2c3-4c9c-ad4c-5f2c07af54c1", + ], + last_updated: Date.now(), + }); + } + + // Create OpenRouter integration if it doesn't exist + if (!existingOpenRouter) { + await ctx.db.insert("integration", { + cover_image: "https://openrouter.ai/favicon.ico", + reference_id: "openrouter", + title: "OpenRouter", + description: + "Access to multiple AI models through a single API for building AI agents and applications.", + tags: ["AI", "LLM", "OpenAI", "Claude", "Gemini", "API"], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://openrouter.ai/docs", + "https://openrouter.ai/docs/quick-start", + "https://openrouter.ai/docs/requests", + "https://openrouter.ai/docs/responses", + ], + llm_instructions: `### 1. Overview + +OpenRouter provides access to multiple AI models through a single API, including OpenAI GPT models, Anthropic Claude, Google Gemini, and many others. This integration allows you to build AI agents and applications that can switch between different AI providers seamlessly. + +### 2. Installation + +OpenRouter is compatible with the OpenAI SDK, so you can use the standard OpenAI package: + +\`\`\`bash +# Install the openai package +npm install openai +\`\`\` + +### 3. Basic Setup + +Initialize the OpenAI client with OpenRouter's base URL and your API key: + +\`\`\`typescript +import OpenAI from 'openai'; + +const openai = new OpenAI({ + apiKey: process.env.OPENROUTER_API_KEY, + baseURL: 'https://openrouter.ai/api/v1', + defaultHeaders: { + 'HTTP-Referer': 'https://your-app.com', // Optional: for rankings + 'X-Title': 'Your App Name', // Optional: for rankings + }, +}); +\`\`\` + +### 4. Sending Chat Completions + +#### Basic Chat Completion + +\`\`\`typescript +async function sendChatMessage() { + try { + const completion = await openai.chat.completions.create({ + model: 'anthropic/claude-3-haiku', // or any other model + messages: [ + { + role: 'user', + content: 'Hello, how are you?' + } + ], + max_tokens: 100, + }); + + const response = completion.choices[0].message.content; + console.log('AI Response:', response); + return { success: true, response }; + } catch (error) { + console.error('Error sending chat message:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Streaming Responses + +\`\`\`typescript +async function sendStreamingMessage() { + try { + const stream = await openai.chat.completions.create({ + model: 'anthropic/claude-3-haiku', + messages: [ + { + role: 'user', + content: 'Tell me a story' + } + ], + stream: true, + max_tokens: 500, + }); + + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content; + if (content) { + process.stdout.write(content); + } + } + + return { success: true }; + } catch (error) { + console.error('Error streaming message:', error); + return { success: false, error }; + } +} +\`\`\` + +### 5. Popular Models + +OpenRouter supports many models. Here are some popular ones: + +\`\`\`typescript +const models = { + // OpenAI Models + gpt4: 'openai/gpt-4', + gpt4Turbo: 'openai/gpt-4-turbo', + gpt35Turbo: 'openai/gpt-3.5-turbo', + + // Anthropic Models + claude3Opus: 'anthropic/claude-3-opus', + claude3Sonnet: 'anthropic/claude-3-sonnet', + claude3Haiku: 'anthropic/claude-3-haiku', + + // Google Models + geminiPro: 'google/gemini-pro', + geminiProVision: 'google/gemini-pro-vision', + + // Other Models + llama2: 'meta-llama/llama-2-70b-chat', + mixtral: 'mistralai/mixtral-8x7b-instruct', +}; + +async function useSpecificModel(modelName: string) { + try { + const completion = await openai.chat.completions.create({ + model: modelName, + messages: [ + { + role: 'user', + content: 'What model are you?' + } + ], + max_tokens: 100, + }); + + return completion.choices[0].message.content; + } catch (error) { + console.error(\`Error with model \${modelName}:\`, error); + return null; + } +} +\`\`\` + +### 6. Environment Variables + +Set the following environment variable: + +- \`OPENROUTER_API_KEY\`: Your OpenRouter API key from the OpenRouter dashboard + +### 7. Advanced Usage + +#### Function Calling + +\`\`\`typescript +async function callFunction() { + try { + const completion = await openai.chat.completions.create({ + model: 'openai/gpt-4', + messages: [ + { + role: 'user', + content: 'What is the weather like in San Francisco?' + } + ], + functions: [ + { + name: 'get_weather', + description: 'Get the current weather in a given location', + parameters: { + type: 'object', + properties: { + location: { + type: 'string', + description: 'The city and state, e.g. San Francisco, CA' + } + }, + required: ['location'] + } + } + ], + function_call: 'auto', + }); + + const functionCall = completion.choices[0].message.function_call; + if (functionCall) { + console.log('Function called:', functionCall.name); + console.log('Arguments:', functionCall.arguments); + } + + return { success: true, completion }; + } catch (error) { + console.error('Error with function calling:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Model Routing with Fallback + +\`\`\`typescript +async function sendWithFallback(message: string) { + const models = [ + 'anthropic/claude-3-haiku', + 'openai/gpt-3.5-turbo', + 'google/gemini-pro' + ]; + + for (const model of models) { + try { + const completion = await openai.chat.completions.create({ + model, + messages: [ + { + role: 'user', + content: message + } + ], + max_tokens: 100, + }); + + return { + success: true, + response: completion.choices[0].message.content, + model: model + }; + } catch (error) { + console.warn(\`Model \${model} failed, trying next...\`); + continue; + } + } + + return { success: false, error: 'All models failed' }; +} +\`\`\` + +### 8. Error Handling + +Always implement proper error handling: + +\`\`\`typescript +async function sendMessageWithErrorHandling(message: string) { + try { + const completion = await openai.chat.completions.create({ + model: 'anthropic/claude-3-haiku', + messages: [ + { + role: 'user', + content: message + } + ], + max_tokens: 100, + }); + + return { + success: true, + response: completion.choices[0].message.content + }; + } catch (error: any) { + if (error.status === 429) { + console.error('Rate limit exceeded'); + return { success: false, error: 'Rate limit exceeded' }; + } else if (error.status === 401) { + console.error('Invalid API key'); + return { success: false, error: 'Invalid API key' }; + } else if (error.status === 400) { + console.error('Bad request:', error.message); + return { success: false, error: 'Bad request' }; + } else { + console.error('Unknown error:', error); + return { success: false, error: 'Unknown error' }; + } + } +} +\`\`\` + +### 9. Best Practices + +1. **Model Selection**: Choose the right model for your use case based on cost, speed, and capability +2. **Rate Limiting**: Implement proper rate limiting and retry logic +3. **Error Handling**: Always handle API errors gracefully +4. **Streaming**: Use streaming for better user experience in chat applications +5. **Cost Management**: Monitor your usage and costs through the OpenRouter dashboard +6. **Model Fallbacks**: Implement fallback logic for better reliability`, + human_added_notes: + "This is a recommended integration for implementing AI functionality with access to multiple AI models.", + env_variables: [ + { + id: "OPENROUTER_API_KEY", + description: + "Your OpenRouter API key from the OpenRouter dashboard. Go to https://openrouter.ai/keys to generate a new API key.", + }, + ], + user_instructions: `1. Go to OpenRouter website at https://openrouter.ai/ + +2. Click "Sign In" or "Sign Up" to create an account + +3. Once logged in, navigate to the "Keys" section at https://openrouter.ai/keys + +4. Click "Create Key" button + +5. Give your API key a name (e.g., "My Project") + +6. Set any usage limits if desired (optional) + +7. Click "Create Key" to generate the key + +8. Copy the generated API key (it will start with "sk-or-") + +9. Add this key to your environment variables as OPENROUTER_API_KEY + +Note: Keep your API key secure and never share it publicly.`, + images: [], + last_updated: Date.now(), + }); + } + + // Create Twilio SMS integration if it doesn't exist + if (!existingTwilio) { + await ctx.db.insert("integration", { + cover_image: + "https://www.twilio.com/content/dam/twilio-com/global/en/favicons/favicon-32x32.png", + reference_id: "twilio_sms", + title: "Twilio SMS", + description: + "Send SMS text messages and notifications using Twilio's powerful messaging API.", + tags: ["SMS", "Messaging", "Notifications", "Text", "Communication"], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://www.twilio.com/docs/sms", + "https://www.twilio.com/docs/libraries/node", + "https://www.twilio.com/docs/sms/quickstart/node", + "https://github.com/twilio/twilio-node", + ], + llm_instructions: `### 1. Overview + +Twilio SMS provides a simple, reliable API for sending SMS text messages and notifications. This integration allows you to send text messages to users for notifications, alerts, OTP verification, and other communication needs. + +### 2. Installation + +Install the Twilio Node.js library: + +\`\`\`bash +# Install the twilio package +npm install twilio +\`\`\` + +### 3. Basic Setup + +Initialize the Twilio client with your Account SID and Auth Token: + +\`\`\`typescript +import twilio from 'twilio'; + +const client = twilio( + process.env.TWILIO_ACCOUNT_SID, + process.env.TWILIO_AUTH_TOKEN +); +\`\`\` + +### 4. Sending SMS Messages + +#### Basic SMS + +\`\`\`typescript +async function sendSMS(to: string, message: string) { + try { + const sms = await client.messages.create({ + body: message, + to: to, // Must include country code, e.g., '+1234567890' + from: process.env.TWILIO_PHONE_NUMBER, // Your Twilio phone number + }); + + console.log('SMS sent successfully:', sms.sid); + return { success: true, messageSid: sms.sid }; + } catch (error) { + console.error('Error sending SMS:', error); + return { success: false, error }; + } +} +\`\`\` + +#### SMS with Media (MMS) + +\`\`\`typescript +async function sendMMS(to: string, message: string, mediaUrl: string) { + try { + const mms = await client.messages.create({ + body: message, + to: to, + from: process.env.TWILIO_PHONE_NUMBER, + mediaUrl: [mediaUrl], // Array of media URLs + }); + + console.log('MMS sent successfully:', mms.sid); + return { success: true, messageSid: mms.sid }; + } catch (error) { + console.error('Error sending MMS:', error); + return { success: false, error }; + } +} +\`\`\` + +### 5. Notification Templates + +#### Welcome Message + +\`\`\`typescript +async function sendWelcomeMessage(to: string, userName: string) { + const message = \`Welcome to our service, \${userName}! We're excited to have you on board.\`; + + return await sendSMS(to, message); +} +\`\`\` + +#### OTP Verification + +\`\`\`typescript +async function sendOTPVerification(to: string, otp: string) { + const message = \`Your verification code is: \${otp}. This code will expire in 10 minutes.\`; + + return await sendSMS(to, message); +} +\`\`\` + +#### Order Updates + +\`\`\`typescript +async function sendOrderUpdate(to: string, orderNumber: string, status: string) { + const message = \`Order #\${orderNumber} has been \${status}. Track your order at: https://your-app.com/orders/\${orderNumber}\`; + + return await sendSMS(to, message); +} +\`\`\` + +### 6. Bulk SMS + +\`\`\`typescript +async function sendBulkSMS(recipients: string[], message: string) { + const results = []; + + for (const recipient of recipients) { + try { + const result = await sendSMS(recipient, message); + results.push({ recipient, ...result }); + + // Add delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + results.push({ + recipient, + success: false, + error: error.message + }); + } + } + + return results; +} +\`\`\` + +### 7. Message Status Tracking + +\`\`\`typescript +async function getMessageStatus(messageSid: string) { + try { + const message = await client.messages(messageSid).fetch(); + + return { + success: true, + status: message.status, + dateCreated: message.dateCreated, + dateSent: message.dateSent, + dateUpdated: message.dateUpdated, + price: message.price, + priceUnit: message.priceUnit, + }; + } catch (error) { + console.error('Error fetching message status:', error); + return { success: false, error }; + } +} +\`\`\` + +### 8. Phone Number Validation + +\`\`\`typescript +async function validatePhoneNumber(phoneNumber: string) { + try { + const lookup = await client.lookups.v1.phoneNumbers(phoneNumber).fetch(); + + return { + success: true, + isValid: true, + phoneNumber: lookup.phoneNumber, + nationalFormat: lookup.nationalFormat, + countryCode: lookup.countryCode, + }; + } catch (error) { + console.error('Phone number validation error:', error); + return { success: false, isValid: false, error }; + } +} +\`\`\` + +### 9. Error Handling + +\`\`\`typescript +async function sendSMSWithErrorHandling(to: string, message: string) { + try { + // Validate phone number first + const validation = await validatePhoneNumber(to); + if (!validation.isValid) { + return { success: false, error: 'Invalid phone number' }; + } + + const sms = await client.messages.create({ + body: message, + to: to, + from: process.env.TWILIO_PHONE_NUMBER, + }); + + return { success: true, messageSid: sms.sid }; + } catch (error: any) { + if (error.code === 21211) { + return { success: false, error: 'Invalid To number' }; + } else if (error.code === 21212) { + return { success: false, error: 'Invalid From number' }; + } else if (error.code === 21408) { + return { success: false, error: 'Permission denied for destination number' }; + } else if (error.code === 21610) { + return { success: false, error: 'Message blocked as spam' }; + } else { + console.error('Unexpected error:', error); + return { success: false, error: 'Failed to send SMS' }; + } + } +} +\`\`\` + +### 10. Environment Variables + +Set the following environment variables: + +- \`TWILIO_ACCOUNT_SID\`: Your Twilio Account SID +- \`TWILIO_AUTH_TOKEN\`: Your Twilio Auth Token +- \`TWILIO_PHONE_NUMBER\`: Your Twilio phone number (for sending messages) + +### 11. Best Practices + +1. **Phone Number Format**: Always use E.164 format (+1234567890) +2. **Rate Limiting**: Implement delays between bulk messages +3. **Error Handling**: Handle specific Twilio error codes appropriately +4. **Message Length**: SMS messages are limited to 160 characters (longer messages are split) +5. **Compliance**: Follow SMS marketing regulations and opt-out requirements +6. **Cost Management**: Monitor usage and costs through the Twilio Console`, + human_added_notes: + "This is a recommended integration for implementing SMS notifications and messaging functionality.", + env_variables: [ + { + id: "TWILIO_ACCOUNT_SID", + description: + "Your Twilio Account SID found in the Twilio Console dashboard. This identifies your Twilio account.", + }, + { + id: "TWILIO_AUTH_TOKEN", + description: + "Your Twilio Auth Token found in the Twilio Console dashboard. This is used to authenticate API requests.", + }, + { + id: "TWILIO_PHONE_NUMBER", + description: + "Your Twilio phone number in E.164 format (e.g., +1234567890). This is the number that will send the SMS messages.", + }, + ], + user_instructions: `1. Go to the Twilio website at https://www.twilio.com/ + +2. Click "Sign up" to create a new account or "Log in" if you already have one + +3. Complete the account verification process (phone and email verification) + +4. Once logged in, go to the Twilio Console at https://console.twilio.com/ + +5. On the dashboard, you'll find your Account SID and Auth Token + +6. Copy the Account SID (starts with "AC") + +7. Click the "show" button next to Auth Token and copy it + +8. To get a phone number: + - Go to "Phone Numbers" > "Manage" > "Buy a number" + - Choose your country and select a number + - Complete the purchase + +9. Add these to your environment variables: + - TWILIO_ACCOUNT_SID: Your Account SID + - TWILIO_AUTH_TOKEN: Your Auth Token + - TWILIO_PHONE_NUMBER: Your purchased phone number + +Note: Keep your Auth Token secure and never share it publicly.`, + images: [], + last_updated: Date.now(), + }); + } + + // Create Twitter API integration if it doesn't exist + if (!existingTwitter) { + await ctx.db.insert("integration", { + cover_image: "https://abs.twimg.com/favicons/twitter.2.ico", + reference_id: "twitter_api", + title: "Twitter API", + description: + "Post tweets, interact with users, and access Twitter's features using the official Twitter API v2.", + tags: ["Social Media", "Twitter", "API", "Posting", "Social"], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://developer.twitter.com/en/docs/twitter-api", + "https://github.com/plhery/node-twitter-api-v2", + "https://developer.twitter.com/en/docs/authentication", + "https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/introduction", + ], + llm_instructions: `### 1. Overview + +The Twitter API v2 allows you to post tweets, interact with users, and access Twitter's features programmatically. This integration uses the twitter-api-v2 library, which provides a comprehensive TypeScript-friendly interface for both v1.1 and v2 endpoints. + +### 2. Installation + +Install the twitter-api-v2 library: + +\`\`\`bash +# Install the twitter-api-v2 package +npm install twitter-api-v2 +\`\`\` + +### 3. Authentication Setup + +Initialize the Twitter API client with your credentials: + +\`\`\`typescript +import { TwitterApi } from 'twitter-api-v2'; + +// For OAuth 1.0a (user context - recommended for posting tweets) +const client = new TwitterApi({ + appKey: process.env.TWITTER_API_KEY, + appSecret: process.env.TWITTER_API_SECRET, + accessToken: process.env.TWITTER_ACCESS_TOKEN, + accessSecret: process.env.TWITTER_ACCESS_SECRET, +}); + +// For OAuth 2.0 (app-only - read-only access) +const bearerClient = new TwitterApi(process.env.TWITTER_BEARER_TOKEN); +\`\`\` + +### 4. Posting Tweets + +#### Basic Tweet + +\`\`\`typescript +async function postTweet(text: string) { + try { + const tweet = await client.v2.tweet(text); + + console.log('Tweet posted successfully:', tweet.data.id); + return { + success: true, + tweetId: tweet.data.id, + text: tweet.data.text + }; + } catch (error) { + console.error('Error posting tweet:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Tweet with Poll + +\`\`\`typescript +async function postTweetWithPoll(text: string, options: string[], durationMinutes: number = 60) { + try { + const tweet = await client.v2.tweet(text, { + poll: { + duration_minutes: durationMinutes, + options: options + } + }); + + console.log('Tweet with poll posted:', tweet.data.id); + return { success: true, tweetId: tweet.data.id }; + } catch (error) { + console.error('Error posting tweet with poll:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Tweet with Media + +\`\`\`typescript +async function postTweetWithImage(text: string, imagePath: string) { + try { + // Upload media using v1.1 API + const mediaId = await client.v1.uploadMedia(imagePath); + + // Post tweet with media using v2 API + const tweet = await client.v2.tweet(text, { + media: { media_ids: [mediaId] } + }); + + console.log('Tweet with image posted:', tweet.data.id); + return { success: true, tweetId: tweet.data.id }; + } catch (error) { + console.error('Error posting tweet with image:', error); + return { success: false, error }; + } +} +\`\`\` + +### 5. Tweet Thread + +\`\`\`typescript +async function postTweetThread(tweets: string[]) { + try { + const thread = await client.v2.tweetThread(tweets); + + const tweetIds = thread.map(tweet => tweet.data.id); + console.log('Thread posted successfully:', tweetIds); + + return { success: true, tweetIds }; + } catch (error) { + console.error('Error posting thread:', error); + return { success: false, error }; + } +} +\`\`\` + +### 6. Replying to Tweets + +\`\`\`typescript +async function replyToTweet(tweetId: string, replyText: string) { + try { + const reply = await client.v2.reply(replyText, tweetId); + + console.log('Reply posted:', reply.data.id); + return { success: true, replyId: reply.data.id }; + } catch (error) { + console.error('Error posting reply:', error); + return { success: false, error }; + } +} +\`\`\` + +### 7. Tweet Interactions + +#### Like a Tweet + +\`\`\`typescript +async function likeTweet(userId: string, tweetId: string) { + try { + await client.v2.like(userId, tweetId); + + console.log('Tweet liked successfully'); + return { success: true }; + } catch (error) { + console.error('Error liking tweet:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Retweet + +\`\`\`typescript +async function retweetTweet(userId: string, tweetId: string) { + try { + await client.v2.retweet(userId, tweetId); + + console.log('Tweet retweeted successfully'); + return { success: true }; + } catch (error) { + console.error('Error retweeting:', error); + return { success: false, error }; + } +} +\`\`\` + +### 8. Reading Tweets and User Data + +#### Get User Information + +\`\`\`typescript +async function getUserInfo(username: string) { + try { + const user = await client.v2.userByUsername(username); + + return { + success: true, + user: { + id: user.data.id, + name: user.data.name, + username: user.data.username, + description: user.data.description, + publicMetrics: user.data.public_metrics + } + }; + } catch (error) { + console.error('Error fetching user info:', error); + return { success: false, error }; + } +} +\`\`\` + +#### Get User Timeline + +\`\`\`typescript +async function getUserTimeline(userId: string, maxResults: number = 10) { + try { + const timeline = await client.v2.userTimeline(userId, { + max_results: maxResults, + 'tweet.fields': ['created_at', 'public_metrics'] + }); + + const tweets = timeline.data.data || []; + + return { + success: true, + tweets: tweets.map(tweet => ({ + id: tweet.id, + text: tweet.text, + createdAt: tweet.created_at, + metrics: tweet.public_metrics + })) + }; + } catch (error) { + console.error('Error fetching timeline:', error); + return { success: false, error }; + } +} +\`\`\` + +### 9. Search Tweets + +\`\`\`typescript +async function searchTweets(query: string, maxResults: number = 10) { + try { + const tweets = await client.v2.search(query, { + max_results: maxResults, + 'tweet.fields': ['created_at', 'author_id', 'public_metrics'] + }); + + return { + success: true, + tweets: tweets.data.data?.map(tweet => ({ + id: tweet.id, + text: tweet.text, + authorId: tweet.author_id, + createdAt: tweet.created_at, + metrics: tweet.public_metrics + })) || [] + }; + } catch (error) { + console.error('Error searching tweets:', error); + return { success: false, error }; + } +} +\`\`\` + +### 10. Delete Tweet + +\`\`\`typescript +async function deleteTweet(tweetId: string) { + try { + await client.v2.deleteTweet(tweetId); + + console.log('Tweet deleted successfully'); + return { success: true }; + } catch (error) { + console.error('Error deleting tweet:', error); + return { success: false, error }; + } +} +\`\`\` + +### 11. Error Handling + +\`\`\`typescript +async function postTweetWithErrorHandling(text: string) { + try { + const tweet = await client.v2.tweet(text); + return { success: true, tweetId: tweet.data.id }; + } catch (error: any) { + if (error.code === 403) { + return { success: false, error: 'Forbidden - Check your API permissions' }; + } else if (error.code === 429) { + return { success: false, error: 'Rate limit exceeded - Try again later' }; + } else if (error.errors?.[0]?.code === 187) { + return { success: false, error: 'Duplicate tweet detected' }; + } else if (error.errors?.[0]?.code === 186) { + return { success: false, error: 'Tweet is too long' }; + } else { + console.error('Unexpected error:', error); + return { success: false, error: 'Failed to post tweet' }; + } + } +} +\`\`\` + +### 12. Environment Variables + +Set the following environment variables: + +For OAuth 1.0a (User Context): +- \`TWITTER_API_KEY\`: Your Twitter API Key (Consumer Key) +- \`TWITTER_API_SECRET\`: Your Twitter API Secret (Consumer Secret) +- \`TWITTER_ACCESS_TOKEN\`: Your Access Token +- \`TWITTER_ACCESS_SECRET\`: Your Access Token Secret + +For OAuth 2.0 (App-only, read-only): +- \`TWITTER_BEARER_TOKEN\`: Your Bearer Token + +### 13. Best Practices + +1. **Rate Limits**: Twitter has strict rate limits. Implement proper retry logic and respect limits +2. **Authentication**: Use OAuth 1.0a for posting tweets, OAuth 2.0 for read-only operations +3. **Content Policy**: Follow Twitter's content policy and terms of service +4. **Error Handling**: Handle specific Twitter API error codes appropriately +5. **Media Uploads**: Use v1.1 API for media uploads, then reference in v2 tweets +6. **Monitoring**: Monitor your API usage through the Twitter Developer Dashboard`, + human_added_notes: + "This is a recommended integration for implementing Twitter functionality and social media posting.", + env_variables: [ + { + id: "TWITTER_API_KEY", + description: + "Your Twitter API Key (Consumer Key) from the Twitter Developer Dashboard. This identifies your Twitter application.", + }, + { + id: "TWITTER_API_SECRET", + description: + "Your Twitter API Secret (Consumer Secret) from the Twitter Developer Dashboard. Used for application authentication.", + }, + { + id: "TWITTER_ACCESS_TOKEN", + description: + "Your Twitter Access Token from the Twitter Developer Dashboard. Used for user authentication and posting tweets.", + }, + { + id: "TWITTER_ACCESS_SECRET", + description: + "Your Twitter Access Token Secret from the Twitter Developer Dashboard. Used with the access token for authentication.", + }, + ], + user_instructions: `1. Go to the Twitter Developer Portal at https://developer.twitter.com/ + +2. Click "Sign up" or "Sign in" with your Twitter account + +3. Apply for a developer account: + - Select your use case (e.g., "Building tools for Twitter users") + - Fill out the application form with your intended use + - Wait for approval (can take 1-2 business days) + +4. Once approved, create a new project: + - Click "Create Project" + - Choose your use case and give it a name + - Create an app within the project + +5. In your app settings, generate your keys: + - Go to "Keys and tokens" tab + - Generate API Key and Secret (these are your Consumer Key/Secret) + - Generate Access Token and Secret + +6. Set up your environment variables: + - TWITTER_API_KEY: Your API Key (Consumer Key) + - TWITTER_API_SECRET: Your API Secret (Consumer Secret) + - TWITTER_ACCESS_TOKEN: Your Access Token + - TWITTER_ACCESS_SECRET: Your Access Token Secret + +7. Enable proper permissions: + - Go to "Settings" tab + - Set App permissions to "Read and write" (for posting tweets) + +Note: Keep all tokens secure and never share them publicly. Twitter API v2 requires approved developer access.`, + images: [], + last_updated: Date.now(), + }); + } + + // Create Convex Geospatial integration if it doesn't exist + if (!existingConvexGeospatial) { + await ctx.db.insert("integration", { + cover_image: "https://docs.convex.dev/img/logo.svg", + reference_id: "convex_geospatial", + title: "Convex Geospatial", + description: + "Build location-aware applications with geospatial data storage, querying, and real-time updates in Convex.", + tags: [ + "Geospatial", + "Location", + "Maps", + "GPS", + "Coordinates", + "Database", + ], + type: "official", + public: true, + recommended: true, + documentation_urls: [ + "https://docs.convex.dev/database/schemas", + "https://docs.convex.dev/database/indexes", + "https://docs.convex.dev/functions/query-functions", + "https://github.com/get-convex/aggregate", + ], + llm_instructions: `### 1. Overview + +This integration provides patterns and utilities for building geospatial applications with Convex. It includes location data storage, proximity queries, geofencing, and real-time location updates for building location-aware applications. + +### 2. Schema Setup + +Define your geospatial data schema in Convex: + +\`\`\`typescript +// convex/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + locations: defineTable({ + name: v.string(), + description: v.optional(v.string()), + latitude: v.number(), + longitude: v.number(), + altitude: v.optional(v.number()), + address: v.optional(v.string()), + category: v.optional(v.string()), + userId: v.optional(v.id("users")), + metadata: v.optional(v.object({ + radius: v.optional(v.number()), + tags: v.optional(v.array(v.string())), + })), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_user", ["userId"]) + .index("by_category", ["category"]) + .index("by_lat_lng", ["latitude", "longitude"]) + .index("by_created_at", ["createdAt"]), + + user_locations: defineTable({ + userId: v.id("users"), + latitude: v.number(), + longitude: v.number(), + altitude: v.optional(v.number()), + accuracy: v.optional(v.number()), + timestamp: v.number(), + isActive: v.boolean(), + }) + .index("by_user", ["userId"]) + .index("by_user_active", ["userId", "isActive"]) + .index("by_timestamp", ["timestamp"]), + + geofences: defineTable({ + name: v.string(), + centerLatitude: v.number(), + centerLongitude: v.number(), + radius: v.number(), // in meters + userId: v.id("users"), + isActive: v.boolean(), + notifications: v.optional(v.object({ + onEnter: v.optional(v.boolean()), + onExit: v.optional(v.boolean()), + message: v.optional(v.string()), + })), + createdAt: v.number(), + }) + .index("by_user", ["userId"]) + .index("by_active", ["isActive"]), +}); +\`\`\` + +### 3. Distance Calculation Utilities + +\`\`\`typescript +// convex/utils/geospatial.ts + +/** + * Calculate distance between two points using Haversine formula + * Returns distance in meters + */ +export function calculateDistance( + lat1: number, + lng1: number, + lat2: number, + lng2: number +): number { + const R = 6371000; // Earth's radius in meters + const φ1 = (lat1 * Math.PI) / 180; + const φ2 = (lat2 * Math.PI) / 180; + const Δφ = ((lat2 - lat1) * Math.PI) / 180; + const Δλ = ((lng2 - lng1) * Math.PI) / 180; + + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * Calculate bounding box for a given center point and radius + */ +export function getBoundingBox( + centerLat: number, + centerLng: number, + radiusMeters: number +) { + const R = 6371000; // Earth's radius in meters + const latOffset = (radiusMeters / R) * (180 / Math.PI); + const lngOffset = latOffset / Math.cos((centerLat * Math.PI) / 180); + + return { + minLat: centerLat - latOffset, + maxLat: centerLat + latOffset, + minLng: centerLng - lngOffset, + maxLng: centerLng + lngOffset, + }; +} + +/** + * Check if a point is within a circular geofence + */ +export function isPointInCircle( + pointLat: number, + pointLng: number, + centerLat: number, + centerLng: number, + radiusMeters: number +): boolean { + const distance = calculateDistance(pointLat, pointLng, centerLat, centerLng); + return distance <= radiusMeters; +} +\`\`\` + +### 4. Location Management + +#### Create Location + +\`\`\`typescript +// convex/locations.ts +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; + +export const createLocation = mutation({ + args: { + name: v.string(), + description: v.optional(v.string()), + latitude: v.number(), + longitude: v.number(), + altitude: v.optional(v.number()), + address: v.optional(v.string()), + category: v.optional(v.string()), + userId: v.optional(v.id("users")), + }, + handler: async (ctx, args) => { + const now = Date.now(); + + const locationId = await ctx.db.insert("locations", { + ...args, + createdAt: now, + updatedAt: now, + }); + + return locationId; + }, +}); +\`\`\` + +#### Find Nearby Locations + +\`\`\`typescript +import { calculateDistance, getBoundingBox } from "./utils/geospatial"; + +export const findNearbyLocations = query({ + args: { + latitude: v.number(), + longitude: v.number(), + radiusMeters: v.number(), + category: v.optional(v.string()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { latitude, longitude, radiusMeters, category, limit = 50 } = args; + + // Get bounding box for initial filtering + const bbox = getBoundingBox(latitude, longitude, radiusMeters); + + // Query locations within bounding box + let locationsQuery = ctx.db + .query("locations") + .withIndex("by_lat_lng", (q) => + q.gte("latitude", bbox.minLat).lte("latitude", bbox.maxLat) + ); + + if (category) { + locationsQuery = ctx.db + .query("locations") + .withIndex("by_category", (q) => q.eq("category", category)); + } + + const locations = await locationsQuery.collect(); + + // Filter by precise distance and add distance field + const nearbyLocations = locations + .map((location) => { + const distance = calculateDistance( + latitude, + longitude, + location.latitude, + location.longitude + ); + + return { + ...location, + distance, + }; + }) + .filter((location) => location.distance <= radiusMeters) + .sort((a, b) => a.distance - b.distance) + .slice(0, limit); + + return nearbyLocations; + }, +}); +\`\`\` + +### 5. User Location Tracking + +#### Update User Location + +\`\`\`typescript +export const updateUserLocation = mutation({ + args: { + userId: v.id("users"), + latitude: v.number(), + longitude: v.number(), + altitude: v.optional(v.number()), + accuracy: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { userId, latitude, longitude, altitude, accuracy } = args; + const timestamp = Date.now(); + + // Deactivate previous location + const existingActive = await ctx.db + .query("user_locations") + .withIndex("by_user_active", (q) => + q.eq("userId", userId).eq("isActive", true) + ) + .first(); + + if (existingActive) { + await ctx.db.patch(existingActive._id, { isActive: false }); + } + + // Insert new location + const locationId = await ctx.db.insert("user_locations", { + userId, + latitude, + longitude, + altitude, + accuracy, + timestamp, + isActive: true, + }); + + return locationId; + }, +}); + +export const getUserLocation = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + const location = await ctx.db + .query("user_locations") + .withIndex("by_user_active", (q) => + q.eq("userId", args.userId).eq("isActive", true) + ) + .first(); + + return location; + }, +}); +\`\`\` + +### 6. Geofencing + +#### Create Geofence + +\`\`\`typescript +export const createGeofence = mutation({ + args: { + name: v.string(), + centerLatitude: v.number(), + centerLongitude: v.number(), + radius: v.number(), + userId: v.id("users"), + notifications: v.optional(v.object({ + onEnter: v.optional(v.boolean()), + onExit: v.optional(v.boolean()), + message: v.optional(v.string()), + })), + }, + handler: async (ctx, args) => { + const geofenceId = await ctx.db.insert("geofences", { + ...args, + isActive: true, + createdAt: Date.now(), + }); + + return geofenceId; + }, +}); +\`\`\` + +#### Check Geofence Triggers + +\`\`\`typescript +import { isPointInCircle } from "./utils/geospatial"; + +export const checkGeofenceTriggers = query({ + args: { + userId: v.id("users"), + latitude: v.number(), + longitude: v.number(), + }, + handler: async (ctx, args) => { + const { userId, latitude, longitude } = args; + + // Get user's active geofences + const geofences = await ctx.db + .query("geofences") + .withIndex("by_user", (q) => q.eq("userId", userId)) + .filter((q) => q.eq(q.field("isActive"), true)) + .collect(); + + const triggers = []; + + for (const geofence of geofences) { + const isInside = isPointInCircle( + latitude, + longitude, + geofence.centerLatitude, + geofence.centerLongitude, + geofence.radius + ); + + if (isInside && geofence.notifications?.onEnter) { + triggers.push({ + geofenceId: geofence._id, + type: "enter", + message: geofence.notifications.message || \`Entered \${geofence.name}\`, + }); + } + } + + return triggers; + }, +}); +\`\`\` + +### 7. Spatial Aggregation with Convex Components + +For advanced spatial analytics, you can use the Convex Aggregate component: + +\`\`\`typescript +// convex/convex.config.ts +import { defineApp } from "convex/server"; +import aggregate from "@convex-dev/aggregate/convex.config"; + +const app = defineApp(); +app.use(aggregate, { name: "locationAggregates" }); + +export default app; +\`\`\` + +\`\`\`typescript +// convex/spatial_analytics.ts +import { components } from "./_generated/api"; +import { TableAggregate } from "@convex-dev/aggregate"; +import { DataModel } from "./_generated/dataModel"; + +const locationsByGrid = new TableAggregate<{ + Key: [number, number]; // [lat_grid, lng_grid] + DataModel: DataModel; + TableName: "locations"; +}>(components.locationAggregates, { + sortKey: (doc) => [ + Math.floor(doc.latitude * 100), // Grid precision + Math.floor(doc.longitude * 100), + ], +}); + +// Count locations in a grid cell +export const getLocationDensity = query({ + args: { + latGrid: v.number(), + lngGrid: v.number(), + }, + handler: async (ctx, args) => { + const bounds = { prefix: [args.latGrid, args.lngGrid] }; + const count = await locationsByGrid.count(ctx, { bounds }); + return count; + }, +}); +\`\`\` + +### 8. Frontend Integration + +\`\`\`typescript +// React component example +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +export function LocationTracker() { + const updateLocation = useMutation(api.locations.updateUserLocation); + const nearbyLocations = useQuery(api.locations.findNearbyLocations, { + latitude: userLocation?.latitude || 0, + longitude: userLocation?.longitude || 0, + radiusMeters: 5000, + }); + + const handleLocationUpdate = async () => { + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + async (position) => { + await updateLocation({ + userId: "user_id", // Get from auth + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + }); + }, + (error) => console.error("Location error:", error) + ); + } + }; + + return ( + <div> + <button onClick={handleLocationUpdate}> + Update Location + </button> + + <h3>Nearby Locations</h3> + {nearbyLocations?.map((location) => ( + <div key={location._id}> + <h4>{location.name}</h4> + <p>{Math.round(location.distance)}m away</p> + </div> + ))} + </div> + ); +} +\`\`\` + +### 9. Best Practices + +1. **Indexing**: Create proper indexes for latitude/longitude queries +2. **Precision**: Use appropriate precision for coordinates based on your use case +3. **Bounding Box**: Use bounding box filtering before precise distance calculations +4. **Rate Limiting**: Implement rate limiting for location updates +5. **Privacy**: Handle location data with appropriate privacy controls +6. **Real-time**: Leverage Convex's real-time features for live location updates +7. **Performance**: Consider spatial partitioning for large datasets + +### 10. Advanced Features + +- Grid-based spatial indexing for performance +- Real-time location sharing between users +- Historical location tracking and analytics +- Integration with mapping services (Google Maps, Mapbox) +- Spatial clustering and heatmaps +- Route calculation and optimization`, + human_added_notes: + "This integration provides comprehensive geospatial functionality for building location-aware applications with Convex.", + env_variables: [], + user_instructions: `This integration doesn't require any external API keys as it uses Convex's built-in database capabilities. + +To get started: + +1. Install the Convex Aggregate component (optional for advanced features): + - Install the package: \`npm install @convex-dev/aggregate\` + +2. Set up your schema: + - Copy the provided schema examples to your convex/schema.ts file + - Modify the schema to match your specific use case + +3. Create utility functions: + - Add the geospatial utility functions to convex/utils/geospatial.ts + +4. Implement location functions: + - Add the provided mutations and queries to your Convex functions + - Customize them based on your application needs + +5. Frontend integration: + - Use the provided React examples as a starting point + - Implement location tracking in your frontend application + +6. Test your implementation: + - Test location creation, nearby searches, and geofencing + - Verify real-time updates work correctly + +Note: This integration leverages Convex's built-in database and real-time capabilities for geospatial functionality.`, + images: [], + last_updated: Date.now(), + }); + } + + console.log("Recommended integrations created successfully"); + return { success: true }; + }, +}); diff --git a/freebuff/web/convex/referralRewards.ts b/freebuff/web/convex/referralRewards.ts new file mode 100644 index 0000000000..f04336e376 --- /dev/null +++ b/freebuff/web/convex/referralRewards.ts @@ -0,0 +1,161 @@ +import { v } from "convex/values"; +import { mutation, query, internalMutation } from "./_generated/server"; +import { getAuthUser } from "./users"; + +// Internal mutation for granting referral rewards (called from users.ts signup flow) +export const internalGrantReferralReward = internalMutation({ + args: { + userId: v.id("users"), + referredUserId: v.id("users"), + referralCode: v.string(), + }, + handler: async (ctx, args) => { + try { + // Check if reward already exists for this referral + const existingReward = await ctx.db + .query("referral_rewards") + .withIndex("by_referred_user", (q) => + q.eq("referred_user_id", args.referredUserId), + ) + .unique(); + + if (existingReward) { + return { + success: false, + error: "Reward already granted for this referral", + }; + } + + // Create reward record + const rewardId = await ctx.db.insert("referral_rewards", { + referrer_user_id: args.userId, + referred_user_id: args.referredUserId, + referral_code: args.referralCode, + reward_type: "token_credits", + reward_amount: 2000000, // 2M tokens + granted_at: Date.now(), + status: "pending", + }); + + await ctx.db.patch(rewardId, { status: "granted" }); + return { success: true, rewardId }; + } catch (error) { + console.error("Failed to grant referral reward:", error); + return { + success: false, + error: "Internal error granting reward", + }; + } + }, +}); + +// Grant referral reward to a user (public mutation for manual granting if needed) +export const grantReferralReward = mutation({ + args: { + userId: v.id("users"), + referredUserId: v.id("users"), + referralCode: v.string(), + }, + handler: async (ctx, args) => { + try { + // Check if reward already exists for this referral + const existingReward = await ctx.db + .query("referral_rewards") + .withIndex("by_referred_user", (q) => + q.eq("referred_user_id", args.referredUserId), + ) + .unique(); + + if (existingReward) { + return { + success: false, + error: "Reward already granted for this referral", + }; + } + + // Create reward record + const rewardId = await ctx.db.insert("referral_rewards", { + referrer_user_id: args.userId, + referred_user_id: args.referredUserId, + referral_code: args.referralCode, + reward_type: "token_credits", + reward_amount: 2000000, // 2M tokens + granted_at: Date.now(), + status: "pending", + }); + + await ctx.db.patch(rewardId, { status: "granted" }); + return { success: true, rewardId }; + } catch (error) { + console.error("Failed to grant referral reward:", error); + return { + success: false, + error: "Internal error granting reward", + }; + } + }, +}); + +// Get user's referral rewards history +export const getUserReferralRewards = query({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const rewards = await ctx.db + .query("referral_rewards") + .withIndex("by_referrer", (q) => q.eq("referrer_user_id", user._id)) + .collect(); + + // Enrich with referred user info + const enrichedRewards = await Promise.all( + rewards.map(async (reward) => { + const referredUser = await ctx.db.get(reward.referred_user_id); + return { + ...reward, + referred_user_name: referredUser?.name, + referred_user_email: referredUser?.email, + }; + }), + ); + + return enrichedRewards; + }, +}); + +// Get referral rewards summary for billing UI +export const getReferralRewardsSummary = query({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return { + totalRewards: 0, + totalCreditsEarned: 0, + pendingRewards: 0, + successfulReferrals: 0, + }; + } + + const rewards = await ctx.db + .query("referral_rewards") + .withIndex("by_referrer", (q) => q.eq("referrer_user_id", user._id)) + .collect(); + + const grantedRewards = rewards.filter((r) => r.status === "granted"); + const pendingRewards = rewards.filter((r) => r.status === "pending"); + + return { + totalRewards: rewards.length, + totalCreditsEarned: grantedRewards.reduce( + (sum, r) => sum + r.reward_amount, + 0, + ), + pendingRewards: pendingRewards.length, + successfulReferrals: grantedRewards.length, + }; + }, +}); diff --git a/freebuff/web/convex/referrals.ts b/freebuff/web/convex/referrals.ts new file mode 100644 index 0000000000..10e2bfdd48 --- /dev/null +++ b/freebuff/web/convex/referrals.ts @@ -0,0 +1,254 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { getAuthUser } from "./users"; + +// Generate a unique referral code +function generateReferralCode(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let code = ""; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; +} + +// Create a new referral code for the authenticated user +export const createReferralCode = mutation({ + args: { + customCode: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + let code: string; + + if (args.customCode) { + // Validate custom code + const trimmedCode = args.customCode.trim().toUpperCase(); + + // Validate format: alphanumeric only, 3-20 characters + if (!/^[A-Z0-9]{3,20}$/.test(trimmedCode)) { + throw new Error( + "Code must be 3-20 characters long and contain only letters and numbers", + ); + } + + // Check if custom code already exists + const existing = await ctx.db + .query("referral_codes") + .withIndex("by_code", (q) => q.eq("code", trimmedCode)) + .unique(); + + if (existing) { + throw new Error( + "This code is already taken. Please choose a different one.", + ); + } + + code = trimmedCode; + } else { + // Auto-generate code + code = generateReferralCode(); + let attempts = 0; + + // Ensure the code is unique + while (attempts < 10) { + const existing = await ctx.db + .query("referral_codes") + .withIndex("by_code", (q) => q.eq("code", code)) + .unique(); + + if (!existing) { + break; + } + + code = generateReferralCode(); + attempts++; + } + + if (attempts >= 10) { + throw new Error("Failed to generate unique code"); + } + } + + const referralCodeId = await ctx.db.insert("referral_codes", { + code, + owner: user._id, + created_at: Date.now(), + uses_count: 0, + active: true, + }); + + return { code, id: referralCodeId }; + }, +}); + +// Get all referral codes for the authenticated user +export const getUserReferralCodes = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return []; + } + + const codes = await ctx.db + .query("referral_codes") + .withIndex("by_owner", (q) => q.eq("owner", user._id)) + .collect(); + + return codes.map((code) => ({ + ...code, + url: `${process.env.NEXT_PUBLIC_APP_URL || ""}/web/?ref=${code.code}`, + })); + }, +}); + +// Get referral statistics for a specific code +export const getReferralStats = query({ + args: { + codeId: v.id("referral_codes"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const code = await ctx.db.get(args.codeId); + if (!code) { + throw new Error("Code not found"); + } + + // Check if user owns this code or is admin/godmode + if ( + code.owner !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Unauthorized"); + } + + // Get users who signed up with this code + const referredUsers = await ctx.db + .query("users") + .withIndex("by_referral_code", (q) => q.eq("referral_code", code.code)) + .collect(); + + return { + code: code.code, + uses_count: code.uses_count, + created_at: code.created_at, + active: code.active, + referred_users: referredUsers.map((u) => ({ + name: u.name, + email: u.email, + signupDate: u._creationTime, + })), + }; + }, +}); + +// Get all referral codes (godmode only) +export const getAllReferralCodes = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user || (user.role !== "god" && user.role !== "admin")) { + return []; + } + + const codes = await ctx.db.query("referral_codes").collect(); + + // Get owner information for each code + const codesWithOwners = await Promise.all( + codes.map(async (code) => { + const owner = await ctx.db.get(code.owner); + return { + ...code, + ownerName: owner?.name || "Unknown", + ownerEmail: owner?.email || "Unknown", + url: `${process.env.NEXT_PUBLIC_APP_URL || ""}/web/?ref=${code.code}`, + }; + }), + ); + + return codesWithOwners; + }, +}); + +// Toggle active status of a referral code +export const toggleReferralCode = mutation({ + args: { + codeId: v.id("referral_codes"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + const code = await ctx.db.get(args.codeId); + if (!code) { + throw new Error("Code not found"); + } + + // Check if user owns this code or is admin/godmode + if ( + code.owner !== user._id && + user.role !== "god" && + user.role !== "admin" + ) { + throw new Error("Unauthorized"); + } + + await ctx.db.patch(args.codeId, { + active: !code.active, + }); + + return { active: !code.active }; + }, +}); + +// Get summary statistics for the dashboard +export const getReferralSummary = query({ + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + return null; + } + + if (user.role === "god" || user.role === "admin") { + // For admin users, show all stats + const allCodes = await ctx.db.query("referral_codes").collect(); + const totalSignups = allCodes.reduce( + (sum, code) => sum + code.uses_count, + 0, + ); + + return { + totalCodes: allCodes.length, + totalSignups, + activeCodes: allCodes.filter((c) => c.active).length, + }; + } else { + // For regular users, show their stats + const userCodes = await ctx.db + .query("referral_codes") + .withIndex("by_owner", (q) => q.eq("owner", user._id)) + .collect(); + + const totalSignups = userCodes.reduce( + (sum, code) => sum + code.uses_count, + 0, + ); + + return { + totalCodes: userCodes.length, + totalSignups, + activeCodes: userCodes.filter((c) => c.active).length, + }; + } + }, +}); diff --git a/freebuff/web/convex/runtime_errors.ts b/freebuff/web/convex/runtime_errors.ts new file mode 100644 index 0000000000..d58e52d3e3 --- /dev/null +++ b/freebuff/web/convex/runtime_errors.ts @@ -0,0 +1,193 @@ +import { internal } from "!/_generated/api"; +import { + httpAction, + internalMutation, + mutation, + query, +} from "!/_generated/server"; +import { v } from "convex/values"; + +export const processRuntimeError = httpAction(async (ctx, request) => { + const body = await request.json(); + + const project = await ctx.runQuery( + internal.project.getProjectFromIdentifier, + { + semanticIdentifier: body.projectSemanticIdentifier, + }, + ); + + console.log("Project found", project?._id); + + if (!project) { + return new Response("Project not found", { status: 500 }); + } + + const urlPath = new URL(body.url).pathname; + + await ctx.runMutation(internal.runtime_errors.logRuntimeError, { + projectId: project._id, + error: body.error, + stack_trace: body.stackTrace, + url: urlPath, + filename: body.filename || undefined, + lineno: body.lineno || undefined, + colno: body.colno || undefined, + }); + + return new Response("OK", { + status: 201, + headers: { + "Access-Control-Allow-Origin": "*", + Vary: "origin", + }, + }); +}); + +export const logRuntimeError = internalMutation({ + args: { + projectId: v.id("project"), + error: v.string(), + stack_trace: v.optional(v.string()), + url: v.string(), + filename: v.optional(v.string()), + lineno: v.optional(v.number()), + colno: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { projectId, error, stack_trace, url, filename, lineno, colno } = + args; + const date = Date.now(); + await ctx.db.insert("runtime_error", { + projectId, + error, + stack_trace, + date, + url, + filename, + lineno, + colno, + status: "unresolved", + }); + }, +}); + +export const getUnresolvedRuntimeErrors = query({ + args: { + projectId: v.id("project"), + paginationOpts: v.optional( + v.object({ + numItems: v.number(), + cursor: v.union(v.string(), v.null()), + }), + ), + }, + handler: async (ctx, args) => { + const { projectId, paginationOpts } = args; + + // Use compound index for much better performance + const results = await ctx.db + .query("runtime_error") + .withIndex("by_project_and_status", (q) => + q.eq("projectId", projectId).eq("status", "unresolved"), + ) + .paginate( + paginationOpts + ? { + numItems: paginationOpts.numItems, + cursor: paginationOpts.cursor ?? null, + } + : { numItems: 100, cursor: null }, + ); + + // For backward compatibility, also get errors with undefined status + // Note: We can't easily combine paginated results, so we prioritize new errors + // Legacy errors (status: undefined) should be migrated to "unresolved" + if (results.page.length < (paginationOpts?.numItems ?? 100)) { + const legacyResults = await ctx.db + .query("runtime_error") + .withIndex("by_project_and_status", (q) => + q.eq("projectId", projectId).eq("status", undefined), + ) + .filter((q) => q.eq(q.field("resolved"), undefined)) + .take((paginationOpts?.numItems ?? 100) - results.page.length); + + return { + ...results, + page: [...results.page, ...legacyResults], + }; + } + + return results; + }, +}); + +export const deleteRuntimeError = mutation({ + args: { + errorId: v.id("runtime_error"), + }, + handler: async (ctx, { errorId }) => { + await ctx.db.delete(errorId); + }, +}); + +export const resolveRuntimeErrors = mutation({ + args: { + errorIds: v.array(v.id("runtime_error")), + }, + handler: async (ctx, { errorIds }) => { + for (const id of errorIds) { + await ctx.db.patch(id, { + resolved: true, + status: "resolved", + }); + } + }, +}); + +export const dismissRuntimeErrors = mutation({ + args: { + errorIds: v.array(v.id("runtime_error")), + }, + handler: async (ctx, { errorIds }) => { + for (const id of errorIds) { + await ctx.db.patch(id, { + resolved: true, + status: "dismissed", + }); + } + }, +}); + +export const invalidateAllRuntimeErrors = internalMutation({ + args: { + projectId: v.id("project"), + }, + returns: v.null(), + handler: async (ctx, args) => { + const unresolvedErrors = await ctx.db + .query("runtime_error") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .filter((q) => + q.or( + q.eq(q.field("status"), "unresolved"), + q.and( + q.eq(q.field("status"), undefined), + q.eq(q.field("resolved"), undefined), + ), + ), + ) + .collect(); + + for (const error of unresolvedErrors) { + await ctx.db.patch(error._id, { + resolved: true, + status: "invalidated", + }); + } + + console.log( + `[RuntimeErrors] Invalidated ${unresolvedErrors.length} runtime errors for project ${args.projectId}`, + ); + }, +}); diff --git a/freebuff/web/convex/schema.ts b/freebuff/web/convex/schema.ts new file mode 100644 index 0000000000..7dc4d03e77 --- /dev/null +++ b/freebuff/web/convex/schema.ts @@ -0,0 +1,1946 @@ +import { defineSchema, defineTable } from 'convex/server' +import { Infer, v } from 'convex/values' +import { modelValidator } from './utils/registry_validators' +import { PAUSE_REASON_VALIDATOR } from './constants' + +// The schema is entirely optional. +// You can delete this file (schema.ts) and the +// app will continue to work. +// The schema provides more precise TypeScript types. + +export const integrationTypeValidator = v.union( + v.literal('user generated'), + v.literal('official'), +) + +export type IntegrationType = Infer<typeof integrationTypeValidator> + +export const integrationApprovalStatusValidator = v.union( + v.literal('pending'), + v.literal('approved'), + v.literal('rejected'), +) + +export type IntegrationApprovalStatus = Infer< + typeof integrationApprovalStatusValidator +> + +export const errorStatusValidator = v.union( + v.literal('unresolved'), + v.literal('resolved'), + v.literal('dismissed'), + v.literal('invalidated'), +) + +export const ticketStatusValidator = v.union( + v.literal('open'), + v.literal('in_progress'), + v.literal('resolved'), + v.literal('closed'), +) + +export type TicketStatus = Infer<typeof ticketStatusValidator> + +export const messageRoleValidator = v.union( + v.literal('user'), + v.literal('admin'), +) + +export type MessageRole = Infer<typeof messageRoleValidator> + +export type ErrorStatus = Infer<typeof errorStatusValidator> + +// Organization role types +export type OrgRole = 'org:admin' | 'org:member' | 'org:viewer' +export type OrgPermission = 'read' | 'write' | 'admin' | 'billing' + +export default defineSchema( + { + users: defineTable({ + name: v.string(), + email: v.string(), + profile_image: v.optional(v.string()), + clerk_id: v.string(), + freebuff_user_id: v.optional(v.string()), + role: v.optional( + v.union(v.literal('god'), v.literal('admin'), v.literal('member')), + ), + tier: v.optional(v.union(v.literal('free'), v.literal('pro'))), + community_badge_tier: v.optional(v.number()), // 0-9 based on subscription tier for community badges + onboarding_completed: v.optional(v.boolean()), + referral_code: v.optional(v.string()), // The referral code used during signup + is_beta: v.optional(v.boolean()), // Beta tester flag for gradual feature rollouts + interested_in_hiring: v.optional(v.boolean()), // Track interest in hiring developers + resend_contact_last_synced_email: v.optional(v.string()), // Lowercased email when successfully synced to Resend contacts + resend_contact_synced_at: v.optional(v.number()), // Last successful sync timestamp + resend_contact_sync_error: v.optional(v.string()), // Last sync failure reason for debugging + codex_auth_fingerprint: v.optional(v.string()), // Salted hash of Codex device auth token tuple (never raw tokens) + codex_auth_encrypted_payload: v.optional(v.string()), // Encrypted Codex auth.json payload for cross-project restore + codex_auth_encryption_version: v.optional(v.number()), // Encryption payload version for future migrations + codex_auth_mode: v.optional(v.string()), // Last observed auth_mode from ~/.codex/auth.json + codex_auth_last_refresh: v.optional(v.string()), // Last observed refresh timestamp from ~/.codex/auth.json + codex_auth_updated_at: v.optional(v.number()), // Last time auth fingerprint metadata was updated + codex_oauth_revoked: v.optional(v.boolean()), // Explicit user disconnect flag for Codex OAuth + gpt_auth_method: v.optional( + v.union(v.literal('oauth'), v.literal('byok')), + ), // Preferred Codex/GPT authentication mode + gpt_model_preference: v.optional(v.string()), // Preferred Codex model name + gpt_openai_api_key_encrypted: v.optional(v.string()), // Encrypted OpenAI API key for user-level BYOK + gpt_openai_api_key_encryption_version: v.optional(v.number()), // Encryption version for OpenAI API key blob + gpt_openai_api_key_updated_at: v.optional(v.number()), // Last OpenAI API key update timestamp + claude_provider_preference: v.optional( + v.union(v.literal('anthropic'), v.literal('bedrock')), + ), // Preferred Claude provider for CLI runs + claude_model_preference: v.optional(v.string()), // Preferred Claude model name + claude_anthropic_api_key_encrypted: v.optional(v.string()), // Encrypted Anthropic API key for user-level BYOK + claude_anthropic_api_key_encryption_version: v.optional(v.number()), // Encryption version for Anthropic API key blob + claude_anthropic_api_key_updated_at: v.optional(v.number()), // Last Anthropic API key update timestamp + claude_bedrock_bearer_token_encrypted: v.optional(v.string()), // Encrypted Bedrock bearer token for user-level BYOK + claude_bedrock_bearer_token_encryption_version: v.optional(v.number()), // Encryption version for Bedrock bearer token blob + claude_bedrock_bearer_token_updated_at: v.optional(v.number()), // Last Bedrock bearer token update timestamp + // Web referral score synced from the Postgres referral ledger via the + // web_referral_score JWT claim. Drives tier-scaled limits and perks. + qualified_referral_count: v.optional(v.number()), + }) + .index('by_clerk_id', ['clerk_id']) + .index('by_freebuff_user_id', ['freebuff_user_id']) + .index('by_email', ['email']) + .index('by_referral_code', ['referral_code']) + .index('by_role', ['role']) + .index('by_resend_contact_last_synced_email', [ + 'resend_contact_last_synced_email', + ]), + project: defineTable({ + name: v.optional(v.string()), + semantic_identifier: v.string(), + sandbox_id: v.string(), + old_sandbox_id: v.optional(v.string()), // old (CSB) sandbox id + state: v.union( + v.literal('initializing'), + v.literal('unassigned'), + v.literal('active'), + v.literal('processing'), + ), + github_url: v.optional(v.string()), + preview_url: v.optional(v.string()), + pretty_preview_url: v.optional(v.string()), + // Screenshot - stores in Cloudflare R2, triggered after 10 agent commits + screenshot_r2_url: v.optional(v.string()), // Public R2 URL + screenshot_storage_id: v.optional(v.string()), // DEPRECATED: old Convex storage ID, migrated to R2 + commits_since_screenshot: v.optional(v.number()), // Counter for agent commits + + last_opened: v.optional(v.number()), + last_dist_build_at: v.optional(v.number()), + primary_document: v.optional(v.id('document')), + template_id: v.optional(v.string()), + custom_instructions: v.optional(v.string()), + terminated: v.optional(v.boolean()), + // Timestamp set while a checkpoint revert is actively running. Lets the + // checkpoint creator distinguish a real revert (must wait) from the + // run's own `processing` state (must not wait). Stale-guarded on read. + reverting_since: v.optional(v.number()), + analytics_enabled: v.optional(v.boolean()), + deleted: v.optional(v.boolean()), + + // Organization context - null for personal projects + organization_id: v.optional(v.string()), + + // Sandbox size for quota tracking (small, medium, large) + // If undefined, defaults to "small" (legacy projects from beta era) + sandbox_size: v.optional( + v.union(v.literal('small'), v.literal('medium'), v.literal('large')), + ), + + // Package manager used by the project (pnpm or bun) + // If undefined, defaults to "bun" for new projects + packageManager: v.optional(v.union(v.literal('pnpm'), v.literal('bun'))), + + // TODO: delete and clean up. currently still here due to errors + files_in_context: v.optional( + v.array( + v.object({ + file_path: v.string(), + importance: v.number(), + }), + ), + ), + + // DEPRECATED + active_workflow_id: v.optional(v.string()), // no longer used. MAY NEED TO BRING BACK FOR CLAUDE CODE + + // DEPRECATED + active_thread: v.optional(v.id('thread')), // DEPRECATED: Use active_agent_thread for new agent-based threads + + active_agent_thread: v.optional(v.id('agent_thread')), // Active agent thread (Claude Code, Gemini CLI, or Codex) + + prod_deployment_slug: v.optional(v.string()), + convex_url: v.optional(v.string()), + spec: v.optional(v.string()), + + // --- Freebuff Cloud (connect-a-repo) --------------------------------- + // Discriminates the two project lifecycles. Absent => "template" (the + // existing pooled Vly snapshot + auto Convex + Vite-on-5173 flow). + project_type: v.optional( + v.union(v.literal('template'), v.literal('connected_repo')), + ), + // For connected_repo projects: the GitHub repo backing this project. + repo_full_name: v.optional(v.string()), // "owner/name" + repo_default_branch: v.optional(v.string()), + current_branch: v.optional(v.string()), + github_installation_id: v.optional(v.number()), + // Agent-controlled runtime configuration for arbitrary repos. The agent + // configures commands via the `freebuff-preview` pseudo-CLI (set / + // set-install / set-build); the user starts/stops the preview from the UI. + runtime_config: v.optional( + v.object({ + install_command: v.optional(v.string()), + preview_command: v.optional(v.string()), + preview_port: v.optional(v.number()), + build_command: v.optional(v.string()), + detection_status: v.optional( + v.union( + v.literal('pending'), + v.literal('detecting'), + v.literal('ready'), + v.literal('failed'), + ), + ), + }), + ), + // Freebuff Cloud: once the user dismisses the getting-started checklist we + // never show it again for this project (persisted server-side so it sticks + // across devices/sessions, not just one browser's localStorage). + cloud_onboarding_dismissed: v.optional(v.boolean()), + // Freebuff Cloud: last-computed git status for the connected-repo sandbox. + // Served to the top-bar git controls by a cheap reactive query so the UI + // never wakes the sandbox just to render. Refreshed (in the same sandbox + // session) whenever a git action runs or the user explicitly refreshes. + git_status_cache: v.optional( + v.object({ + currentBranch: v.string(), + defaultBranch: v.union(v.string(), v.null()), + branches: v.array(v.string()), + isDirty: v.boolean(), + changedFiles: v.number(), + insertions: v.number(), + deletions: v.number(), + ahead: v.number(), // unpushed commits vs origin/<branch> + behind: v.number(), // commits on origin/<branch> not local + hasUpstream: v.boolean(), + behindDefault: v.number(), // commits on default branch not in this one + repoFullName: v.union(v.string(), v.null()), + updatedAt: v.number(), + }), + ), + }) + .index('by_semantic_identifier', ['semantic_identifier']) + .index('by_sandbox_id', ['sandbox_id']) + .index('by_state', ['state']) + .index('by_prod_deployment_slug', ['prod_deployment_slug']) + .index('by_organization', ['organization_id']) + .index('by_project_type', ['project_type']), + + daytona_migration: defineTable({ + project_id: v.id('project'), + daytona_server: v.optional( + v.union(v.literal('legacy'), v.literal('new')), + ), + migration_status: v.optional( + v.union( + v.literal('idle'), + v.literal('queued'), + v.literal('copying'), + v.literal('validating'), + v.literal('cutting_over'), + v.literal('done'), + v.literal('failed'), + ), + ), + migration_error: v.optional(v.string()), + legacy_sandbox_id: v.optional(v.string()), + migration_target_sandbox_id: v.optional(v.string()), + migration_started_at: v.optional(v.number()), + migration_completed_at: v.optional(v.number()), + updated_at: v.optional(v.number()), + }) + .index('by_project_id', ['project_id']) + .index('by_daytona_server', ['daytona_server']) + .index('by_migration_status', ['migration_status']), + + // DEPRECATED: Legacy thread format for old message-based system + // Use agent_thread for new agent-based systems (Claude Code, Gemini CLI, Codex) + thread: defineTable({ + project: v.id('project'), + entry_point: v.optional(v.id('entry_point')), + title: v.optional(v.string()), + status: v.union(v.literal('active'), v.literal('processing')), + files_in_context: v.optional( + v.array( + v.object({ + file_path: v.string(), + importance: v.number(), + }), + ), + ), + // integrations that are in context + integrations_in_context: v.optional( + v.array(v.id('integration')), // max 3 integrations. kick off otherwise + ), + // Track when the latest external change happened (e.g., versions page revert, git sync) + // to hide undo buttons for messages that existed before the external change + latest_external_change_timestamp: v.optional(v.number()), + // Thread-level chat history compaction state for deterministic prompt assembly. + compacted_history_summary: v.optional(v.string()), + compacted_history_up_to_message_time: v.optional(v.number()), + compacted_history_tokens: v.optional(v.number()), + compacted_history_message_count: v.optional(v.number()), + compacted_history_updated_at: v.optional(v.number()), + compaction_count: v.optional(v.number()), + }).index('by_project_by_entry_point', ['project']), + freebuff_agent_runs: defineTable({ + run_id: v.string(), + work_id: v.optional(v.string()), + // Optional for compatibility with runs created before usage tracking. + user_id: v.optional(v.id('users')), + project_id: v.id('project'), + thread_id: v.id('agent_thread'), + message_id: v.id('agent_message'), + status: v.union( + v.literal('queued'), + v.literal('running'), + v.literal('completed'), + v.literal('paused'), + v.literal('error'), + v.literal('timed_out'), + v.literal('cancelled'), + ), + queued_at: v.number(), + started_at: v.optional(v.number()), + last_event_at: v.optional(v.number()), + completed_at: v.optional(v.number()), + timed_out_at: v.optional(v.number()), + error: v.optional(v.string()), + metered_credits: v.optional(v.number()), + // Set when this run was started by a scheduled Automation fire (see + // convex/automations.ts). Null/undefined for human-initiated runs. + // Filtering by `by_automation` yields an automation's run history. + automation_id: v.optional(v.id('automation')), + }) + .index('by_run_id', ['run_id']) + .index('by_status', ['status']) + .index('by_status_started_at', ['status', 'started_at']) + .index('by_automation', ['automation_id']), + // User-configured scheduled agent runs. Each enabled automation owns one + // runtime-registered cron in the @convex-dev/crons component + // (cron_component_id). On fire it starts a fresh agent_thread run via + // internal.automations.startAutomationRun. See convex/automations.ts. + automation: defineTable({ + user_id: v.id('users'), // owner → rate-limit key + project access + project_id: v.id('project'), + name: v.string(), + prompt: v.string(), // message sent to the agent on each fire + freebuff_model: v.optional(v.string()), + cron_spec: v.string(), // standard 5-field cronspec, interpreted as UTC (v1) + // IANA timezone the schedule was authored in (e.g. "America/New_York"). + // Display/edit only — cron_spec is still a fixed UTC schedule. Optional + // for rows created before this field existed (fall back to UTC). + cron_timezone: v.optional(v.string()), + enabled: v.boolean(), + // @convex-dev/crons cron id; present only while enabled + registered. + cron_component_id: v.optional(v.string()), + last_run_at: v.optional(v.number()), + last_run_status: v.optional( + v.union( + v.literal('success'), + v.literal('skipped'), + v.literal('rate_limited'), + v.literal('quota_exceeded'), + v.literal('paused'), + v.literal('error'), + ), + ), + last_run_error: v.optional(v.string()), + last_run_thread_id: v.optional(v.id('agent_thread')), + created_by: v.id('users'), + }) + .index('by_user', ['user_id']) + .index('by_project', ['project_id']) + .index('by_project_and_enabled', ['project_id', 'enabled']), + freebuff_daily_usage: defineTable({ + user_id: v.id('users'), + day: v.string(), + run_count: v.number(), + metered_credits: v.number(), + error_count: v.number(), + timed_out_count: v.number(), + last_run_at: v.number(), + }) + .index('by_user_day', ['user_id', 'day']) + .index('by_day_metered_credits', ['day', 'metered_credits']), + // Single active session/VM/agent per user. One row per user (upserted). The + // holder claims the slot with a random session_id (tab); other tabs/devices + // read this reactively to show a seamless "take over" prompt, and the agent + // trigger gate uses agent_running to hard-block a second concurrent agent. + user_active_session: defineTable({ + user_id: v.id('users'), + session_id: v.string(), // random per browser tab/instance + project_id: v.optional(v.id('project')), + semantic_identifier: v.optional(v.string()), + surface: v.optional(v.union(v.literal('web'), v.literal('cloud'))), + agent_running: v.optional(v.boolean()), + agent_thread_id: v.optional(v.id('agent_thread')), + updated_at: v.number(), + }).index('by_user', ['user_id']), + temporary_stream: defineTable({ + content: v.string(), + resolved: v.boolean(), + }), + deployments: defineTable({ + project: v.id('project'), + freestyleDeploymentId: v.optional(v.string()), + convexDeploymentName: v.optional(v.string()), + deploymentDomain: v.optional(v.string()), + state: v.union( + v.literal('active'), + v.literal('deploying'), + v.literal('cancelling'), + v.literal('cancelled'), + v.literal('error'), + v.literal('obsolete'), + ), + deploy_status_text: v.optional(v.string()), + // GitHub deployment integration + github_deployment_id: v.optional(v.number()), + github_deployment_url: v.optional(v.string()), + }) + .index('by_project', ['project']) + .index('by_project_and_state', ['project', 'state']), + + entry_point: defineTable({ + project: v.id('project'), + type: v.union(v.literal('page')), // currently only pages are supported + associated_files: v.array(v.string()), // the files that are associated with the file / page + abstraction: v.string(), // the abstraction document representing the page + status: v.union(v.literal('active'), v.literal('processing')), // page status and locking + canvas_state: v.optional( + v.object({ + x: v.number(), + y: v.number(), + }), + ), + // page specific fields + page: v.optional( + v.object({ + page_title: v.string(), + page_display_url: v.string(), + page_file: v.string(), // the path to the file + has_dynamic_params: v.boolean(), + resolved_display_url: v.optional(v.string()), + + // DO THIS LATER + navigation_paths: v.array( + v.object({ + navigation_path: v.string(), + description: v.string(), + line_number: v.number(), + }), + ), + }), + ), + active_thread: v.optional(v.id('thread')), + }) + .index('by_project', ['project']) + .index('by_project_and_page_file', ['project', 'page.page_file']), + + // multi member + project_member: defineTable({ + project: v.id('project'), + user: v.id('users'), + project_role: v.union( + v.literal('member'), + v.literal('admin'), + v.literal('owner'), + ), + }) + .index('by_project', ['project']) + .index('by_user', ['user']) + .index('by_project_and_user', ['project', 'user']) + .index('by_project_and_role', ['project', 'project_role']), + // New invites table for project sharing via email. + invites: defineTable({ + project: v.id('project'), + email: v.string(), + token: v.string(), + expires_at: v.optional(v.number()), + project_role: v.optional( + v.union(v.literal('member'), v.literal('admin'), v.literal('owner')), + ), + }) + .index('by_project', ['project']) + .index('by_email', ['email']) + .index('by_token', ['token']), + + // DEPRECATED: Legacy messages table for old message-based system + // Use agent_message for new agent-based systems (Claude Code, Gemini CLI, Codex) + // Old format had separate messages for user/assistant turns + messages: defineTable({ + project_id: v.id('project'), + thread: v.optional(v.id('thread')), + role: v.union(v.literal('user'), v.literal('assistant')), + selected_entry_point_ids: v.optional(v.array(v.id('entry_point'))), + reasoningText: v.optional(v.string()), // reasoning for the main ai agent + content: v.string(), // the streamed ai output or tool call output + date: v.number(), // date of the message + streaming: v.boolean(), // whether the message is busy streaming + isFastReturn: v.optional(v.boolean()), // whether the message is a fast return + + // Images field for storing image storage IDs + images: v.optional(v.array(v.id('_storage'))), + + object: v.optional(v.string()), // the object that was extracted or tool call that was extracted + result: v.optional(v.string()), // the resulting response + + summarization: v.optional(v.string()), // paragraph list summarization + compact_summarization: v.optional(v.string()), // sentence summarization + code_summarization: v.optional(v.string()), // code summarization + + tool_call: v.optional(v.string()), // the name of the tool used. DEPRECATED + + thinking: v.optional(v.string()), // the thinking that was done + + // just make it do object stuff + codegen: v.optional(v.array(v.string())), // the secondary file edits + // TODO: codegen should store more information. such as file path, raw content, parsed content, etc + + error_check: v.optional(v.string()), // the formatted error check result + + file_apply_results: v.optional( + v.array( + v.object({ + path: v.string(), + success: v.boolean(), + error: v.optional(v.string()), + }), + ), + ), + + core_message: v.optional(v.string()), + fast_return_preview: v.optional(v.string()), + + // token usage + token_usage: v.optional( + v.array( + v.object({ + input_tokens: v.number(), + output_tokens: v.number(), + model: modelValidator, + }), + ), + ), + // Actual credits deducted (after tier multiplier) + credits_deducted: v.optional(v.number()), + total_cost_usd: v.optional(v.number()), + usage_breakdown: v.optional( + v.object({ + input_tokens: v.number(), + cached_input_tokens: v.number(), + output_tokens: v.number(), + reasoning_tokens: v.optional(v.number()), + cache_write_input_tokens: v.optional(v.number()), + other: v.optional(v.string()), + }), + ), + provider_metadata: v.optional(v.string()), + // Human-friendly semantic model name (e.g., "GPT 5", "Claude 4 Sonnet") + model_semantic_name: v.optional(v.string()), + thread_id: v.optional(v.id('thread')), + exclude_from_agent_history: v.optional(v.boolean()), + + commit_hash: v.optional(v.string()), + checkpoint_id: v.optional(v.string()), // Checkpoint identifier (format: projectId_commitHashPrefix) + deactivated: v.optional(v.boolean()), // whether the message was rolled back + + // AI-generated suggestions that appear after message completion + suggestions: v.optional(v.array(v.string())), // Array of 3 suggestion strings + + // Message state for tracking current status + message_state: v.optional( + v.object({ + status: v.union( + v.literal('complete'), + v.literal('error'), + v.literal('type_errors'), + v.literal('checking_errors'), + v.literal('processing_tools'), + v.literal('streaming'), + v.literal('thinking'), + v.literal('insufficient_credits'), + ), + message: v.optional(v.string()), // Additional context about the state + color: v.optional( + v.union( + v.literal('green'), + v.literal('red'), + v.literal('yellow'), + v.literal('blue'), + v.literal('gray'), + v.literal('orange'), + ), + ), // Color coding for the state + timestamp: v.optional(v.number()), // When the state was last updated + }), + ), + + // Temporary context for injection during processing (not permanently stored) + pageContext: v.optional(v.string()), + debug_prompt_log: v.optional(v.string()), + + // env vars + integration_references: v.optional(v.array(v.id('integration'))), // array of integration IDs attached to this message + }) + .index('by_project_and_date', ['project_id']) + .index('by_thread', ['thread_id', 'streaming']) + .index('by_thread_active', ['thread_id', 'streaming', 'deactivated']) + .index('by_thread_and_commit', ['thread_id', 'commit_hash', 'streaming']), + + // New agent thread format for Claude Code, Gemini CLI, and Codex + // Combines user and agent turns into a single thread model + agent_thread: defineTable({ + project_id: v.id('project'), + title: v.optional(v.string()), + isProcessing: v.boolean(), // Whether the thread is currently being processed + last_external_change_timestamp: v.optional(v.number()), // Track when external changes happened (e.g., git sync, reverts) + agent_type: v.union( + v.literal('Claude Code'), + v.literal('Gemini CLI'), + v.literal('Codex'), + v.literal('Freebuff'), + ), // Type of agent handling this thread + last_edited_timestamp: v.number(), // Timestamp for thread ordering and last activity + workflow_id: v.optional(v.string()), // Workflow ID from Convex workflow component + active_session_id: v.optional(v.string()), // Active session ID for resuming conversations + active_session_id_freebuff: v.optional(v.string()), // Per-agent session state for Freebuff + active_session_id_codex: v.optional(v.string()), // Per-agent session state for Codex + active_session_id_claude: v.optional(v.string()), // Per-agent session state for Claude Code + active_session_id_gemini: v.optional(v.string()), // Per-agent session state for Gemini CLI + active_freebuff_run_state_storage_id: v.optional(v.id('_storage')), + // Selected open-source Freebuff model id for this thread (drives which + // bundled base2-free agent runs). Defaults to DEFAULT_FREEBUFF_MODEL_ID + // when unset. Only meaningful for agent_type === 'Freebuff'. + selected_freebuff_model: v.optional(v.string()), + }) + .index('by_project', ['project_id']) + // Powers the timeout sweep: only threads currently flagged as processing + // (a handful at any time) are in the eq(true) range, so the 1-minute cron + // reads a few rows instead of scanning the whole table. + .index('by_processing', ['isProcessing', 'last_edited_timestamp']), + + // New agent message format for Claude Code, Gemini CLI, and Codex + // Combines user and assistant turns into a single message + agent_message: defineTable({ + thread_id: v.id('agent_thread'), // Reference to the agent thread + session_id: v.optional(v.string()), // session id to track conversation sessions and forks. Tracked by message for forking for message rollbacks + + // User message (optional, since some messages might be system-initiated) + user_message: v.optional(v.string()), + + // Assistant stream: array of stream chunks representing the streamed response + assistant_stream: v.optional( + v.array( + v.object({ + type: v.string(), // could be: text, tool, tool_result, summary, error, etc + title: v.optional(v.string()), + status: v.optional(v.string()), + content: v.string(), + description: v.optional(v.string()), + }), + ), + ), + + // GitHub integration fields + commit_hash: v.optional(v.string()), // Git commit hash associated with this message + checkpoint_id: v.optional(v.string()), // Checkpoint identifier (format: projectId_commitHashPrefix) + + // Message state and status + deactivated: v.optional(v.boolean()), // Whether the message was rolled back/deactivated + isStreaming: v.boolean(), // Whether the message is currently streaming + state: v.union( + v.literal('Processing'), + v.literal('Completed'), + v.literal('Paused'), + v.literal('Cancelled'), + v.literal('Error'), + ), // Current state of the message + state_message: v.optional(v.string()), // Additional context about the state, ie logging error message + + // Monotonic counter for live streaming deltas (see agent_message_delta). + // Incremented as each delta row is appended during a streaming run, and + // cleared when the message is finalized (deltas coalesced into the body). + stream_seq: v.optional(v.number()), + + // Wall-clock start (ms) of the current cloud (connected_repo) CLI-agent + // turn, carried across chained continuations so the per-turn budget + // (CLOUD_TURN_BUDGET_MS) is measured from the first run, not each chained + // action. Only set for Codex/Claude runs on connected_repo projects. + cloud_turn_started_at: v.optional(v.number()), + + // Absolute wall-clock deadline (ms) after which the watchdog cron + // force-finishes this run (marks it Paused so the user can continue), + // regardless of agent activity. Set when the turn starts for every agent + // (cloud=20min, web/template=10min). This is the hard guarantee against + // "stuck forever" runs; it does not depend on the in-action abort timer + // or idle detection. Cleared/superseded when the message reaches a + // terminal state. + processing_deadline_at: v.optional(v.number()), + + // Usage and cost tracking + total_cost_usd: v.optional(v.number()), // Total cost in USD for this message + credits_deducted: v.optional(v.number()), // Actual credits deducted (after tier multiplier) + usage_breakdown: v.optional( + // universal. put rest in other + v.object({ + input_tokens: v.number(), + cache_creation_input_tokens: v.number(), + cache_read_input_tokens: v.number(), + output_tokens: v.number(), + other: v.optional(v.string()), // rest of the dump + }), + ), // Detailed usage breakdown + model_used: v.optional(v.string()), // Model identifier used for this message + + // Images associated with the message + images: v.optional(v.array(v.id('_storage'))), // Storage IDs for images + + // Persisted ad message payload. These rows render like assistant + // messages, but are deduped against the agent message that triggered + // the ad. + ad_source_message_id: v.optional(v.id('agent_message')), + ad_payload: v.optional( + v.object({ + provider: v.string(), + adText: v.string(), + title: v.string(), + cta: v.string(), + brandName: v.optional(v.string()), + url: v.string(), + favicon: v.optional(v.string()), + imageUrl: v.optional(v.string()), + clickUrl: v.string(), + impUrl: v.string(), + placementId: v.optional(v.string()), + servedAt: v.number(), + }), + ), + }) + .index('by_thread', ['thread_id']) + .index('by_thread_active', ['thread_id', 'isStreaming', 'deactivated']) + .index('by_processing_deadline', ['isStreaming', 'processing_deadline_at']) + .index('by_thread_and_ad_source', ['thread_id', 'ad_source_message_id']) + .index('by_thread_and_commit', [ + 'thread_id', + 'commit_hash', + 'isStreaming', + ]), //verify these indexes + + // Append-only live streaming deltas for an in-flight agent_message. Each row + // is one stream chunk (a single text/reasoning/subagent/status item). Writing + // a tiny row per flush instead of read-modify-writing the whole + // `assistant_stream` array eliminates the quadratic DB I/O that dominated + // Freebuff's bandwidth. Clients tail rows with seq > cursor, so the websocket + // only ships new chunks (not the full growing message) on each update. Rows + // are coalesced into agent_message_body and deleted when the run finalizes. + agent_message_delta: defineTable({ + message_id: v.id('agent_message'), + seq: v.number(), + type: v.string(), + title: v.optional(v.string()), + status: v.optional(v.string()), + content: v.string(), + description: v.optional(v.string()), + }).index('by_message_seq', ['message_id', 'seq']), + + // Immutable coalesced assistant stream for a finalized agent_message. Holding + // the (potentially large) body here instead of inline on agent_message keeps + // listAgentThreadMessages reads light, and because the row never changes + // after the run completes it never re-invalidates the per-message body + // subscription. + agent_message_body: defineTable({ + message_id: v.id('agent_message'), + // Denormalized so the per-message body query can authorize against the + // thread without reading the (mutable) agent_message doc — keeping the body + // subscription immutable so post-completion metadata patches don't re-push it. + thread_id: v.id('agent_thread'), + stream: v.array( + v.object({ + type: v.string(), + title: v.optional(v.string()), + status: v.optional(v.string()), + content: v.string(), + description: v.optional(v.string()), + }), + ), + }).index('by_message', ['message_id']), + + scraped_site_logs: defineTable({ + url: v.string(), + raw_content: v.string(), + cleaned_content: v.string(), + date: v.number(), + project: v.id('project'), + }), + memory: defineTable({ + project: v.id('project'), + thread: v.id('thread'), + type: v.union( + v.literal('error_solution'), + v.literal('problem_solution'), + v.literal('approved_component'), + v.literal('unsolved_problem'), + ), + content: v.string(), + date: v.number(), + status: v.union(v.literal('logged'), v.literal('implemented')), + }), + domain: defineTable({ + domain: v.string(), + ownership_verified: v.boolean(), + wildcard_cert_generated: v.boolean(), + pointing_verified: v.boolean(), + ownershipVerificationCode: v.optional(v.string()), + rootDomain: v.string(), + owner: v.id('users'), + }) + .index('by_domain', ['domain']) + .index('by_rootDomain', ['rootDomain']), + project_domain: defineTable({ + projectId: v.id('project'), + domainId: v.id('domain'), + }) + .index('by_project', ['projectId']) + .index('by_domain', ['domainId']), + + // integrations + integration: defineTable({ + cover_image: v.optional(v.string()), + reference_id: v.string(), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + type: integrationTypeValidator, + public: v.boolean(), + recommended: v.optional(v.boolean()), + documentation_urls: v.array(v.string()), // reference urls + llm_instructions: v.string(), + human_added_notes: v.optional(v.string()), + env_variables: v.optional( + v.array( + v.object({ + id: v.string(), + description: v.string(), + }), + ), + ), + user_instructions: v.string(), // for what the user needs to do, in markdown + images: v.optional(v.array(v.string())), // images that go on the listing + // unified search text across multiple fields + search_text: v.optional(v.string()), + context7_library_id: v.optional(v.string()), // Context7 library ID if sourced from Context7 + + creator: v.optional(v.id('project')), + last_updated: v.number(), + approval_status: v.optional(integrationApprovalStatusValidator), + }) + .index('by_reference_id', ['reference_id']) + .index('by_public', ['public']) + .index('by_type_and_approval_status', ['type', 'approval_status']) + .index('by_last_updated', ['last_updated']) + .index('by_public_and_last_updated', ['public', 'last_updated']) + .searchIndex('search_all', { + searchField: 'search_text', + filterFields: ['public'], + }), + + search_logs: defineTable({ + projectId: v.id('project'), + userId: v.id('users'), + date: v.number(), + query: v.string(), + response: v.string(), + model: v.string(), + citations: v.array(v.string()), + }).index('by_project', ['projectId']), + project_integration: defineTable({ + projectId: v.id('project'), + integrationId: v.id('integration'), + }) + .index('by_project', ['projectId']) + .index('by_integration', ['integrationId']) + .index('by_project_and_integration', ['projectId', 'integrationId']), + + // Pending Gravity Index integrations awaiting the user's API keys. Captured + // when a service is recommended (catalog "Integrate" click or an agent + // `gravity_index` search), then converted into a deterministic + // `report_integration` once the user saves the required env var(s) in the + // Keys tab — the real conversion moment — instead of relying on the model + // to remember the report call. + gravity_pending_integration: defineTable({ + projectId: v.id('project'), + userId: v.id('users'), + // Gravity service slug actually being integrated (e.g. "resend"). + slug: v.string(), + // search_id from the originating Gravity search; ties the later + // report_integration back to us for attribution/CPA credit. + searchId: v.string(), + // Env var keys whose presence signals a completed setup for this service. + requiredEnvVars: v.array(v.string()), + // Where the recommendation came from, for debugging/attribution. + source: v.optional(v.string()), + createdAt: v.number(), + // Set once report_integration has been sent so we never double-report. + reportedAt: v.optional(v.number()), + }) + .index('by_project', ['projectId']) + .index('by_project_and_slug', ['projectId', 'slug']), + + integration_bearer_keys: defineTable({ + project_id: v.id('project'), + key: v.string(), + }) + .index('by_project', ['project_id']) + .index('by_key', ['key']), + + runtime_error: defineTable({ + projectId: v.id('project'), + error: v.string(), + stack_trace: v.optional(v.string()), + filename: v.optional(v.string()), + lineno: v.optional(v.number()), + colno: v.optional(v.number()), + url: v.string(), + date: v.number(), + resolved: v.optional(v.boolean()), + status: v.optional(errorStatusValidator), + }) + .index('by_project', ['projectId']) + .index('by_project_and_status', ['projectId', 'status']), + build_error: defineTable({ + projectId: v.id('project'), + error: v.string(), + build_log: v.string(), + date: v.number(), + resolved: v.optional(v.boolean()), + status: v.optional(errorStatusValidator), + }) + .index('by_project', ['projectId']) + .index('by_project_and_status', ['projectId', 'status']), + + // GitHub integration tables + github_connections: defineTable({ + user_id: v.id('users'), + github_user_id: v.string(), + github_username: v.string(), + access_token: v.string(), // encrypted OAuth token + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + installation_id: v.optional(v.number()), // GitHub App installation ID + installation_token: v.optional(v.string()), // encrypted installation token + installation_token_expires_at: v.optional(v.number()), + rotation_lock_expires_at: v.optional(v.number()), // Distributed lock for token rotation + repositories: v.optional(v.array(v.string())), // list of repo names + created_at: v.number(), + updated_at: v.number(), + }) + .index('by_user', ['user_id']) + .index('by_github_user_id', ['github_user_id']) + // CRITICAL: Used by token rotation cron job to find expiring tokens via range queries + // Prevents table scans across 5000+ connections during scheduled rotation + .index('by_installation_token_expires_at', [ + 'installation_token_expires_at', + ]) + .index('by_token_expires_at', ['token_expires_at']) + .index('by_rotation_lock_expires_at', ['rotation_lock_expires_at']), + + // Freebuff Cloud: cached list of connectable repos/installations per user so + // the connect dialog renders instantly instead of hitting GitHub every open. + // Refreshed on demand (refresh button) or when the cache is missing/stale. + github_repo_cache: defineTable({ + user_id: v.id('users'), + installations: v.array( + v.object({ + installation_id: v.number(), + account_login: v.string(), + account_type: v.optional(v.string()), + contents_permission: v.optional(v.string()), + can_write: v.boolean(), + manage_url: v.string(), + }), + ), + repos: v.array( + v.object({ + name: v.string(), + full_name: v.string(), + owner: v.string(), + private: v.boolean(), + description: v.union(v.string(), v.null()), + html_url: v.string(), + default_branch: v.string(), + permission_push: v.boolean(), + installation_id: v.number(), + pushed_at: v.union(v.string(), v.null()), + }), + ), + updated_at: v.number(), + }).index('by_user', ['user_id']), + + github_sync_state: defineTable({ + project_id: v.id('project'), + github_repo_name: v.string(), + github_repo_owner: v.string(), + last_sync_time: v.number(), + sync_direction: v.literal('bidirectional'), + last_commit_hash: v.optional(v.string()), + sync_status: v.union( + v.literal('synced'), + v.literal('pending'), + v.literal('error'), + v.literal('conflict'), + ), + error_message: v.optional(v.string()), + conflict_resolved_at: v.optional(v.number()), + conflict_resolved_by: v.optional(v.id('users')), + }) + .index('by_project', ['project_id']) + .index('by_repo', ['github_repo_owner', 'github_repo_name']), + + oauth_states: defineTable({ + user_id: v.id('users'), + state: v.string(), + return_url: v.optional(v.string()), + created_at: v.number(), + used: v.optional(v.boolean()), // Track if state has been used to prevent reuse + temp_used: v.optional(v.boolean()), // Track temporary usage during installation flow + temp_used_at: v.optional(v.number()), // Timestamp of temporary usage + }) + .index('by_user', ['user_id']) + .index('by_state', ['state']), + + // Convex OAuth connections - stores encrypted OAuth tokens for user's own Convex projects + convex_connections: defineTable({ + user_id: v.id('users'), + projectId: v.optional(v.id('project')), + + // OAuth tokens (ENCRYPTED) + access_token: v.string(), + refresh_token: v.optional(v.string()), + token_expires_at: v.optional(v.number()), + + // Convex project info (optional for pending_setup state) + convex_project_id: v.optional(v.number()), + project_slug: v.optional(v.string()), + team_slug: v.optional(v.string()), + + // Deployment info (optional for pending_setup state) + dev_deployment_name: v.optional(v.string()), + dev_deployment_url: v.optional(v.string()), + prod_deployment_name: v.optional(v.string()), + prod_deployment_url: v.optional(v.string()), + + // Deploy keys (ENCRYPTED) - optional for pending_setup state + dev_deploy_key: v.optional(v.string()), + dev_deploy_key_name: v.optional(v.string()), + prod_deploy_key: v.optional(v.string()), + prod_deploy_key_name: v.optional(v.string()), + + // Metadata + connection_type: v.union( + v.literal('migrated'), // Migrated from VLY + v.literal('self_hosted'), // User's own from start + v.literal('oauth_pending_setup'), // OAuth done, waiting for deploy key + ), + is_active: v.boolean(), + created_at: v.number(), + updated_at: v.number(), + }) + .index('by_user', ['user_id']) + .index('by_project', ['projectId']) + .index('by_convex_project_id', ['convex_project_id']), + + // Convex migration tracking - tracks data migration from VLY Convex to user's Convex + convex_migrations: defineTable({ + projectId: v.id('project'), + user_id: v.id('users'), + + // Migration status + status: v.union( + v.literal('initiated'), + v.literal('exporting'), + v.literal('importing'), + v.literal('updating_credentials'), + v.literal('completed'), + v.literal('failed'), + ), + progress_percentage: v.number(), + + // Source (VLY-managed) + source_dev_deployment: v.string(), + source_prod_deployment: v.optional(v.string()), + + // Target (User's Convex) + target_convex_project_id: v.optional(v.number()), + target_team_slug: v.optional(v.string()), + target_dev_deployment: v.string(), + target_prod_deployment: v.optional(v.string()), + + // Stats + tables_exported: v.optional(v.array(v.string())), + total_documents_exported: v.optional(v.number()), + total_documents_imported: v.optional(v.number()), + + // Errors + error_message: v.optional(v.string()), + error_details: v.optional(v.string()), + + // Timestamps + started_at: v.number(), + completed_at: v.optional(v.number()), + }) + .index('by_project', ['projectId']) + .index('by_user', ['user_id']) + .index('by_status', ['status']), + + referral_codes: defineTable({ + code: v.string(), // Unique referral code + owner: v.id('users'), // User who owns this referral code + created_at: v.number(), // Timestamp when created + uses_count: v.number(), // Number of signups using this code + active: v.boolean(), // Whether the code is currently active + }) + .index('by_code', ['code']) + .index('by_owner', ['owner']) + .index('by_active', ['active']), + referral_rewards: defineTable({ + referrer_user_id: v.id('users'), // User who gets the reward + referred_user_id: v.id('users'), // User who was referred + referral_code: v.string(), // The referral code used + reward_type: v.string(), // Type of reward (e.g., "token_credits") + reward_amount: v.number(), // Amount of reward + granted_at: v.number(), // Timestamp when granted + status: v.union( + v.literal('pending'), + v.literal('granted'), + v.literal('failed'), + ), // Reward status + }) + .index('by_referrer', ['referrer_user_id']) + .index('by_referred_user', ['referred_user_id']) + .index('by_status', ['status']) + .index('by_referral_code', ['referral_code']), + referral_spins: defineTable({ + user: v.id('users'), // User who can use this spin + referred_user_id: v.optional(v.id('users')), // User that generated the spin (for referral spins) + referred_user_email: v.optional(v.string()), // Lowercased referred user email snapshot for dedupe across account recreation + referral_code: v.optional(v.string()), // Referral code that generated this spin + source: v.union( + v.literal('welcome'), + v.literal('referral'), + v.literal('manual'), + ), + status: v.union( + v.literal('available'), + v.literal('spinning'), + v.literal('awarded'), + v.literal('failed'), + v.literal('revoked'), + ), + awarded_credits: v.optional(v.number()), + reward_label: v.optional(v.string()), + granted_at: v.number(), + spun_at: v.optional(v.number()), + awarded_at: v.optional(v.number()), + revoked_at: v.optional(v.number()), + revoked_reason: v.optional(v.string()), + }) + .index('by_user', ['user']) + .index('by_referred_user', ['referred_user_id']) + .index('by_referred_email', ['referred_user_email']) + .index('by_user_and_status', ['user', 'status']) + .index('by_user_and_source', ['user', 'source']) + .index('by_user_and_referred_user', ['user', 'referred_user_id']) + .index('by_status', ['status']), + bounties: defineTable({ + title: v.string(), + description: v.string(), + instructions: v.string(), + evidence_requirements: v.string(), + links: v.array(v.string()), + reward_credits: v.number(), + preview_image_storage_id: v.optional(v.id('_storage')), + status: v.union( + v.literal('active'), + v.literal('paused'), + v.literal('archived'), + ), + created_by: v.id('users'), + updated_by: v.optional(v.id('users')), + created_at: v.number(), + updated_at: v.number(), + archived_at: v.optional(v.number()), + }) + .index('by_status', ['status']) + .index('by_created_by', ['created_by']) + .index('by_status_and_created_at', ['status', 'created_at']), + bounty_submissions: defineTable({ + bounty_id: v.id('bounties'), + user_id: v.id('users'), + evidence_text: v.optional(v.string()), + evidence_links: v.array(v.string()), + evidence_image_ids: v.array(v.id('_storage')), + status: v.union( + v.literal('draft'), + v.literal('pending'), + v.literal('approved'), + v.literal('rejected'), + v.literal('revoked'), + ), + admin_review_note: v.optional(v.string()), + reviewed_by: v.optional(v.id('users')), + submitted_at: v.optional(v.number()), + reviewed_at: v.optional(v.number()), + credit_status: v.union( + v.literal('not_granted'), + v.literal('grant_pending'), + v.literal('granted'), + v.literal('grant_failed'), + v.literal('revoke_pending'), + v.literal('revoked'), + v.literal('revoke_failed'), + ), + credited_amount: v.optional(v.number()), + credit_awarded_at: v.optional(v.number()), + credit_revoked_at: v.optional(v.number()), + created_at: v.number(), + updated_at: v.number(), + }) + .index('by_bounty', ['bounty_id']) + .index('by_user', ['user_id']) + .index('by_user_and_bounty', ['user_id', 'bounty_id']) + .index('by_status', ['status']) + .index('by_credit_status', ['credit_status']) + .index('by_status_and_updated_at', ['status', 'updated_at']), + project_convex_instance: defineTable({ + projectId: v.id('project'), + convexProjectId: v.number(), + devDeploymentName: v.string(), + prodDeploymentName: v.union(v.string(), v.null()), + // Log stream IDs for new Deployment API + devLogStreamId: v.optional(v.string()), + prodLogStreamId: v.optional(v.string()), + }) + .index('by_project', ['projectId']) + .index('by_dev_deployment_name', ['devDeploymentName']) + .index('by_prod_deployment_name', ['prodDeploymentName']), + + // Latest exported filesystem snapshot for a WebContainer-backed project + // (LZ4-compressed binary blob in `_storage`). Mounted back into the + // WebContainer when the user reopens the project. One row per project — + // each new export overwrites the previous storageId (old blob deleted + // best-effort) rather than keeping full history. + project_snapshots: defineTable({ + projectId: v.id('project'), + storageId: v.id('_storage'), + sizeBytes: v.optional(v.number()), + }) + .index('by_project', ['projectId']), + + // User-defined FRONTEND env vars for WebContainer-backed projects. The + // container's `.env.local` is regenerated on every boot (and intentionally + // excluded from filesystem snapshots because it contains a deploy key), so + // this table is the durable source of truth. The client merges these into + // `.env.local` on boot and whenever they change (see + // `src/vly/lib/webcontainer/env.ts`). Backend env vars are NOT stored + // here — they live on the project's Convex deployment and are managed via + // the Convex Management API. CONVEX_DEPLOY_KEY is never allowed in here. + webcontainer_env_vars: defineTable({ + projectId: v.id('project'), + vars: v.record(v.string(), v.string()), + }).index('by_project', ['projectId']), + + // Server-to-client RPC bridge for AI agent tool calls against + // WebContainer-backed projects. The agent's LLM loop runs server-side as + // usual, but actual file/command execution can only happen inside the + // user's open browser tab (where the WebContainer lives), so each tool + // call is queued here, picked up by the client via a reactive query, + // executed locally, and written back. + pending_tool_calls: defineTable({ + runId: v.string(), + projectId: v.id('project'), + toolName: v.string(), + input: v.any(), + status: v.union( + v.literal('pending'), + v.literal('done'), + v.literal('error'), + ), + output: v.optional(v.any()), + error: v.optional(v.string()), + createdAt: v.number(), + }) + .index('by_project_status', ['projectId', 'status']) + .index('by_run', ['runId']), + + // Stats table for tracking overall statistics + stats: defineTable({ + name: v.string(), // Stat name (e.g., "users", "projects") + value: v.number(), // Current count/value + }).index('by_name', ['name']), + + // Approximate per-user agent activity for admin dashboards (not Convex billing) + user_platform_usage_stats: defineTable({ + userId: v.id('users'), + agentInvocations: v.number(), + lastInvocationAt: v.number(), + v2Runs: v.optional(v.number()), + cliRuns: v.optional(v.number()), + }) + .index('by_user', ['userId']) + .index('by_invocations', ['agentInvocations']), + + model_usage_stats: defineTable({ + agentType: v.string(), + model: v.string(), + total: v.number(), + recentDay: v.number(), + recentDayDate: v.string(), + }).index('by_agent_model', ['agentType', 'model']), + + // Persisted pagination cursors for the admin usage backfill, so a refresh + // resumes from where the last one stopped instead of re-scanning the whole + // (large) messages / agent_message tables on every admin click. + admin_backfill_state: defineTable({ + key: v.string(), // 'cli' | 'v2' + cursor: v.union(v.string(), v.null()), + updated_at: v.number(), + }).index('by_key', ['key']), + + // tickets table + tickets: defineTable({ + title: v.string(), + description: v.string(), + status: ticketStatusValidator, + userId: v.id('users'), + projectId: v.id('project'), + attachments: v.optional(v.array(v.id('_storage'))), + }) + .index('by_user', ['userId']) + .index('by_project', ['projectId']) + .index('by_status', ['status']) + .index('by_user_and_project', ['userId', 'projectId']), + + // ticket messages + tickets_messages: defineTable({ + ticketId: v.id('tickets'), + content: v.string(), + role: messageRoleValidator, + userId: v.id('users'), + attachments: v.optional(v.array(v.id('_storage'))), + aiSummary: v.optional(v.string()), + }) + .index('by_ticket', ['ticketId']) + .index('by_user', ['userId']), + + // Settings table for admin controls + settings: defineTable({ + key: v.string(), + value: v.boolean(), + }).index('by_key', ['key']), + + // Golden Daytona snapshots built/promoted from the admin dashboard. + // The `primary` row is the base snapshot used to create all new sandboxes. + daytona_snapshot: defineTable({ + // Daytona snapshot name/id (unique on the Daytona side). + snapshot_id: v.string(), + name: v.string(), + // "small" = limited; "large" = Cloud standard (6 GB, promotable); + // "web_standard" = Web standard (4 GB, used via DAYTONA_SNAPSHOT_ID env); + // "medium" = 8 GB storage-upgrade snapshot. Legacy rows may still be + // "medium"/"large". + tier: v.union( + v.literal('small'), + v.literal('medium'), + v.literal('large'), + v.literal('web_standard'), + ), + specs: v.object({ + cpu: v.string(), + ram: v.string(), + disk: v.string(), + }), + status: v.union( + v.literal('building'), + v.literal('ready'), + v.literal('primary'), + v.literal('failed'), + ), + // Monotonic version label, e.g. "golden-2026-06-23-1". + version: v.string(), + // Daytona server the snapshot lives on (legacy/new). + daytona_server: v.optional(v.union(v.literal('legacy'), v.literal('new'))), + build_logs: v.optional(v.string()), + error: v.optional(v.string()), + created_by: v.optional(v.id('users')), + created_at: v.number(), + promoted_at: v.optional(v.number()), + }) + .index('by_status', ['status']) + .index('by_snapshot_id', ['snapshot_id']), + + // Email notifications queue for Tickets + emailQueue: defineTable({ + ticketId: v.id('tickets'), + userId: v.id('users'), + scheduledFor: v.number(), + sent: v.boolean(), + cancelled: v.boolean(), + }) + .index('by_ticket', ['ticketId']) + .index('by_scheduled', ['scheduledFor']) + .index('by_sent', ['sent']) + .index('by_ticket_sent_cancelled', ['ticketId', 'sent', 'cancelled']) + .index('by_sent_cancelled_scheduled', [ + 'sent', + 'cancelled', + 'scheduledFor', + ]), + + // Ticket user read state - tracks which message each user last read + ticketUserState: defineTable({ + ticketId: v.id('tickets'), + userId: v.id('users'), + lastReadMessageId: v.optional(v.id('tickets_messages')), + }) + .index('by_ticket_and_user', ['ticketId', 'userId']) + .index('by_user', ['userId']) + .index('by_ticket', ['ticketId']), + // Integration logs for tracking integration creation process + // Feature flags for gradual rollouts and kill switches + feature_flags: defineTable({ + key: v.string(), // Unique identifier for the feature (e.g., "billing_enforcement") + rollout_strategy: v.union( + v.literal('disabled'), + v.literal('god_only'), + v.literal('beta'), + v.literal('percentage'), + v.literal('enabled'), + ), // Rollout strategy: disabled, god_only, beta, percentage, or enabled + rollout_percentage: v.optional(v.number()), // 0-100, used when strategy is "percentage" + description: v.optional(v.string()), // Human-readable description + categories: v.optional(v.array(v.string())), // Categories for grouping (e.g., ["Billing", "UI"]) + runbook: v.optional(v.string()), // Markdown documentation/runbook for this category + updated_at: v.number(), // Last update timestamp + updated_by: v.optional(v.id('users')), // User who last updated + }).index('by_key', ['key']), + + integration_logs: defineTable({ + projectId: v.id('project'), + integrationId: v.optional(v.id('integration')), // Set once integration is created/added + status: v.union( + v.literal('draft'), + v.literal('created'), + v.literal('already_existing'), + v.literal('failed'), + ), + type: v.union(v.literal('existing'), v.literal('new')), // Whether it's an existing integration or newly created + query: v.string(), // Original query from the user + steps: v.array(v.string()), // Array of step logs during the creation process + result: v.optional(v.string()), // Final result message + error: v.optional(v.string()), // Error message if failed + deep_research_results: v.optional(v.string()), // Raw output from AI deep research (for new integrations) + context7_results: v.optional( + v.object({ + searched: v.boolean(), // Whether Context7 search was attempted + search_results: v.optional(v.string()), // JSON string of full search results + results_count: v.optional(v.number()), // Number of results found + selected_library_id: v.optional(v.string()), // Library ID that was selected + raw_documentation: v.optional(v.string()), // The full raw documentation text fetched + documentation_length: v.optional(v.number()), // Length of documentation fetched + error: v.optional(v.string()), // Any error that occurred + }), + ), // Context7 search and fetch results + created_at: v.number(), // Timestamp when log started + completed_at: v.optional(v.number()), // Timestamp when completed + }) + .index('by_project', ['projectId']) + .index('by_integration', ['integrationId']), + + // User-level pause tracking for automatic resource limit enforcement + paused_users: defineTable({ + userId: v.id('users'), + pauseReason: PAUSE_REASON_VALIDATOR, + pausedAt: v.number(), // timestamp + pausedBy: v.optional(v.id('users')), // admin userId if manual pause + autoUnpauseEnabled: v.boolean(), // true for automatic pauses, false for manual + unpausedAt: v.optional(v.number()), // timestamp when unpaused + active: v.boolean(), // true if currently paused, false if unpaused + }) + .index('by_user_and_active', ['userId', 'active']) + .index('by_reason_and_active', [ + 'pauseReason', + 'active', + 'autoUnpauseEnabled', + ]), + + // Project-level pause tracking for admin manual pauses + paused_projects: defineTable({ + projectId: v.id('project'), + pauseReason: PAUSE_REASON_VALIDATOR, + pausedAt: v.number(), // timestamp + pausedBy: v.id('users'), // admin userId + unpausedAt: v.optional(v.number()), // timestamp when unpaused + active: v.boolean(), // true if currently paused, false if unpaused + }).index('by_project_and_active', ['projectId', 'active']), + + // Hiring interest forms - for companies looking to hire developers + hiring_interest_forms: defineTable({ + userId: v.id('users'), + formType: v.union( + v.literal('hiring'), + v.literal('developer_application'), + v.literal('enterprise'), + ), + // For hiring form + companyName: v.optional(v.string()), + whatBuilding: v.optional(v.string()), + budget: v.optional(v.string()), + phoneNumber: v.optional(v.string()), + // For developer application form + name: v.optional(v.string()), + linkedin: v.optional(v.string()), + github: v.optional(v.string()), + pitch: v.optional(v.string()), + submittedAt: v.number(), + }) + .index('by_user', ['userId']) + .index('by_form_type', ['formType']) + .index('by_submitted_at', ['submittedAt']) + .index('by_user_and_form_type', ['userId', 'formType']), + + cloud_beta_feedback: defineTable({ + userId: v.id('users'), + recordedName: v.string(), + recordedEmail: v.string(), + productDirection: v.union( + v.literal('combined'), + v.literal('separate'), + v.literal('unsure'), + ), + improvement: v.string(), + submittedAt: v.number(), + }) + .index('by_user', ['userId']) + .index('by_submitted_at', ['submittedAt']) + .index('by_product_direction', ['productDirection']), + + issue_reports: defineTable({ + userId: v.id('users'), + recordedUserName: v.optional(v.string()), + recordedUserEmail: v.optional(v.string()), + replyEmail: v.string(), + reportType: v.optional( + v.union(v.literal('bug'), v.literal('feature_request')), + ), + severity: v.number(), + issue: v.string(), + category: v.optional( + v.union( + v.literal('agent_response'), + v.literal('ui_ux'), + v.literal('deployment'), + v.literal('previews'), + v.literal('github_sync'), + v.literal('integrations'), + v.literal('backend'), + v.literal('other'), + ), + ), + reproductionSteps: v.optional(v.string()), + additionalLogs: v.optional(v.string()), + screenshotIds: v.optional(v.array(v.id('_storage'))), + source: v.union(v.literal('chat'), v.literal('cloud')), + status: v.union( + v.literal('open'), + v.literal('reviewing'), + v.literal('resolved'), + ), + pageUrl: v.optional(v.string()), + userAgent: v.optional(v.string()), + threadId: v.optional(v.string()), + projectId: v.optional(v.id('project')), + projectSemanticIdentifier: v.optional(v.string()), + submittedAt: v.number(), + emailSendStatus: v.union( + v.literal('pending'), + v.literal('sent'), + v.literal('failed'), + ), + emailSentAt: v.optional(v.number()), + emailError: v.optional(v.string()), + }) + .index('by_user', ['userId']) + .index('by_status', ['status']) + .index('by_project', ['projectId']) + .index('by_submitted_at', ['submittedAt']) + .index('by_email_send_status', ['emailSendStatus']), + + // God-configurable target for the automated bug-fixer bot: the Freebuff + // Cloud (connected_repo) project whose sandbox runs Codex against our + // production codebase. Single row, managed from /web/admin/bug-fixer. + bug_fixer_config: defineTable({ + target_project_semantic_id: v.string(), + enabled: v.boolean(), + updated_by: v.id('users'), + updated_at: v.number(), + }), + + // One row per issue report that entered the bug-fixer pipeline. Doubles as + // the dedupe ledger for triage (past summaries) and the FIFO dispatch + // queue for the configured cloud project. Runs dispatch strictly one at a + // time so concurrent Codex runs never fight over the sandbox worktree. + bug_fixer_queue: defineTable({ + report_id: v.id('issue_reports'), + user_id: v.id('users'), + status: v.union( + v.literal('triaging'), + v.literal('rejected'), + v.literal('queued'), + v.literal('running'), + v.literal('completed'), + v.literal('failed'), + ), + // Stamped at enqueue time; dispatch refuses to fire into any project + // other than the currently configured target. + target_project_semantic_id: v.string(), + triage_reason: v.optional(v.string()), + // Short normalized summary produced by triage; fed back into future + // triage calls for duplicate detection. + triage_summary: v.optional(v.string()), + thread_id: v.optional(v.id('agent_thread')), + message_id: v.optional(v.id('agent_message')), + enqueued_at: v.number(), + started_at: v.optional(v.number()), + finished_at: v.optional(v.number()), + error: v.optional(v.string()), + // Why a 'failed' row stopped, distinct from the free-text `error` + // message: lets the admin log show "timed out at the 20-min cloud turn + // budget" vs. "the agent errored" vs. "dispatch itself never started" + // instead of collapsing every failure into one generic label. + failure_kind: v.optional( + v.union( + v.literal('timed_out'), + v.literal('agent_error'), + v.literal('cancelled'), + v.literal('dispatch_error'), + ), + ), + }) + .index('by_status', ['status']) + .index('by_report', ['report_id']) + // Range-scan dispatched rows within a rolling window for the per-day + // Codex dispatch cap (rows without started_at sort before any number + // and are excluded from a `.gte(cutoff)` range). + .index('by_started_at', ['started_at']), + + // ============================================ + // VLY SOCIAL MEDIA TABLES + // ============================================ + + // Published projects - projects shared to the community + community_posts: defineTable({ + projectId: v.id('project'), + userId: v.id('users'), + title: v.string(), + description: v.string(), + tags: v.array(v.string()), + screenshotUrl: v.optional(v.string()), + screenshotStorageId: v.optional(v.id('_storage')), // Storage ID for user-uploaded cover images + previewUrl: v.optional(v.string()), + likesCount: v.number(), + commentsCount: v.number(), + viewsCount: v.number(), + featured: v.optional(v.boolean()), + isPublic: v.optional(v.boolean()), // defaults to true, false = private/unlisted + publishedAt: v.number(), + updatedAt: v.optional(v.number()), + }) + .index('by_user', ['userId']) + .index('by_project', ['projectId']) + .index('by_published_at', ['publishedAt']) + .index('by_likes_count', ['likesCount']) + .index('by_featured', ['featured', 'publishedAt']) + .index('by_public_and_published_at', ['isPublic', 'publishedAt']) + .searchIndex('search_posts', { + searchField: 'title', + filterFields: ['featured'], + }), + + // Likes on community posts + community_likes: defineTable({ + postId: v.id('community_posts'), + userId: v.id('users'), + createdAt: v.number(), + }) + .index('by_post', ['postId']) + .index('by_user', ['userId']) + .index('by_post_and_user', ['postId', 'userId']), + + // Comments on community posts + community_comments: defineTable({ + postId: v.id('community_posts'), + userId: v.id('users'), + content: v.string(), + parentCommentId: v.optional(v.id('community_comments')), + likesCount: v.number(), + createdAt: v.number(), + updatedAt: v.optional(v.number()), + }) + .index('by_post', ['postId', 'createdAt']) + .index('by_user', ['userId']) + .index('by_parent', ['parentCommentId']), + + // Comment likes + community_comment_likes: defineTable({ + commentId: v.id('community_comments'), + userId: v.id('users'), + createdAt: v.number(), + }) + .index('by_comment', ['commentId']) + .index('by_user', ['userId']) + .index('by_comment_and_user', ['commentId', 'userId']), + + // User follows + community_follows: defineTable({ + followerId: v.id('users'), + followingId: v.id('users'), + createdAt: v.number(), + }) + .index('by_follower', ['followerId']) + .index('by_following', ['followingId']) + .index('by_follower_and_following', ['followerId', 'followingId']), + + // User profile extensions for social features + community_profiles: defineTable({ + userId: v.id('users'), + bio: v.optional(v.string()), + website: v.optional(v.string()), + twitter: v.optional(v.string()), + github: v.optional(v.string()), + followersCount: v.number(), + followingCount: v.number(), + postsCount: v.number(), + totalLikesReceived: v.number(), + updatedAt: v.number(), + }) + .index('by_user', ['userId']) + .index('by_followers_count', ['followersCount']) + .index('by_total_likes', ['totalLikesReceived']), + + // Post views tracking + community_views: defineTable({ + postId: v.id('community_posts'), + viewerUserId: v.optional(v.id('users')), + viewerIp: v.optional(v.string()), + viewedAt: v.number(), + }) + .index('by_post', ['postId']) + .index('by_post_and_viewer', ['postId', 'viewerUserId']), + + // UI Presets (Themes & Components) + ui_preset: defineTable({ + title: v.string(), + description: v.string(), + category: v.union(v.literal('theme'), v.literal('component')), + source_url: v.string(), // Link to original creator/website + tags: v.array(v.string()), + keywords: v.optional(v.array(v.string())), // Use-case oriented search terms (e.g., "image zoom", "product preview") + public: v.boolean(), + code: v.string(), // For interactive preview (Sandpack renders this) + prompt: v.string(), // Sent to AI when user adds to project + last_updated: v.number(), + }) + .index('by_category', ['category']) + .index('by_public', ['public']) + .index('by_category_and_public', ['category', 'public']) + .index('by_last_updated', ['last_updated']) + .searchIndex('search_presets', { + searchField: 'description', + filterFields: ['category'], + }), + + // Junction table for project-preset associations + project_ui_preset: defineTable({ + projectId: v.id('project'), + uiPresetId: v.id('ui_preset'), + }) + .index('by_project', ['projectId']) + .index('by_ui_preset', ['uiPresetId']) + .index('by_project_and_preset', ['projectId', 'uiPresetId']), + + // OTP codes for "Import existing projects" flow. + // The signed-in Freebuff user (requester) requests an OTP delivered to a + // legacy email address; on verify, projects from the legacy users row are + // moved to the requester via Strategy A (claim legacy row) or Strategy B + // (transfer project_member rows). + import_email_otps: defineTable({ + requester_user_id: v.id('users'), // current signed-in Freebuff user + email: v.string(), // lowercased legacy email the OTP was sent to + code: v.string(), // 6-digit numeric code (string to preserve leading zeros) + expires_at: v.number(), // ms timestamp + attempts: v.number(), // failed verification attempts + consumed: v.boolean(), // true once successfully used + created_at: v.number(), + }) + .index('by_requester_and_email', ['requester_user_id', 'email']) + .index('by_expires_at', ['expires_at']), + + // One row per user, updated on every Freebuff message send. Counted via + // the userActivityAggregate (keyed by last_active_at) so "live users in + // the past hour" is an O(log n) aggregate range count — never a scan. + user_activity: defineTable({ + user_id: v.id('users'), + last_active_at: v.number(), // ms timestamp of most recent message send + }).index('by_user', ['user_id']), + + // One row per (user, UTC day) inserted on the user's first message send + // of that day. Counted via activeUsersByDayAggregate (keyed by day) so + // daily-active-user counts are aggregate prefix counts — never a scan. + user_activity_daily: defineTable({ + user_id: v.id('users'), + day: v.string(), // UTC day key YYYY-MM-DD + }) + .index('by_user_and_day', ['user_id', 'day']) + .index('by_day', ['day']), + + // One row per (user, UTC day) inserted on the user's first Freebuff Cloud + // (connected_repo) message send of that day. Counted via the + // cloudActiveUsersByDayAggregate (keyed by day) so Freebuff Cloud + // daily-active-user counts are aggregate prefix counts — never a scan. + cloud_user_activity_daily: defineTable({ + user_id: v.id('users'), + day: v.string(), // UTC day key YYYY-MM-DD + }) + .index('by_user_and_day', ['user_id', 'day']) + .index('by_day', ['day']), + + // One row per (user, UTC day) inserted on the user's first Freebuff Web + // (non-connected_repo) message send of that day. Counted via the + // webActiveUsersByDayAggregate (keyed by day) so Web daily-active-user + // counts are aggregate prefix counts — never a scan. + web_user_activity_daily: defineTable({ + user_id: v.id('users'), + day: v.string(), // UTC day key YYYY-MM-DD + }) + .index('by_user_and_day', ['user_id', 'day']) + .index('by_day', ['day']), + + // Limited-tier (non-allowlisted-country) usage sessions for Freebuff Web. + // One row per started session: the first message starts a 1-hour session, + // messages within the hour ride free, and at most + // FREEBUFF_WEB_LIMITED_SESSION_LIMIT sessions start per Pacific day. + // Deliberately a separate pool from the CLI's Postgres-backed sessions. + web_limited_sessions: defineTable({ + user_id: v.id('users'), + started_at: v.number(), // ms timestamp + expires_at: v.number(), // started_at + session length (1h) + day: v.string(), // Pacific day key YYYY-MM-DD + }) + .index('by_user_and_expires', ['user_id', 'expires_at']) + .index('by_user_and_day', ['user_id', 'day']), + + // Immutable daily metrics snapshots written by a cron just after UTC + // midnight. Keeps history durable even if aggregates are cleared/rebuilt. + daily_stats: defineTable({ + day: v.string(), // UTC day key YYYY-MM-DD (the day being summarized) + active_users: v.number(), // unique users who sent >= 1 message that day + new_users: v.number(), // signups that day + new_projects: v.number(), // projects created that day + total_users: v.number(), // running total at snapshot time + total_projects: v.number(), // running total at snapshot time + created_at: v.number(), + }).index('by_day', ['day']), + + // Freebuff Cloud (connected_repo) daily snapshot, mirroring daily_stats but + // scoped to Cloud usage. Written by the same nightly cron. All counts come + // from aggregates so there is never a table scan. + cloud_daily_stats: defineTable({ + day: v.string(), // UTC day key YYYY-MM-DD + active_users: v.number(), // unique users who sent >= 1 Cloud message that day + new_projects: v.number(), // connected_repo projects created that day + total_projects: v.number(), // running total connected_repo projects + created_at: v.number(), + }).index('by_day', ['day']), + + // Freebuff Web (template / non-connected_repo) daily snapshot, mirroring + // cloud_daily_stats but scoped to Web usage. Written by the same nightly + // cron. All counts come from aggregates so there is never a table scan. + web_daily_stats: defineTable({ + day: v.string(), // UTC day key YYYY-MM-DD + active_users: v.number(), // unique users who sent >= 1 Web message that day + new_projects: v.number(), // template projects created that day + total_projects: v.number(), // running total template projects + created_at: v.number(), + }).index('by_day', ['day']), + + // Incremental admin counters: one row per (UTC day, agent_type). Bumped + // O(1) on each user prompt send — no agent_message scans for dashboards. + agent_prompt_daily_stats: defineTable({ + day: v.string(), // UTC YYYY-MM-DD + agent_type: v.string(), // Freebuff | Codex | Claude Code | Gemini CLI + prompt_count: v.number(), + }) + .index('by_day', ['day']) + .index('by_day_agent', ['day', 'agent_type']), + + // Legacy nightly snapshot table — no longer written; auth/thread counts use + // incremental stats counters (admin_platform_metrics.ts). + admin_metric_snapshot: defineTable({ + key: v.string(), + data: v.any(), + computed_at: v.number(), + }).index('by_key', ['key']), + + // Per-event audit log of who uses each Freebuff Cloud feature. One row per + // user action (publish, invite, integration add, custom link save, AI + // credential connect). Powers the admin "Cloud feature usage" table without + // scanning feature-specific tables. Written via a scheduled internal + // mutation so it can never break the originating action. + cloud_feature_usage: defineTable({ + userId: v.id('users'), + feature: v.string(), // publish | invite | integration | custom_link | chatgpt_oauth | openai_byok | anthropic_byok | bedrock_byok + projectId: v.optional(v.id('project')), + day: v.string(), // UTC YYYY-MM-DD (for cheap windowed reads) + detail: v.optional(v.string()), // small free-form context (e.g. integration provider) + created_at: v.number(), + }) + .index('by_feature', ['feature']) + .index('by_feature_and_day', ['feature', 'day']) + .index('by_day', ['day']) + .index('by_user', ['userId']) + .index('by_user_and_feature', ['userId', 'feature']), + + // Incremental per-(day, feature) counters for the admin headline numbers. + // Bumped O(1) alongside each cloud_feature_usage insert; avoids counting + // rows for the dashboard summary cards. + cloud_feature_usage_daily: defineTable({ + day: v.string(), // UTC YYYY-MM-DD + feature: v.string(), + count: v.number(), + unique_users: v.number(), // distinct users that used the feature that day + }) + .index('by_day', ['day']) + .index('by_day_feature', ['day', 'feature']), + }, + { + schemaValidation: false, // TODO: turn back to true + }, +) + +export type CodegenModels = '2.5-pro' | '3.7-sonnet' | '3.5-sonnet' diff --git a/freebuff/web/convex/scraped_site_logs.ts b/freebuff/web/convex/scraped_site_logs.ts new file mode 100644 index 0000000000..1ee6c412eb --- /dev/null +++ b/freebuff/web/convex/scraped_site_logs.ts @@ -0,0 +1,29 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; + +/** + * Create a new scraped site log entry + */ +export const create = internalMutation({ + args: { + url: v.string(), + raw_content: v.string(), + cleaned_content: v.string(), + date: v.number(), + project: v.id("project"), + }, + async handler(ctx, args) { + const { url, raw_content, cleaned_content, date, project } = args; + + // Create a new entry in the scraped_site_logs table + const id = await ctx.db.insert("scraped_site_logs", { + url, + raw_content, + cleaned_content, + date, + project, + }); + + return id; + }, +}); diff --git a/freebuff/web/convex/screenshot.ts b/freebuff/web/convex/screenshot.ts new file mode 100644 index 0000000000..9baaf2b2f2 --- /dev/null +++ b/freebuff/web/convex/screenshot.ts @@ -0,0 +1,137 @@ +import { internalMutation, mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUser } from "./users"; + +/** + * Saves a screenshot R2 URL to a project + * + * Called after screenshot is uploaded to Cloudflare R2. + * Resets the commit counter to 0. + * + * @param projectId - ID of the project + * @param r2Url - Public URL of the screenshot in R2 + */ +export const saveProjectScreenshot = mutation({ + args: { + projectId: v.id("project"), + r2Url: v.string(), + }, + handler: async (ctx, args) => { + // Authenticate user + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Not authenticated"); + } + + // Validate project exists + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + // Check project membership (owner or admin can save screenshots) + const membership = await ctx.db + .query("project_member") + .withIndex("by_project_and_user", (q) => + q.eq("project", args.projectId).eq("user", user._id), + ) + .first(); + + if ( + !membership || + (membership.project_role !== "owner" && + membership.project_role !== "member" && + membership.project_role !== "admin") + ) { + throw new Error( + "Unauthorized: You do not have permission to edit this project", + ); + } + + // Update project with new screenshot URL and reset commit counter + await ctx.db.patch(args.projectId, { + screenshot_r2_url: args.r2Url, + commits_since_screenshot: 0, + }); + + console.log("[Screenshot] Saved R2 URL and reset commit counter", { + projectId: args.projectId, + r2Url: args.r2Url, + }); + }, +}); + +/** + * Internal mutation to save screenshot from automated process + * No authentication needed since it's internal + */ +export const saveProjectScreenshotInternal = internalMutation({ + args: { + projectId: v.id("project"), + r2Url: v.string(), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + // Update project with new screenshot URL and reset commit counter + await ctx.db.patch(args.projectId, { + screenshot_r2_url: args.r2Url, + commits_since_screenshot: 0, + }); + + console.log( + "[Screenshot] Saved R2 URL and reset commit counter (internal)", + { + projectId: args.projectId, + r2Url: args.r2Url, + }, + ); + }, +}); + +/** + * Increments the commit counter for a project + * Called by the agent after each commit + */ +export const incrementCommitCounter = internalMutation({ + args: { projectId: v.id("project") }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + + const currentCount = project.commits_since_screenshot ?? 0; + const newCount = currentCount + 1; + + await ctx.db.patch(args.projectId, { + commits_since_screenshot: newCount, + }); + + console.log("[Screenshot] Incremented commit counter", { + projectId: args.projectId, + count: newCount, + }); + + return newCount; + }, +}); + +/** + * Checks if a screenshot should be updated for a project + * Returns true if 10 or more commits have been made since last screenshot + */ +export const shouldUpdateScreenshot = query({ + args: { projectId: v.id("project") }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) return false; + + // Check if 0 or more commits since last screenshot + const commitCount = project.commits_since_screenshot ?? 0; + return commitCount > 0; + }, +}); diff --git a/freebuff/web/convex/search.ts b/freebuff/web/convex/search.ts new file mode 100644 index 0000000000..1804b81714 --- /dev/null +++ b/freebuff/web/convex/search.ts @@ -0,0 +1,30 @@ +import { v } from "convex/values"; +import { internalMutation } from "./_generated/server"; +import { Doc } from "./_generated/dataModel"; + +export const insertSearchLog = internalMutation({ + args: { + userId: v.id("users"), + projectId: v.id("project"), + query: v.string(), + response: v.string(), + model: v.string(), + citations: v.array(v.string()), + }, + handler: async (ctx, args) => { + if (args.userId) { + const log = await ctx.db.insert("search_logs", { + projectId: args.projectId, + userId: args.userId, + date: Date.now(), + query: args.query, + response: args.response, + model: args.model, + citations: args.citations, + }); + const logDoc = await ctx.db.get(log); + return logDoc as Doc<"search_logs">; + } + return undefined; + }, +}); diff --git a/freebuff/web/convex/services/VersioningService.ts b/freebuff/web/convex/services/VersioningService.ts new file mode 100644 index 0000000000..4ff071e8ab --- /dev/null +++ b/freebuff/web/convex/services/VersioningService.ts @@ -0,0 +1,406 @@ +"use node"; + +import { api, internal } from "../_generated/api"; +import { Id } from "../_generated/dataModel"; +import { ActionCtx } from "../_generated/server"; + +/** + * VersioningService - Encapsulates all checkpoint and revert logic + * + * This service provides a clean interface for version control operations, + * hiding the complexity of git operations, message management, and error handling. + */ +export class VersioningService { + /** Max age of a `reverting_since` marker we'll still wait on (ms). */ + private static readonly REVERT_WAIT_STALE_MS = 60_000; + + constructor(private ctx: ActionCtx) {} + + /** + * Create a checkpoint for the current state + */ + async createCheckpoint( + projectId: Id<"project">, + message: string, + messageId?: Id<"messages">, + ): Promise<{ success: boolean; checkpointId?: string; error?: string }> { + const maxRetries = 3; + let retryCount = 0; + + while (retryCount <= maxRetries) { + try { + // Check if project is busy (being reverted) + const project = await this.ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (!project) { + return { success: false, error: "Project not found" }; + } + + // Only wait when a *revert* is actively running (it rewrites git state + // out from under us). A plain `processing` state is just the run's own + // agent — the common case — and must not be delayed: the previous code + // waited a flat ~6s on every checkpoint and committed anyway, burning + // latency and spamming retry logs. The stale guard prevents a crashed + // revert from wedging checkpoints forever. + const revertInFlight = + typeof project.reverting_since === "number" && + Date.now() - project.reverting_since < + VersioningService.REVERT_WAIT_STALE_MS; + + if (revertInFlight && retryCount < maxRetries) { + console.log( + `[VersioningService] Revert in progress, retry ${retryCount + 1}/${maxRetries}`, + ); + await this.delay(2000); + retryCount++; + continue; + } + + // Create the commit + const commitResult: any = await this.ctx.runAction( + internal.codesandbox.versionControl.commit, + { projectId, message, messageId }, + ); + + if (!commitResult) { + if (retryCount < maxRetries) { + await this.delay(1000); + retryCount++; + continue; + } + return { + success: false, + error: "Failed to create commit after multiple attempts", + }; + } + + if (commitResult.hash === "no-changes") { + // No file changes to commit \u2014 not an error, just a no-op. + // First-message agent runs hit this before any tool has touched files. + return { + success: true, + checkpointId: undefined, + }; + } + + return { + success: true, + checkpointId: commitResult.hash, + }; + } catch (error) { + console.error(`[VersioningService] Error creating checkpoint:`, error); + + // Retry on transient errors + if (retryCount < maxRetries && this.isTransientError(error)) { + console.log( + `[VersioningService] Transient error, retry ${retryCount + 1}/${maxRetries}`, + ); + await this.delay(2000); + retryCount++; + continue; + } + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + return { + success: false, + error: "Failed to create checkpoint after maximum retries", + }; + } + + /** + * Revert to a specific checkpoint + */ + async revertToCheckpoint( + projectId: Id<"project">, + semanticIdentifier: string, + commitHash: string, + source?: "chat" | "versions_page", + ): Promise<void> { + // Validate the checkpoint + this.validateCheckpointId(commitHash); + + // Validate that the commit exists + await this.validateCommitExists(semanticIdentifier, commitHash); + + // Mark the project as actively reverting so a concurrently-scheduled + // "Before:" checkpoint waits for us instead of racing the git revert. + // Cleared in the finally below (and stale-guarded on read) so a crashed + // revert can't wedge checkpoints permanently. + await this.ctx.runMutation(internal.project.setReverting, { + projectId, + }); + + // Terminate any active processing (cancels agent if running) + await this.ctx.runMutation(internal.project.setStateTerminated, { + projectId, + }); + + // Save work in progress + await this.saveWorkInProgress(projectId); + + // Set project to processing state + await this.ctx.runMutation(internal.project.setStateProcessing, { + projectId, + }); + + try { + // Terminate any active processing (cancels agent if running) + await this.ctx.runMutation(internal.project.setStateTerminated, { + projectId, + }); + + // Find the thread for message deactivation + const { threadId, messageFound } = await this.findThreadForCommit( + projectId, + commitHash, + ); + + if (!threadId) { + console.warn( + `No chat history found for checkpoint ${commitHash}. Proceeding with revert without message deactivation.`, + ); + } + + // Deactivate messages after this commit + if (threadId && messageFound) { + await this.ctx.runMutation( + internal.messages.deactivateMessagesAfterCommit, + { threadId, commitHash }, + ); + } + + // Track external changes if from versions page + if (threadId && source === "versions_page") { + await this.ctx.runMutation(internal.thread.markExternalChange, { + threadId, + }); + } + + // Perform the actual revert + const project = await this.ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (!project) { + throw new Error("Project not found during revert"); + } + + const revertResult = await this.ctx.runAction( + internal.codesandbox.versionControl.revertToCommit, + { + semanticIdentifier: project.semantic_identifier, + commitHash, + }, + ); + + if (!revertResult.success) { + throw new Error(this.formatRevertError(revertResult.message)); + } + + console.log(`Checkpoint restored successfully: ${revertResult.message}`); + + // Sync to GitHub if needed + if (revertResult.newCommitHash) { + await this.ctx.runAction( + internal.codesandbox.versionControl.syncCommitToGitHub, + { + projectId, + commitHash: revertResult.newCommitHash, + }, + ); + } + } finally { + // Always clear the revert marker and mark project as done. + await this.ctx.runMutation(internal.project.clearReverting, { + projectId, + }); + await this.ctx.runMutation(internal.project.setStateDone, { + projectId, + }); + } + } + + /** + * Get all checkpoints for a project + */ + async getCheckpoints(projectId: Id<"project">): Promise<any[]> { + try { + const project = await this.ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (!project) { + return []; + } + + return await this.ctx.runAction( + api.codesandbox.versionControl.getCommits, + { + semanticIdentifier: project.semantic_identifier, + }, + ); + } catch (error) { + console.error("Failed to list checkpoints:", error); + return []; + } + } + + // ============= Private Helper Methods ============= + + private validateCheckpointId(commitHash: string): void { + if ( + !commitHash || + commitHash === "creating" || + commitHash === "failed" || + commitHash.trim() === "" + ) { + throw new Error( + "Cannot revert: The checkpoint is not ready yet. Please wait a moment and try again.", + ); + } + } + + private async validateCommitExists( + semanticIdentifier: string, + commitHash: string, + ): Promise<void> { + try { + const commits = await this.ctx.runAction( + api.codesandbox.versionControl.getCommits, + { semanticIdentifier }, + ); + + const commitExists = commits.some((c: any) => c.hash === commitHash); + if (!commitExists) { + throw new Error( + `Cannot revert: The checkpoint ${commitHash.substring(0, 7)} was not found. It may have been removed or corrupted.`, + ); + } + } catch (error) { + if (error instanceof Error && error.message.includes("Cannot revert")) { + throw error; + } + console.error("Failed to validate commit existence:", error); + // Continue anyway - the git revert will fail if commit doesn't exist + } + } + + private async saveWorkInProgress(projectId: Id<"project">): Promise<void> { + try { + const wipCommitResult = await this.ctx.runAction( + internal.codesandbox.versionControl.commit, + { + projectId, + message: "Work in progress - saved before revert", + }, + ); + + if ( + wipCommitResult && + wipCommitResult.hash && + wipCommitResult.hash !== "no-changes" + ) { + console.log( + `Committed WIP changes before revert: ${wipCommitResult.hash}`, + ); + } + } catch (error) { + console.log("No WIP changes to commit before revert:", error); + // Continue with revert even if no changes to commit + } + } + + private async findThreadForCommit( + projectId: Id<"project">, + commitHash: string, + ): Promise<{ threadId: Id<"thread"> | null; messageFound: boolean }> { + // Use getAllForProject to include deactivated messages in the search + const messages = await this.ctx.runQuery( + internal.messages.getAllForProject, + { + projectId, + }, + ); + + const messageWithCommitHash = messages.find( + (m: any) => m.commit_hash === commitHash, + ); + + if (messageWithCommitHash?.thread_id) { + return { + threadId: messageWithCommitHash.thread_id as Id<"thread">, + messageFound: true, + }; + } + + // Fall back to active thread if no message found + const projectData = await this.ctx.runQuery(internal.project.getProject, { + projectId, + }); + + if (projectData?.active_thread) { + console.log( + `Using active thread ${projectData.active_thread} for commit ${commitHash} (no associated message)`, + ); + return { + threadId: projectData.active_thread as Id<"thread">, + messageFound: false, + }; + } + + return { threadId: null, messageFound: false }; + } + + private formatRevertError(errorMessage?: string): string { + const message = errorMessage || "Unknown error"; + + if (message.includes("conflicts")) { + return "Cannot revert due to merge conflicts. Please resolve conflicts manually or try reverting to a different checkpoint."; + } + if (message.includes("not found") || message.includes("bad revision")) { + return "The checkpoint could not be found. It may have been corrupted or removed."; + } + if (message.includes("permission") || message.includes("access")) { + return "You don't have permission to revert this project. Please check your access rights."; + } + if (message.includes("timeout")) { + return "The revert operation timed out. Please try again in a moment."; + } + if (message.includes("network") || message.includes("connection")) { + return "Network error during revert. Please check your connection and try again."; + } + + // Clean up technical jargon + return ( + message + .replace(/fatal:\s*/gi, "") + .replace(/error:\s*/gi, "") + .replace(/git\s+/gi, "") + .trim() || "Failed to revert to checkpoint. Please try again." + ); + } + + private isTransientError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + + const message = error.message.toLowerCase(); + return ( + message.includes("timeout") || + message.includes("network") || + message.includes("session") || + message.includes("connect") + ); + } + + private async delay(ms: number): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/freebuff/web/convex/settings.ts b/freebuff/web/convex/settings.ts new file mode 100644 index 0000000000..d7457e3d96 --- /dev/null +++ b/freebuff/web/convex/settings.ts @@ -0,0 +1,67 @@ +import { v } from "convex/values"; +import { internalQuery, mutation, query } from "./_generated/server"; +import { getAuthUser } from "./users"; + +export const getInternal = internalQuery({ + args: { + key: v.string(), + defaultValue: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const setting = await ctx.db + .query("settings") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + return setting?.value ?? args.defaultValue ?? false; + }, +}); + +// Get a setting +export const get = query({ + args: { + key: v.string(), + defaultValue: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + return args.defaultValue ?? false; + } + + const setting = await ctx.db + .query("settings") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + return setting?.value ?? args.defaultValue ?? false; + }, +}); + +// Update a setting +export const update = mutation({ + args: { + key: v.string(), + value: v.boolean(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || user.role !== "god") { + throw new Error("Unauthorized: Admin access required"); + } + + const existing = await ctx.db + .query("settings") + .withIndex("by_key", (q) => q.eq("key", args.key)) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { value: args.value }); + } else { + await ctx.db.insert("settings", { + key: args.key, + value: args.value, + }); + } + }, +}); diff --git a/freebuff/web/convex/stats.ts b/freebuff/web/convex/stats.ts new file mode 100644 index 0000000000..2484ddf02c --- /dev/null +++ b/freebuff/web/convex/stats.ts @@ -0,0 +1,202 @@ +import { query, internalMutation } from "./_generated/server"; +import { v } from "convex/values"; + +/** + * Get a specific stat value by name + */ +export const getStat = query({ + args: { name: v.string() }, + returns: v.number(), + handler: async (ctx, args) => { + const stat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", args.name)) + .unique(); + + return stat?.value ?? 0; + }, +}); + +/** + * Get multiple stats by names + */ +export const getStats = query({ + args: { names: v.array(v.string()) }, + returns: v.record(v.string(), v.number()), + handler: async (ctx, args) => { + const stats: Record<string, number> = {}; + + for (const name of args.names) { + const stat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", name)) + .unique(); + + stats[name] = stat?.value ?? 0; + } + + return stats; + }, +}); + +/** + * Get user count (replaces the old inefficient getUserCount) + */ +export const getUserCount = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const stat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", "users")) + .unique(); + + return stat?.value ?? 0; + }, +}); + +/** + * Get project count (replaces the old inefficient getProjectCount) + */ +export const getProjectCount = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const stat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", "projects")) + .unique(); + + return stat?.value ?? 0; + }, +}); + +/** + * Internal function to increment a stat by a given amount + */ +export const incrementStat = internalMutation({ + args: { + name: v.string(), + amount: v.optional(v.number()), + }, + returns: v.number(), + handler: async (ctx, args) => { + const incrementAmount = args.amount ?? 1; + + // Try to find existing stat + const existingStat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", args.name)) + .unique(); + + if (existingStat) { + // Update existing stat + const newValue = existingStat.value + incrementAmount; + await ctx.db.patch(existingStat._id, { value: newValue }); + return newValue; + } else { + // Create new stat + const newValue = incrementAmount; + await ctx.db.insert("stats", { + name: args.name, + value: newValue, + }); + return newValue; + } + }, +}); + +/** + * Helper function to increment a stat directly from a mutation context + */ +export async function incrementStatDirectly( + ctx: { db: any }, + name: string, + amount: number = 1, +): Promise<number> { + // Try to find existing stat + const existingStat = await ctx.db + .query("stats") + .withIndex("by_name", (q: any) => q.eq("name", name)) + .unique(); + + if (existingStat) { + // Update existing stat + const newValue = existingStat.value + amount; + await ctx.db.patch(existingStat._id, { value: newValue }); + return newValue; + } else { + // Create new stat + const newValue = amount; + await ctx.db.insert("stats", { + name, + value: newValue, + }); + return newValue; + } +} + +/** + * Internal function to decrement a stat by a given amount + */ +export const decrementStat = internalMutation({ + args: { + name: v.string(), + amount: v.optional(v.number()), + }, + returns: v.number(), + handler: async (ctx, args) => { + const decrementAmount = args.amount ?? 1; + + // Try to find existing stat + const existingStat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", args.name)) + .unique(); + + if (existingStat) { + // Update existing stat (don't go below 0) + const newValue = Math.max(0, existingStat.value - decrementAmount); + await ctx.db.patch(existingStat._id, { value: newValue }); + return newValue; + } else { + // Create new stat with value 0 (since we're decrementing) + await ctx.db.insert("stats", { + name: args.name, + value: 0, + }); + return 0; + } + }, +}); + +/** + * Internal function to set a stat to a specific value + */ +export const setStat = internalMutation({ + args: { + name: v.string(), + value: v.number(), + }, + returns: v.number(), + handler: async (ctx, args) => { + // Try to find existing stat + const existingStat = await ctx.db + .query("stats") + .withIndex("by_name", (q) => q.eq("name", args.name)) + .unique(); + + if (existingStat) { + // Update existing stat + await ctx.db.patch(existingStat._id, { value: args.value }); + return args.value; + } else { + // Create new stat + await ctx.db.insert("stats", { + name: args.name, + value: args.value, + }); + return args.value; + } + }, +}); diff --git a/freebuff/web/convex/temporaryStream.ts b/freebuff/web/convex/temporaryStream.ts new file mode 100644 index 0000000000..80cc2d506d --- /dev/null +++ b/freebuff/web/convex/temporaryStream.ts @@ -0,0 +1,64 @@ +import { v } from "convex/values"; +import { internalMutation, mutation, query } from "./_generated/server"; + +export const refinePrompt = mutation({ + args: { + content: v.string(), + }, + handler: async (ctx, args) => { + const { content } = args; + + const stream = await ctx.db.insert("temporary_stream", { + content: "", + resolved: false, + }); + + // ctx.scheduler.runAfter(0, internal.ai.agentCalls.refinePromptWithStream, { + // streamId: stream, + // initialText: content, + // }); + + return stream; + }, +}); + +export const getStream = query({ + args: { + streamId: v.id("temporary_stream"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.streamId); + }, +}); +export const updateStream = internalMutation({ + args: { + streamId: v.id("temporary_stream"), + chunk: v.string(), + }, + handler: async (ctx, args) => { + const stream = await ctx.db.get(args.streamId); + if (!stream) { + throw new Error("Stream not found"); + } + + await ctx.db.patch(stream._id, { + content: stream.content + args.chunk, + }); + }, +}); + +export const resolveStream = internalMutation({ + args: { + streamId: v.id("temporary_stream"), + }, + handler: async (ctx, args) => { + const stream = await ctx.db.get(args.streamId); + if (!stream) { + throw new Error("Stream not found"); + } + + await ctx.db.patch(stream._id, { + resolved: true, + }); + }, +}); diff --git a/freebuff/web/convex/thread.ts b/freebuff/web/convex/thread.ts new file mode 100644 index 0000000000..673d1b1808 --- /dev/null +++ b/freebuff/web/convex/thread.ts @@ -0,0 +1,741 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { Id } from "./_generated/dataModel"; +import { + action, + internalMutation, + internalQuery, + mutation, + MutationCtx, + query, +} from "./_generated/server"; +import { getVerifiedAccessProject } from "./project"; +import { getAuthUser } from "./users"; + +// update thread state +export const updateThreadState = internalMutation({ + args: { + projectId: v.id("project"), + state: v.string(), + terminated: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + if (!project) { + throw new Error("Project not found"); + } + await ctx.db.patch(args.projectId, { + state: args.state as + | "initializing" + | "unassigned" + | "active" + | "processing", + terminated: args.terminated, + }); + }, +}); + +//test + +// Helper function to merge files in context and files to add, assigning importance and deduplicating +export function mergeFilesInContext( + filesInContext: { file_path: string; importance: number }[], + filesToAdd: string[], +) { + // assign file priority numbers + // baseline importance + const highestImportance = + (filesInContext ?? []).length > 0 + ? Math.max(...(filesInContext ?? []).map((file) => file.importance)) + : 0; + + const filesToAddWithImportance = filesToAdd.map((file, index) => { + return { + file_path: file, + importance: highestImportance + index + 1, + }; + }); + + // Filter out existing files that have matching paths in filesToAdd + const filteredFilesInContext = (filesInContext ?? []).filter( + (existingFile) => + !filesToAdd.some((newFile) => newFile === existingFile.file_path), + ); + + // Combine existing and new files + const combinedFiles = [ + ...filteredFilesInContext, + ...filesToAddWithImportance, + ].sort((a, b) => b.importance - a.importance); + + return combinedFiles; +} + +// automatically adds a list of files to context +export const addFilesToContext = internalMutation({ + args: { + threadId: v.id("thread"), + filesToAdd: v.array(v.string()), + }, + handler: async (ctx, args) => { + const thread = await ctx.db.get(args.threadId); + if (!thread) { + throw new Error("Thread not found"); + } + + if (!thread.files_in_context) { + thread.files_in_context = []; + } + + // Use the helper function to merge files + const combinedFiles = mergeFilesInContext( + thread.files_in_context, + args.filesToAdd, + ); + + await ctx.db.patch(args.threadId, { + files_in_context: combinedFiles, + }); + + // return files in context + return combinedFiles; + }, +}); + +export const setFilesInContext = internalMutation({ + args: { + threadId: v.id("thread"), + filesInContext: v.array(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.threadId, { + files_in_context: args.filesInContext.map((file, index) => ({ + file_path: file, + importance: index + 1, + })), + }); + }, +}); + +export const updateThreadCompaction = internalMutation({ + args: { + threadId: v.id("thread"), + summary: v.string(), + upToMessageTime: v.number(), + tokenCount: v.number(), + messageCount: v.number(), + }, + handler: async (ctx, args) => { + const thread = await ctx.db.get(args.threadId); + if (!thread) { + throw new Error("Thread not found"); + } + + await ctx.db.patch(args.threadId, { + compacted_history_summary: args.summary, + compacted_history_up_to_message_time: args.upToMessageTime, + compacted_history_tokens: args.tokenCount, + compacted_history_message_count: args.messageCount, + compacted_history_updated_at: Date.now(), + compaction_count: (thread.compaction_count ?? 0) + 1, + }); + }, +}); + +export const terminateThread = action({ + args: { + projectSemanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.projectSemanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + await ctx.runMutation(internal.project.setStateTerminated, { + projectId: project._id, + }); + }, +}); + +export const rollbackAssistantMessage = internalMutation({ + args: { + assistantMessageId: v.id("messages"), + }, + handler: async (ctx, args) => { + const assistantMessage = await ctx.db.patch(args.assistantMessageId, { + deactivated: true, + }); + return assistantMessage; + }, +}); + +export const checkIfProjectTerminated = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + const project = await ctx.db.get(args.projectId); + + if (project && project.terminated) { + return true; + } + return false; + }, +}); + +// gets messages from oldest to newest (in reverse order with latest messages first) +export const getProjectMessages = internalQuery({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_project_and_date", (q) => + q.eq("project_id", args.projectId), + ) + .filter((q) => q.neq(q.field("deactivated"), true)) + .order("desc") + .take(50); + }, +}); + +export const createNewThreadMain = mutation({ + args: { + projectSemanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + // authenticate + const user = await getAuthUser(ctx); + + if (!user) { + throw new Error("User not found"); + } + + // authorize + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.projectSemanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + // create thread + const threadId = await createNewThread(ctx, project._id); + return threadId; + }, +}); + +export const createNewThreadFromEntryPoint = mutation({ + args: { + projectSemanticIdentifier: v.string(), + entryPointId: v.id("entry_point"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + + if (!user) { + throw new Error("User not found"); + } + + // authorize + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.projectSemanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + // get entry point + const entryPoint = await ctx.db.get(args.entryPointId); + if (!entryPoint) { + throw new Error("Entry point not found"); + } + + // get associated files + const associatedFiles = entryPoint.associated_files; + + // create new thread + const thread = await ctx.db.insert("thread", { + project: project._id, + entry_point: args.entryPointId, + status: "active", + files_in_context: associatedFiles.map((file) => ({ + file_path: file, + importance: -1, + })), + }); + + await ctx.db.patch(project._id, { + active_thread: thread, + active_agent_thread: undefined, // Clear agent thread to switch to old chat UI + }); + + return thread; + }, +}); + +export async function createNewThread( + ctx: MutationCtx, + projectId: Id<"project">, +) { + // Start with empty files in context + const filesInContext: { file_path: string; importance: number }[] = []; + + const thread = await ctx.db.insert("thread", { + project: projectId, + status: "active", + files_in_context: filesInContext, + }); + + await ctx.db.patch(projectId, { + active_thread: thread, + active_agent_thread: undefined, // Clear agent thread to switch to old chat UI + }); + + return thread; +} + +export const getThread = internalQuery({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.threadId); + }, +}); + +export const getProjectThreads = query({ + args: { + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("thread") + .withIndex("by_project_by_entry_point", (q) => + q.eq("project", args.projectId), + ) + .order("desc") + .collect(); + }, +}); + +export const getFirstMessageInThread = internalQuery({ + args: { + threadId: v.id("thread"), + projectId: v.id("project"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_thread", (q) => + q.eq("thread_id", args.threadId).eq("streaming", false), + ) + .order("asc") + .filter((q) => q.neq(q.field("deactivated"), true)) + .first(); + }, +}); + +export const setThreadTitle = internalMutation({ + args: { + threadId: v.id("thread"), + title: v.string(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.threadId, { + title: args.title, + }); + }, +}); + +export const addIntegrationIdToContext = internalMutation({ + args: { + threadId: v.id("thread"), + integrationReferenceId: v.string(), + }, + handler: async (ctx, args) => { + // Get the thread + const thread = await ctx.db.get(args.threadId); + if (!thread) throw new Error("Thread not found"); + + // Find the integration by name + const integration = await ctx.db + .query("integration") + .withIndex("by_reference_id", (q) => + q.eq("reference_id", args.integrationReferenceId), + ) + .first(); + if (!integration) throw new Error("Integration not found"); + + // Get current integrations in context + const currentIntegrations = thread.integrations_in_context ?? []; + + // Check if integration already exists in context + const existingIndex = currentIntegrations.indexOf(integration._id); + + let newIntegrations; + if (existingIndex !== -1) { + // If integration exists, move it to the end + newIntegrations = [ + ...currentIntegrations.slice(0, existingIndex), + ...currentIntegrations.slice(existingIndex + 1), + integration._id, + ]; + } else { + // If integration doesn't exist, add it to the end + newIntegrations = [...currentIntegrations, integration._id]; + + // If we have more than 3 integrations, remove the first one + if (newIntegrations.length > 3) { + newIntegrations = newIntegrations.slice(-3); + } + } + + // Update thread with new integrations + await ctx.db.patch(args.threadId, { + integrations_in_context: newIntegrations, + }); + + return integration; + }, +}); + +export const addIntegrationNameToContextPublic = mutation({ + args: { + semanticIdentifier: v.string(), + threadId: v.id("thread"), + referenceId: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // auth check on the project id + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + await ctx.runMutation(internal.thread.addIntegrationIdToContext, { + threadId: args.threadId, + integrationReferenceId: args.referenceId, + }); + }, +}); + +export const updateThreadTitle = mutation({ + args: { + semanticIdentifier: v.string(), + threadId: v.id("thread"), + title: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // auth check on the project id + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + // verify the thread belongs to this project + const thread = await ctx.db.get(args.threadId); + if (!thread || thread.project !== project._id) { + throw new Error("Thread not found or access denied"); + } + + await ctx.runMutation(internal.thread.setThreadTitle, { + threadId: args.threadId, + title: args.title, + }); + }, +}); + +export const deleteThread = mutation({ + args: { + semanticIdentifier: v.string(), + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + console.log("[deleteThread] Starting deletion", { + semanticIdentifier: args.semanticIdentifier, + threadId: args.threadId, + }); + + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("User not found"); + } + + // auth check on the project id + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + throw new Error("Access denied"); + } + + console.log("[deleteThread] Project found", { + projectId: project._id, + activeThread: project.active_thread, + }); + + // verify the thread belongs to this project + const thread = await ctx.db.get(args.threadId); + if (!thread) { + throw new Error("Thread not found"); + } + if (thread.project !== project._id) { + throw new Error("Thread does not belong to this project"); + } + + console.log("[deleteThread] Thread verified", { + threadId: thread._id, + isActiveThread: project.active_thread === args.threadId, + }); + + // If this is the active thread, switch to another thread or create a new one + const isActiveThread = project.active_thread === args.threadId; + console.log("[deleteThread] Is active thread?", { + isActiveThread, + activeThreadId: project.active_thread, + deletingThreadId: args.threadId, + match: project.active_thread === args.threadId, + }); + + if (isActiveThread) { + console.log( + "[deleteThread] Active thread being deleted, finding replacement", + ); + + // Find another thread in the project (excluding the one we're deleting) + const allThreads = await ctx.db + .query("thread") + .withIndex("by_project_by_entry_point", (q) => + q.eq("project", project._id), + ) + .collect(); + + console.log("[deleteThread] All threads found", { + totalThreads: allThreads.length, + threadIds: allThreads.map((t) => t._id), + deletingThreadId: args.threadId, + }); + + const otherThread = allThreads.find((t) => t._id !== args.threadId); + + if (otherThread) { + console.log("[deleteThread] Switching to other thread", { + newActiveThread: otherThread._id, + }); + // Switch to another thread BEFORE deleting + await ctx.db.patch(project._id, { + active_thread: otherThread._id, + }); + console.log("[deleteThread] Active thread updated"); + } else { + console.log("[deleteThread] No other threads, creating new one"); + // No other threads, create a new one BEFORE deleting + const newThreadId = await createNewThread(ctx, project._id); + console.log("[deleteThread] New thread created", { + newThreadId, + }); + await ctx.db.patch(project._id, { + active_thread: newThreadId, + }); + console.log("[deleteThread] Active thread set to new thread"); + } + } + + // Delete the thread + console.log("[deleteThread] Deleting thread", args.threadId); + try { + await ctx.db.delete(args.threadId); + console.log("[deleteThread] Thread deleted successfully"); + } catch (error) { + console.error("[deleteThread] Error deleting thread:", error); + throw error; + } + + return { success: true }; + }, +}); + +export const markExternalChange = internalMutation({ + args: { + threadId: v.id("thread"), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.threadId, { + latest_external_change_timestamp: Date.now(), + }); + }, +}); + +// Internal cacheable version - accepts projectId and activeThread directly +export const getLatestExternalChangeTimestampInternal = internalQuery({ + args: { + projectId: v.id("project"), + activeThread: v.optional(v.id("thread")), + }, + handler: async (ctx, args) => { + if (!args.activeThread) { + return null; + } + + const thread = await ctx.db.get(args.activeThread); + return thread?.latest_external_change_timestamp ?? null; + }, +}); + +export const getLatestExternalChangeTimestamp = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args): Promise<number | null> => { + const user = await getAuthUser(ctx); + if (!user) { + return null; + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + return null; + } + + // Delegate to internal cached version + return await ctx.runQuery( + internal.thread.getLatestExternalChangeTimestampInternal, + { + projectId: project._id, + activeThread: project.active_thread, + }, + ); + }, +}); + +export const getActiveThreadCompactionStatusInternal = internalQuery({ + args: { + activeThread: v.optional(v.id("thread")), + }, + handler: async (ctx, args) => { + if (!args.activeThread) { + return null; + } + + const thread = await ctx.db.get(args.activeThread); + if (!thread) { + return null; + } + + return { + compactionCount: thread.compaction_count ?? 0, + compactedHistoryUpdatedAt: thread.compacted_history_updated_at ?? null, + compactedHistoryTokens: thread.compacted_history_tokens ?? null, + compactedHistoryMessageCount: + thread.compacted_history_message_count ?? null, + }; + }, +}); + +export const getActiveThreadCompactionStatus = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async ( + ctx, + args, + ): Promise<{ + compactionCount: number; + compactedHistoryUpdatedAt: number | null; + compactedHistoryTokens: number | null; + compactedHistoryMessageCount: number | null; + } | null> => { + const user = await getAuthUser(ctx); + if (!user) { + return null; + } + + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + undefined, + "read", + user, + ); + + if (!project) { + return null; + } + + return await ctx.runQuery( + internal.thread.getActiveThreadCompactionStatusInternal, + { + activeThread: project.active_thread, + }, + ); + }, +}); diff --git a/freebuff/web/convex/ticket_read_state.ts b/freebuff/web/convex/ticket_read_state.ts new file mode 100644 index 0000000000..43545ca929 --- /dev/null +++ b/freebuff/web/convex/ticket_read_state.ts @@ -0,0 +1,262 @@ +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { getAuthUser } from "./users"; +import { Id } from "./_generated/dataModel"; + +/** + * Get the unread message count for a user on a specific ticket. + */ +export const getUnreadCount = query({ + args: { + ticketId: v.id("tickets"), + }, + returns: v.number(), + handler: async (ctx, args) => { + try { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + // Verify ticket access + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Ticket not found"); + } + + const isAdmin = user.role === "god"; + const isTicketOwner = ticket.userId === user._id; + + if (!isAdmin && !isTicketOwner) { + throw new Error("Unauthorized: Cannot access this ticket"); + } + + // Get the user's read state for this ticket + const readState = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket_and_user", (q) => + q.eq("ticketId", args.ticketId).eq("userId", user._id), + ) + .first(); + + // Edge case 1: No read state exists → return total message count + if (!readState || !readState.lastReadMessageId) { + const totalMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + return totalMessages.length; + } + + // Edge case 2: Verify lastReadMessage still exists + const lastReadMessage = await ctx.db.get(readState.lastReadMessageId); + if (!lastReadMessage) { + // Message was deleted - treat as if no read state exists + const totalMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + return totalMessages.length; + } + // Get unread messages + const unreadMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .filter((q) => + q.gt(q.field("_creationTime"), lastReadMessage._creationTime), + ) + .collect(); + + return unreadMessages.length; + } catch (error) { + console.error("Error getting unread count:", error); + throw error; + } + }, +}); + +/** + * Get unread count for multiple tickets for the current user. + */ +export const getUnreadCountByTicket = query({ + args: { + ticketIds: v.array(v.id("tickets")), + }, + returns: v.record(v.id("tickets"), v.number()), + handler: async (ctx, args) => { + try { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const result: Record<Id<"tickets">, number> = {}; + + // OPTIMIZATION 1: Fetch all read states for this user in one query + const allReadStates = await ctx.db + .query("ticketUserState") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .collect(); + + // Create a Map for O(1) lookups: ticketId -> readState + const readStateMap = new Map<Id<"tickets">, (typeof allReadStates)[0]>(); + for (const readState of allReadStates) { + readStateMap.set(readState.ticketId, readState); + } + + // Process each ticket + for (const ticketId of args.ticketIds) { + try { + // Verify ticket access + const ticket = await ctx.db.get(ticketId); + if (!ticket) { + // Ticket not found, skip it + result[ticketId] = 0; + continue; + } + + const isAdmin = user.role === "god"; + const isTicketOwner = ticket.userId === user._id; + + if (!isAdmin && !isTicketOwner) { + // User doesn't have access, skip it + result[ticketId] = 0; + continue; + } + + // OPTIMIZATION 2: Get read state from map (O(1) instead of query) + const readState = readStateMap.get(ticketId); + + // No read state → return total message count + if (!readState || !readState.lastReadMessageId) { + const totalMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + result[ticketId] = totalMessages.length; + continue; + } + + // Verify lastReadMessage still exists + const lastReadMessage = await ctx.db.get(readState.lastReadMessageId); + if (!lastReadMessage) { + // Message was deleted - return total count + const totalMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + result[ticketId] = totalMessages.length; + continue; + } + + // ✅ Use ID-based filtering: only fetch unread messages + const unreadMessages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .filter((q) => + q.gt(q.field("_creationTime"), lastReadMessage._creationTime), + ) + .collect(); + + result[ticketId] = unreadMessages.length; + } catch (ticketError) { + console.error(`Error processing ticket ${ticketId}:`, ticketError); + result[ticketId] = 0; + } + } + + return result; + } catch (error) { + console.error("Error getting unread counts:", error); + throw error; + } + }, +}); + +/** + * Mark all messages in a ticket as read for the current user. + */ +export const markTicketAsRead = mutation({ + args: { + ticketId: v.id("tickets"), + }, + returns: v.null(), + handler: async (ctx, args) => { + try { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + // ✅ Added: Verify ticket access + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Ticket not found"); + } + + const isAdmin = user.role === "god"; + const isTicketOwner = ticket.userId === user._id; + + if (!isAdmin && !isTicketOwner) { + throw new Error("Unauthorized: Cannot access this ticket"); + } + + // Get the latest message in the ticket + const latestMessage = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .order("desc") + .first(); + + if (!latestMessage) { + // No messages, just create/update the read state with null + const existingReadState = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket_and_user", (q) => + q.eq("ticketId", args.ticketId).eq("userId", user._id), + ) + .first(); + + if (existingReadState) { + await ctx.db.patch(existingReadState._id, { + lastReadMessageId: undefined, + }); + } else { + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: user._id, + lastReadMessageId: undefined, + }); + } + return null; + } + + // Check if read state exists + const existingReadState = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket_and_user", (q) => + q.eq("ticketId", args.ticketId).eq("userId", user._id), + ) + .first(); + + if (existingReadState) { + // Update existing read state + await ctx.db.patch(existingReadState._id, { + lastReadMessageId: latestMessage._id, + }); + } else { + // Create new read state + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: user._id, + lastReadMessageId: latestMessage._id, + }); + } + + return null; + } catch (error) { + console.error("Error marking ticket as read:", error); + throw error; + } + }, +}); diff --git a/freebuff/web/convex/tickets.ts b/freebuff/web/convex/tickets.ts new file mode 100644 index 0000000000..55564263e7 --- /dev/null +++ b/freebuff/web/convex/tickets.ts @@ -0,0 +1,261 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { getAuthUser } from "./users"; +import { ticketStatusValidator } from "./schema"; +import { Id } from "./_generated/dataModel"; + +export const listMine = query({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const tickets = await ctx.db + .query("tickets") + .withIndex("by_user", (q) => q.eq("userId", user._id)) + .collect(); + + // Get project names + const ticketsWithProjects = await Promise.all( + tickets.map(async (ticket) => { + const project = await ctx.db.get(ticket.projectId); + return { + ...ticket, + projectName: project?.name || "Unknown Project", + }; + }), + ); + + return ticketsWithProjects; + }, +}); + +// Get the ticket for a specific project (one-to-one relationship) +export const getByProject = query({ + args: { projectId: v.id("project") }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const allTickets = await ctx.db + .query("tickets") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + + // Filter for only active tickets + const activeTickets = allTickets.filter( + (ticket) => ticket.status === "open" || ticket.status === "in_progress", + ); + + if (activeTickets.length === 0) { + return false; + } + + return true; + }, +}); + +// Get all tickets (admin only) +export const listAll = query({ + args: { + status: v.optional(ticketStatusValidator), + projectId: v.optional(v.id("project")), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || (user.role !== "god" && user.role !== "admin")) { + throw new Error("Unauthorized: Admin access required"); + } + + let tickets; + + if (args.status !== undefined) { + const status = args.status; + tickets = await ctx.db + .query("tickets") + .withIndex("by_status", (q) => q.eq("status", status)) + .collect(); + } else if (args.projectId !== undefined) { + const projectId = args.projectId; + tickets = await ctx.db + .query("tickets") + .withIndex("by_project", (q) => q.eq("projectId", projectId)) + .collect(); + } else { + tickets = await ctx.db.query("tickets").collect(); + } + + // Get user and project info + const ticketsWithDetails = await Promise.all( + tickets.map(async (ticket) => { + const ticketUser = await ctx.db.get(ticket.userId); + const project = await ctx.db.get(ticket.projectId); + return { + ...ticket, + userName: ticketUser?.name || ticketUser?.email || "Unknown User", + userEmail: ticketUser?.email, + projectName: project?.name || "Unknown Project", + }; + }), + ); + + return ticketsWithDetails; + }, +}); + +// Get a single ticket +export const get = query({ + args: { id: v.id("tickets") }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const ticket = await ctx.db.get(args.id); + if (!ticket) { + return null; + } + + // Check permissions + if ( + user.role !== "god" && + user.role !== "admin" && + ticket.userId !== user._id + ) { + throw new Error("Unauthorized: Cannot view this ticket"); + } + + const project = await ctx.db.get(ticket.projectId); + const ticketUser = await ctx.db.get(ticket.userId); + + return { + ...ticket, + projectName: project?.name || "Unknown Project", + userName: ticketUser?.name || ticketUser?.email || "Unknown User", + userEmail: ticketUser?.email, + }; + }, +}); + +// Create a new ticket +export const create = mutation({ + args: { + title: v.string(), + description: v.string(), + projectId: v.id("project"), + attachments: v.optional(v.array(v.id("_storage"))), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + // Check if there's already a ticket for this project (globally, not per user) + const existingTicket = await ctx.db + .query("tickets") + .withIndex("by_project", (q) => q.eq("projectId", args.projectId)) + .collect(); + + const hasActiveTicket = existingTicket.some( + (ticket) => ticket.status === "open" || ticket.status === "in_progress", + ); + + if (hasActiveTicket) { + throw new Error( + "This project already has an active ticket. Please wait for it to be resolved or closed before creating a new one.", + ); + } + + return await ctx.db.insert("tickets", { + title: args.title, + description: args.description, + status: "open", + userId: user._id, + projectId: args.projectId, + attachments: args.attachments, + }); + }, +}); + +// Generate upload URL for file attachments +export const generateUploadUrl = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + return await ctx.storage.generateUploadUrl(); + }, +}); + +// Update ticket status (admin only) +export const updateStatus = mutation({ + args: { + id: v.id("tickets"), + status: ticketStatusValidator, + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || (user.role !== "god" && user.role !== "admin")) { + throw new Error("Unauthorized: Admin access required"); + } + + await ctx.db.patch(args.id, { status: args.status }); + }, +}); + +// Delete a ticket (admin only) +export const remove = mutation({ + args: { id: v.id("tickets") }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user || (user.role !== "god" && user.role !== "admin")) { + throw new Error("Unauthorized: Admin access required"); + } + + // Delete all messages associated with the ticket + const messages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.id)) + .collect(); + + // Collect attachments from messages and ticket + const attachmentIds = new Set<Id<"_storage">>(); + for (const m of messages) { + if (m.attachments) { + for (const a of m.attachments) attachmentIds.add(a); + } + } + const ticketDoc = await ctx.db.get(args.id); + if (ticketDoc?.attachments) { + for (const a of ticketDoc.attachments) attachmentIds.add(a); + } + + for (const message of messages) { + await ctx.db.delete(message._id); + } + + // Delete the ticket + await ctx.db.delete(args.id); + + // Delete attachments + try { + await Promise.all( + Array.from(attachmentIds).map(async (attachmentId) => { + await ctx.storage.delete(attachmentId); + }), + ); + } catch (error) { + console.error("Error deleting attachments:", error); + throw new Error("Failed to delete some attachments"); + } + return null; + }, +}); diff --git a/freebuff/web/convex/tickets_email.ts b/freebuff/web/convex/tickets_email.ts new file mode 100644 index 0000000000..d672043b74 --- /dev/null +++ b/freebuff/web/convex/tickets_email.ts @@ -0,0 +1,171 @@ +import { + internalMutation, + internalAction, + internalQuery, +} from "./_generated/server"; +import { api, internal } from "./_generated/api"; +import { v } from "convex/values"; + +// Keep this mutation for database operations only +export const markEmailAsSent = internalMutation({ + args: { emailId: v.id("emailQueue") }, + handler: async (ctx, { emailId }) => { + await ctx.db.patch(emailId, { sent: true }); + }, +}); + +// Fetch pending emails and coordinate email sending +export const processPendingEmails = internalAction({ + args: {}, + handler: async (ctx) => { + // Fetch emails via query + const pendingEmails: any[] = await ctx.runQuery( + internal.tickets_email.getPendingEmails, + ); + + // console.log( + // `[processPendingEmails] Found ${pendingEmails.length} emails to send`, + // ); + + let sentCount = 0; + let errorCount = 0; + + for (const emailRecord of pendingEmails) { + try { + // Fetch all data + const recipient: any = await ctx.runQuery( + internal.tickets_email.getUser, + { + userId: emailRecord.userId, + }, + ); + const ticket: any = await ctx.runQuery( + internal.tickets_email.getTicket, + { + ticketId: emailRecord.ticketId, + }, + ); + const latestMessage: any = await ctx.runQuery( + internal.tickets_email.getLatestMessage, + { + ticketId: emailRecord.ticketId, + }, + ); + + const projectSemanticsIdentifier = await ctx.runQuery( + internal.tickets_email.getProjectSemanticsIdentifier, + { + ticketId: emailRecord.ticketId, + }, + ); + + if (!projectSemanticsIdentifier) { + console.error( + `[processPendingEmails] Missing project semantics identifier for email ${emailRecord._id}`, + ); + errorCount++; + continue; + } + if (!recipient || !ticket || !latestMessage) { + console.error( + `[processPendingEmails] Missing data for email ${emailRecord._id}`, + ); + errorCount++; + continue; + } + + // Call email sending action + const emailResult = await ctx.runAction( + api.email.sendTicketReplyEmail, + { + recipientEmail: recipient.email, + recipientName: recipient.name ?? "User", + ticketTitle: ticket.title, + messageContent: latestMessage.content, + ticketUrl: getTicketUrl(projectSemanticsIdentifier), + }, + ); + + if (!emailResult.success) { + console.error(`[processPendingEmails] Failed: ${emailResult.error}`); + errorCount++; + } else { + console.log(`[processPendingEmails] Sent to ${recipient.email}`); + // Mark as sent via mutation + await ctx.runMutation(internal.tickets_email.markEmailAsSent, { + emailId: emailRecord._id, + }); + sentCount++; + } + } catch (error) { + console.error(`[processPendingEmails] Error: ${error}`); + errorCount++; + } + } + + return { sentCount, errorCount, totalProcessed: pendingEmails.length }; + }, +}); + +// Helper queries +export const getPendingEmails = internalQuery({ + args: {}, + handler: async (ctx) => { + // Round to nearest 5 minutes for better caching + // This groups all calls within a 5-minute window together + const roundedNow = + Math.floor(Date.now() / (5 * 60 * 1000)) * (5 * 60 * 1000); + + return await ctx.db + .query("emailQueue") + .withIndex("by_sent_cancelled_scheduled", (q: any) => + q + .eq("sent", false) + .eq("cancelled", false) + .lte("scheduledFor", roundedNow), + ) + .collect(); + }, +}); + +export const getUser = internalQuery({ + args: { userId: v.id("users") }, + handler: async (ctx, { userId }) => { + return await ctx.db.get(userId); + }, +}); + +export const getTicket = internalQuery({ + args: { ticketId: v.id("tickets") }, + handler: async (ctx, { ticketId }) => { + return await ctx.db.get(ticketId); + }, +}); + +export const getLatestMessage = internalQuery({ + args: { ticketId: v.id("tickets") }, + handler: async (ctx, { ticketId }) => { + return await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q: any) => q.eq("ticketId", ticketId)) + .order("desc") + .first(); + }, +}); + +export const getProjectSemanticsIdentifier = internalQuery({ + args: { ticketId: v.id("tickets") }, + handler: async (ctx, { ticketId }) => { + const ticket = await ctx.db.get(ticketId); + if (!ticket) { + return null; + } + const project = await ctx.db.get(ticket.projectId); + return project?.semantic_identifier; + }, +}); + +function getTicketUrl(projectSemanticsIdentifier: string): string { + const baseUrl = process.env.APP_URL || "https://freebuff.app"; + return `${baseUrl}/project/${projectSemanticsIdentifier}`; +} diff --git a/freebuff/web/convex/tickets_messages.ts b/freebuff/web/convex/tickets_messages.ts new file mode 100644 index 0000000000..8904970213 --- /dev/null +++ b/freebuff/web/convex/tickets_messages.ts @@ -0,0 +1,279 @@ +import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import { getAuthUser } from "./users"; +import { messageRoleValidator } from "./schema"; + +// Get file URL from storage +export const getFileUrl = query({ + args: { storageId: v.id("_storage") }, + returns: v.union( + v.null(), + v.object({ + url: v.string(), + contentType: v.optional(v.string()), + }), + ), + handler: async (ctx, args) => { + try { + const url = await ctx.storage.getUrl(args.storageId); + if (!url) return null; + const metadata = await ctx.db.system.get(args.storageId); + return { + url, + contentType: metadata?.contentType, + }; + } catch (error) { + console.error("Error getting file URL:", error); + throw error; + } + }, +}); + +// Get all messages for a ticket +export const listByTicket = query({ + args: { ticketId: v.id("tickets") }, + returns: v.array( + v.object({ + _id: v.id("tickets_messages"), + _creationTime: v.number(), + ticketId: v.id("tickets"), + content: v.string(), + role: messageRoleValidator, + userId: v.id("users"), + attachments: v.optional(v.array(v.id("_storage"))), + aiSummary: v.optional(v.string()), + userName: v.string(), + }), + ), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Ticket not found"); + } + + // Check permissions + if (user.role !== "god" && ticket.userId !== user._id) { + throw new Error("Unauthorized: Cannot view messages for this ticket"); + } + + const messages = await ctx.db + .query("tickets_messages") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + + // Get user info for each message + const messagesWithUsers = await Promise.all( + messages.map(async (message) => { + const messageUser = await ctx.db.get(message.userId); + return { + ...message, + userName: messageUser?.name || messageUser?.email || "Unknown User", + }; + }), + ); + + return messagesWithUsers; + }, +}); + +// Send a message +export const send = mutation({ + args: { + ticketId: v.id("tickets"), + content: v.string(), + attachments: v.optional(v.array(v.id("_storage"))), + }, + returns: v.id("tickets_messages"), + handler: async (ctx, args) => { + try { + const user = await getAuthUser(ctx); + if (!user) { + throw new Error("Unauthorized"); + } + + const ticket = await ctx.db.get(args.ticketId); + if (!ticket) { + throw new Error("Ticket not found"); + } + + // Check permissions + if (user.role !== "god" && ticket.userId !== user._id) { + throw new Error("Unauthorized: Cannot send message to this ticket"); + } + + // Determine role + const role: "user" | "admin" = user.role === "god" ? "admin" : "user"; + + // ===== PHASE 1: Pre-fetch data (minimize race window) ===== + + // Pre-fetch sender's read state (needed in Phase 3) + const senderReadState = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket_and_user", (q) => + q.eq("ticketId", args.ticketId).eq("userId", user._id), + ) + .first(); + + // Pre-fetch all admins (needed for both admin and user reply branches) + const admins = await ctx.db + .query("users") + .withIndex("by_role", (q) => q.eq("role", "god")) + .collect(); + console.log( + `[send PHASE 1] Found ${admins.length} total admins, sender=${user._id}`, + ); + + // If user is replying, get pending emails to cancel + let pendingEmails: any[] = []; + if (role === "user") { + pendingEmails = await ctx.db + .query("emailQueue") + .withIndex("by_ticket_sent_cancelled", (q) => + q + .eq("ticketId", args.ticketId) + .eq("sent", false) + .eq("cancelled", false), + ) + .collect(); + } + + // ===== PHASE 2: Write operations in correct order ===== + + // Cancel pending emails (if user replying) + for (const email of pendingEmails) { + await ctx.db.patch(email._id, { cancelled: true }); + } + + // Update ticket status (if admin replying to open ticket) + if (role === "admin" && ticket.status === "open") { + await ctx.db.patch(args.ticketId, { status: "in_progress" }); + } + + // Create the message + const messageId = await ctx.db.insert("tickets_messages", { + ticketId: args.ticketId, + content: args.content, + role, + userId: user._id, + attachments: args.attachments, + }); + + // ===== PHASE 3: IMMEDIATELY mark sender's message as read ===== + // This prevents the sender from seeing their own message as unread + + if (senderReadState) { + await ctx.db.patch(senderReadState._id, { + lastReadMessageId: messageId, + }); + } else { + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: user._id, + lastReadMessageId: messageId, + }); + } + + // ===== PHASE 4: Initialize read states for recipients ===== + // Use batch operations to avoid N+1 queries + + if (role === "admin") { + // Admin replying: ensure BOTH ticket user AND other admins have read states + // + // BUG FIX: Ensures ticket user AND all other admins get notified + // When Admin A sends message, Admin B needs a read state initialized + // so they see it as unread + + // Get existing read states for all users on this ticket + const existingReadStates = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + + // Create a Set of user IDs that already have read states + const userIdsWithReadState = new Set( + existingReadStates.map((rs) => rs.userId), + ); + + // Ensure ticket user has read state + if (!userIdsWithReadState.has(ticket.userId)) { + console.log( + `[send PHASE 4] Creating read state for ticket user=${ticket.userId}, ticket=${args.ticketId}`, + ); + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: ticket.userId, + lastReadMessageId: undefined, + }); + userIdsWithReadState.add(ticket.userId); + } + + // Ensure other admins have read states (exclude sender) + for (const admin of admins) { + if (admin._id !== user._id && !userIdsWithReadState.has(admin._id)) { + console.log( + `[send PHASE 4] Creating read state for admin=${admin._id}, ticket=${args.ticketId}`, + ); + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: admin._id, + lastReadMessageId: undefined, + }); + } + } + } else { + // User replying: ensure all admins have read state for this ticket + // Pre-fetched admins in Phase 1 + + // Get existing read states for admins (batch query) + const existingAdminReadStates = await ctx.db + .query("ticketUserState") + .withIndex("by_ticket", (q) => q.eq("ticketId", args.ticketId)) + .collect(); + + // Create a Set of admin IDs that already have read states + const adminIdsWithReadState = new Set( + existingAdminReadStates + .filter((rs) => admins.some((admin) => admin._id === rs.userId)) + .map((rs) => rs.userId), + ); + + // Only create read states for admins who don't have them + for (const admin of admins) { + if (!adminIdsWithReadState.has(admin._id)) { + console.log( + `[send PHASE 4] Creating read state for admin=${admin._id}, ticket=${args.ticketId}`, + ); + await ctx.db.insert("ticketUserState", { + ticketId: args.ticketId, + userId: admin._id, + lastReadMessageId: undefined, + }); + } + } + } + + // ===== PHASE 5: Schedule email notification ===== + + if (role === "admin") { + const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; + await ctx.db.insert("emailQueue", { + ticketId: args.ticketId, + userId: ticket.userId, + scheduledFor: fiveMinutesFromNow, + sent: false, + cancelled: false, + }); + } + + return messageId; + } catch (error) { + console.error("Error sending message:", error); + throw error; + } + }, +}); diff --git a/freebuff/web/convex/tsconfig.json b/freebuff/web/convex/tsconfig.json new file mode 100644 index 0000000000..35c6e9982f --- /dev/null +++ b/freebuff/web/convex/tsconfig.json @@ -0,0 +1,33 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true, + "paths": { + "!/*": ["./*"] + } + }, + "include": [ + "./**/*", + "../csb-utils/Codebase.ts", + "../csb-utils/instanceManager.ts", + "../csb-utils/github.ts" + ], + "exclude": ["./_generated"] +} diff --git a/freebuff/web/convex/uiPresets.ts b/freebuff/web/convex/uiPresets.ts new file mode 100644 index 0000000000..e1444dd94d --- /dev/null +++ b/freebuff/web/convex/uiPresets.ts @@ -0,0 +1,508 @@ +import { + mutation, + query, + internalQuery, + internalMutation, +} from "./_generated/server"; +import { internal } from "./_generated/api"; +import { paginationOptsValidator } from "convex/server"; +import { v } from "convex/values"; +import { getAuthUser } from "./users"; +import { getVerifiedAccessProject } from "./project"; + +// Validator for preset category +const categoryValidator = v.union(v.literal("theme"), v.literal("component")); +const UI_PRESET_SEARCH_CACHE_TTL_MS = 10 * 60 * 1000; + +type CachedSearchPreset = { + _id: string; + title: string; + description: string; + category: "theme" | "component"; + tags: string[]; + keywords?: string[]; + titleLower: string; + descriptionLower: string; + tagsLower: string[]; + keywordsLower: string[]; +}; + +let cachedSearchPresets: + | { + expiresAt: number; + presets: CachedSearchPreset[]; + } + | undefined; + +function invalidateUiPresetSearchCache() { + cachedSearchPresets = undefined; +} + +// Get a single UI preset by ID +export const getUiPresetById = query({ + args: { + presetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.presetId); + }, +}); + +// Get all UI presets (library) with server-side pagination +// Filters by category (theme/component) and visibility +// Optimized: Only fetches user if needed (for god mode check) +export const getUiPresets = query({ + args: { + category: v.optional(categoryValidator), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + // Fast path: Check identity first without full user fetch + const identity = await ctx.auth.getUserIdentity(); + + // For god mode, we need to fetch the user + // But for regular users, we can skip this expensive operation + let isGod = false; + if (identity) { + // Only fetch user if we need to check god status + // Use direct DB query instead of runQuery for better performance + const user = await ctx.db + .query("users") + .withIndex("by_clerk_id", (q) => q.eq("clerk_id", identity.subject)) + .unique(); + isGod = user?.role === "god"; + } + + // Build the query based on category and user role + if (args.category) { + // Filter by category + if (isGod) { + // God users see all presets in category + return await ctx.db + .query("ui_preset") + .withIndex("by_category", (q) => q.eq("category", args.category!)) + .order("desc") + .paginate(args.paginationOpts); + } else { + // Regular users only see public presets in category + return await ctx.db + .query("ui_preset") + .withIndex("by_category_and_public", (q) => + q.eq("category", args.category!).eq("public", true), + ) + .order("desc") + .paginate(args.paginationOpts); + } + } else { + // No category filter - get all presets + if (isGod) { + return await ctx.db + .query("ui_preset") + .withIndex("by_last_updated") + .order("desc") + .paginate(args.paginationOpts); + } else { + return await ctx.db + .query("ui_preset") + .withIndex("by_public", (q) => q.eq("public", true)) + .order("desc") + .paginate(args.paginationOpts); + } + } + }, +}); + +// Get UI presets added to a specific project +export const getProjectUiPresets = query({ + args: { + semanticIdentifier: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Get project-preset associations + const projectPresets = await ctx.db + .query("project_ui_preset") + .withIndex("by_project", (q) => q.eq("projectId", project._id)) + .collect(); + + // Get full preset details + const presets = await Promise.all( + projectPresets.map(async (pp) => { + const preset = await ctx.db.get(pp.uiPresetId); + return preset; + }), + ); + + return presets.filter((p): p is NonNullable<typeof p> => p !== null); + }, +}); + +// Add a UI preset to a project +export const addUiPresetToProject = mutation({ + args: { + semanticIdentifier: v.string(), + uiPresetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Check if preset exists + const preset = await ctx.db.get(args.uiPresetId); + if (!preset) throw new Error("UI Preset not found"); + + // Check if preset is already added to project + const existing = await ctx.db + .query("project_ui_preset") + .withIndex("by_project_and_preset", (q) => + q.eq("projectId", project._id).eq("uiPresetId", args.uiPresetId), + ) + .unique(); + + if (existing) { + throw new Error("UI Preset already added to project"); + } + + // Add preset to project + await ctx.db.insert("project_ui_preset", { + projectId: project._id, + uiPresetId: args.uiPresetId, + }); + + return true; + }, +}); + +// Remove a UI preset from a project +export const removeUiPresetFromProject = mutation({ + args: { + semanticIdentifier: v.string(), + uiPresetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + // Verify project access and get project + const project = await getVerifiedAccessProject( + ctx, + user._id, + args.semanticIdentifier, + ); + if (!project) throw new Error("Project not found"); + + // Find and delete the project-preset association + const projectPreset = await ctx.db + .query("project_ui_preset") + .withIndex("by_project_and_preset", (q) => + q.eq("projectId", project._id).eq("uiPresetId", args.uiPresetId), + ) + .unique(); + + if (!projectPreset) { + throw new Error("UI Preset not found in project"); + } + + await ctx.db.delete(projectPreset._id); + return true; + }, +}); + +// God mode: Create a new UI preset +export const createUiPreset = mutation({ + args: { + title: v.string(), + description: v.string(), + category: categoryValidator, + source_url: v.string(), + tags: v.array(v.string()), + keywords: v.optional(v.array(v.string())), // Use-case oriented search terms + public: v.boolean(), + code: v.string(), + prompt: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + if (user.role !== "god") throw new Error("Not authorized"); + + const presetId = await ctx.db.insert("ui_preset", { + ...args, + last_updated: Date.now(), + }); + invalidateUiPresetSearchCache(); + + return presetId; + }, +}); + +// God mode: Update an existing UI preset +export const updateUiPreset = mutation({ + args: { + presetId: v.id("ui_preset"), + title: v.optional(v.string()), + description: v.optional(v.string()), + category: v.optional(categoryValidator), + source_url: v.optional(v.string()), + tags: v.optional(v.array(v.string())), + keywords: v.optional(v.array(v.string())), // Use-case oriented search terms + public: v.optional(v.boolean()), + code: v.optional(v.string()), + prompt: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + if (user.role !== "god") throw new Error("Not authorized"); + + const { presetId, ...updates } = args; + + // Filter out undefined values + const updateData: Record<string, unknown> = {}; + for (const [key, value] of Object.entries(updates)) { + if (value !== undefined) { + updateData[key] = value; + } + } + + // Always update last_updated timestamp + updateData.last_updated = Date.now(); + + await ctx.db.patch(presetId, updateData); + invalidateUiPresetSearchCache(); + return await ctx.db.get(presetId); + }, +}); + +// God mode: Delete a UI preset +export const deleteUiPreset = mutation({ + args: { + presetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + if (user.role !== "god") throw new Error("Not authorized"); + + // Delete all project associations first + const projectPresets = await ctx.db + .query("project_ui_preset") + .withIndex("by_ui_preset", (q) => q.eq("uiPresetId", args.presetId)) + .collect(); + + for (const pp of projectPresets) { + await ctx.db.delete(pp._id); + } + + // Then delete the preset + await ctx.db.delete(args.presetId); + invalidateUiPresetSearchCache(); + return true; + }, +}); + +const loadSearchPresets = async (ctx: any): Promise<CachedSearchPreset[]> => { + if (cachedSearchPresets && cachedSearchPresets.expiresAt > Date.now()) { + return cachedSearchPresets.presets; + } + + const presets = await ctx.runQuery(internal.uiPresets.getUiPresetSearchData); + const normalized = presets.map((preset: any) => ({ + _id: preset._id, + title: preset.title, + description: preset.description, + category: preset.category, + tags: preset.tags ?? [], + keywords: preset.keywords ?? [], + titleLower: preset.title.toLowerCase(), + descriptionLower: preset.description.toLowerCase(), + tagsLower: (preset.tags ?? []).map((tag: string) => tag.toLowerCase()), + keywordsLower: (preset.keywords ?? []).map((kw: string) => + kw.toLowerCase(), + ), + })); + + cachedSearchPresets = { + expiresAt: Date.now() + UI_PRESET_SEARCH_CACHE_TTL_MS, + presets: normalized, + }; + + return normalized; +}; + +/** Search metadata only — code/prompt are loaded on demand via getUiPresetContent. */ +export const getUiPresetSearchData = internalQuery({ + args: {}, + handler: async (ctx) => { + const rows = await ctx.db.query("ui_preset").collect(); + return rows.map((preset) => ({ + _id: preset._id, + title: preset.title, + description: preset.description, + category: preset.category, + tags: preset.tags ?? [], + keywords: preset.keywords ?? [], + })); + }, +}); + +// Internal query for AI agent tool - search UI presets using search index + regex scoring. +// Replaces the old internalAction that did a full table scan with in-memory cache. +export const searchUiPresetsInternal = internalQuery({ + args: { + searchQuery: v.string(), + category: v.optional(v.union(v.literal("theme"), v.literal("component"))), + maxResults: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const maxResults = args.maxResults ?? 3; + const searchTerms = args.searchQuery + .toLowerCase() + .split(/\s+/) + .filter((t) => t.length > 2); + + if (searchTerms.length === 0) return []; + + // Use search index on description, filtered by category if provided + const searchResults = await ctx.db + .query("ui_preset") + .withSearchIndex("search_presets", (q) => { + const base = q.search("description", args.searchQuery); + return args.category ? base.eq("category", args.category) : base; + }) + .take(32); + + // Also fetch by category index to catch title/tag/keyword matches the search index misses + const categoryResults = args.category + ? await ctx.db + .query("ui_preset") + .withIndex("by_category", (q) => q.eq("category", args.category!)) + .collect() + : await ctx.db.query("ui_preset").collect(); + + // Merge and deduplicate + const seenIds = new Set(searchResults.map((r) => r._id.toString())); + const allPresets = [...searchResults]; + for (const r of categoryResults) { + if (!seenIds.has(r._id.toString())) { + allPresets.push(r); + } + } + + // Score using regex for accurate matching across all fields + const scoredPresets = allPresets.map((preset) => { + let score = 0; + const titleLower = preset.title.toLowerCase(); + const descriptionLower = preset.description.toLowerCase(); + const tagsLower = (preset.tags ?? []).map((t) => t.toLowerCase()); + const keywordsLower = (preset.keywords ?? []).map((k) => k.toLowerCase()); + + for (const term of searchTerms) { + const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const exactRegex = new RegExp(`\\b${escapedTerm}\\b`, "i"); + const partialRegex = new RegExp(escapedTerm, "i"); + + if (keywordsLower.some((kw) => exactRegex.test(kw))) score += 15; + else if (keywordsLower.some((kw) => partialRegex.test(kw))) score += 10; + + if (exactRegex.test(titleLower)) score += 15; + else if (partialRegex.test(titleLower)) score += 10; + + if (tagsLower.some((tag) => exactRegex.test(tag))) score += 15; + else if (tagsLower.some((tag) => partialRegex.test(tag))) score += 10; + + if (exactRegex.test(descriptionLower)) score += 8; + else if (partialRegex.test(descriptionLower)) score += 5; + } + return { preset, score }; + }); + + return scoredPresets + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, maxResults) + .map(({ preset, score }) => ({ + _id: preset._id, + title: preset.title, + description: preset.description, + category: preset.category, + tags: preset.tags, + keywords: preset.keywords, + score, + })); + }, +}); + +// Internal query to get full preset content (code + prompt) by ID +// Used as step 2 after searchUiPresetsInternal returns lightweight results +export const getUiPresetContent = internalQuery({ + args: { + presetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + const preset = await ctx.db.get(args.presetId); + if (!preset) return null; + return { + _id: preset._id, + title: preset.title, + code: preset.code, + prompt: preset.prompt, + }; + }, +}); + +// ============================================ +// Internal functions for keyword generation +// ============================================ + +// Internal query to get a preset by ID +export const getPresetById = internalQuery({ + args: { + presetId: v.id("ui_preset"), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.presetId); + }, +}); + +// Internal mutation to update preset keywords +export const updatePresetKeywords = internalMutation({ + args: { + presetId: v.id("ui_preset"), + keywords: v.array(v.string()), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.presetId, { + keywords: args.keywords, + last_updated: Date.now(), + }); + invalidateUiPresetSearchCache(); + }, +}); + +// Internal query to get all presets without keywords +export const getPresetsWithoutKeywords = internalQuery({ + args: {}, + handler: async (ctx) => { + const allPresets = await ctx.db.query("ui_preset").collect(); + return allPresets.filter((p) => !p.keywords || p.keywords.length === 0); + }, +}); diff --git a/freebuff/web/convex/users.ts b/freebuff/web/convex/users.ts new file mode 100644 index 0000000000..14741f7e61 --- /dev/null +++ b/freebuff/web/convex/users.ts @@ -0,0 +1,887 @@ +import { v } from 'convex/values' +import { internal } from './_generated/api' +import { Doc, Id } from './_generated/dataModel' +import { + ActionCtx, + internalMutation, + internalQuery, + mutation, + MutationCtx, + query, + QueryCtx, +} from './_generated/server' +import { + allUsers, + usersByRole, + usersByTier, + usersByDay, +} from './aggregates/admin_aggregates' +import { applyUserAuthMetricDelta, userAuthMetricFlags } from './admin_platform_metrics' + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +async function getProjectMembershipCount( + ctx: QueryCtx | MutationCtx, + userId: Id<'users'>, +) { + return ( + await ctx.db + .query('project_member') + .withIndex('by_user', (q) => q.eq('user', userId)) + .collect() + ).length +} + +async function resolveUserByFreebuffIdOrEmail( + ctx: QueryCtx | MutationCtx, + args: { + freebuffUserId: string + email?: string + }, +) { + const userByFreebuffId = await ctx.db + .query('users') + .withIndex('by_freebuff_user_id', (q) => + q.eq('freebuff_user_id', args.freebuffUserId), + ) + .unique() + + const emailUsers = args.email + ? await ctx.db + .query('users') + .withIndex('by_email', (q) => + q.eq('email', normalizeEmail(args.email!)), + ) + .collect() + : [] + + const candidates = [ + ...(userByFreebuffId ? [userByFreebuffId] : []), + ...emailUsers.filter((user) => user._id !== userByFreebuffId?._id), + ] + + if (candidates.length === 0) { + return { user: null, userByFreebuffId } + } + + const scoredCandidates = await Promise.all( + candidates.map(async (user) => ({ + user, + projectMembershipCount: await getProjectMembershipCount(ctx, user._id), + })), + ) + + scoredCandidates.sort((a, b) => { + if (b.projectMembershipCount !== a.projectMembershipCount) { + return b.projectMembershipCount - a.projectMembershipCount + } + + if (a.user._id === userByFreebuffId?._id) return -1 + if (b.user._id === userByFreebuffId?._id) return 1 + return b.user._creationTime - a.user._creationTime + }) + + return { user: scoredCandidates[0].user, userByFreebuffId } +} + +export const get = internalQuery({ + args: { + userId: v.id('users'), + }, + handler: async (ctx, args) => { + return await ctx.db.get(args.userId) + }, +}) + +export const upsertCodexAuthFingerprintInternal = internalMutation({ + args: { + userId: v.id('users'), + codexAuthFingerprint: v.optional(v.string()), + codexAuthEncryptedPayload: v.optional(v.string()), + codexAuthEncryptionVersion: v.optional(v.number()), + codexAuthMode: v.optional(v.string()), + codexAuthLastRefresh: v.optional(v.string()), + codexAuthUpdatedAt: v.number(), + codexOauthRevoked: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const before = await ctx.db.get(args.userId) + await ctx.db.patch(args.userId, { + codex_auth_fingerprint: args.codexAuthFingerprint, + codex_auth_encrypted_payload: args.codexAuthEncryptedPayload, + codex_auth_encryption_version: args.codexAuthEncryptionVersion, + codex_auth_mode: args.codexAuthMode, + codex_auth_last_refresh: args.codexAuthLastRefresh, + codex_auth_updated_at: args.codexAuthUpdatedAt, + ...(args.codexOauthRevoked !== undefined + ? { codex_oauth_revoked: args.codexOauthRevoked } + : {}), + }) + const after = await ctx.db.get(args.userId) + if (before && after) { + await applyUserAuthMetricDelta(ctx, before, after) + // Record a feature-usage event the first time a user transitions into a + // connected ChatGPT-OAuth state (not on every token refresh). + const wasConnected = userAuthMetricFlags(before).chatgptSubscriptionConnected + const nowConnected = userAuthMetricFlags(after).chatgptSubscriptionConnected + if (!wasConnected && nowConnected) { + await ctx.scheduler.runAfter( + 0, + internal.cloud_feature_usage.recordCloudFeatureUsage, + { userId: args.userId, feature: 'chatgpt_oauth' }, + ) + } + } + }, +}) + +// Per-credential field names used for storing encrypted BYOK secrets. +// Driven by `kind` so we don't need a setter per credential. +const BYOK_FIELDS = { + openai: { + encrypted: 'gpt_openai_api_key_encrypted', + version: 'gpt_openai_api_key_encryption_version', + updatedAt: 'gpt_openai_api_key_updated_at', + }, + anthropic: { + encrypted: 'claude_anthropic_api_key_encrypted', + version: 'claude_anthropic_api_key_encryption_version', + updatedAt: 'claude_anthropic_api_key_updated_at', + }, + bedrock: { + encrypted: 'claude_bedrock_bearer_token_encrypted', + version: 'claude_bedrock_bearer_token_encryption_version', + updatedAt: 'claude_bedrock_bearer_token_updated_at', + }, +} as const + +export const patchByokSecretInternal = internalMutation({ + args: { + userId: v.id('users'), + kind: v.union( + v.literal('openai'), + v.literal('anthropic'), + v.literal('bedrock'), + ), + encrypted: v.string(), + version: v.number(), + }, + handler: async (ctx, args) => { + const before = await ctx.db.get(args.userId) + const f = BYOK_FIELDS[args.kind] + await ctx.db.patch(args.userId, { + [f.encrypted]: args.encrypted, + [f.version]: args.version, + [f.updatedAt]: Date.now(), + } as any) + const after = await ctx.db.get(args.userId) + if (before && after) { + await applyUserAuthMetricDelta(ctx, before, after) + } + }, +}) + +export const setCodexOauthRevokedInternal = internalMutation({ + args: { + userId: v.id('users'), + revoked: v.boolean(), + }, + handler: async (ctx, args) => { + const before = await ctx.db.get(args.userId) + await ctx.db.patch(args.userId, { + codex_oauth_revoked: args.revoked, + }) + const after = await ctx.db.get(args.userId) + if (before && after) { + await applyUserAuthMetricDelta(ctx, before, after) + } + }, +}) + +export const getCliByokSettings = query({ + args: {}, + returns: v.union( + v.object({ + gptAuthMethod: v.union(v.literal('oauth'), v.literal('byok')), + hasCodexOauth: v.boolean(), + gptModelPreference: v.string(), + hasOpenAiApiKey: v.boolean(), + openAiApiKeyUpdatedAt: v.optional(v.number()), + claudeProviderPreference: v.union( + v.literal('anthropic'), + v.literal('bedrock'), + ), + claudeModelPreference: v.string(), + hasAnthropicApiKey: v.boolean(), + anthropicApiKeyUpdatedAt: v.optional(v.number()), + hasBedrockBearerToken: v.boolean(), + bedrockBearerTokenUpdatedAt: v.optional(v.number()), + }), + v.null(), + ), + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + return null + } + + return { + gptAuthMethod: user.gpt_auth_method ?? 'oauth', + hasCodexOauth: + user.codex_auth_mode === 'chatgpt' && user.codex_oauth_revoked !== true, + gptModelPreference: user.gpt_model_preference ?? 'default', + hasOpenAiApiKey: !!user.gpt_openai_api_key_encrypted, + openAiApiKeyUpdatedAt: user.gpt_openai_api_key_updated_at, + claudeProviderPreference: user.claude_provider_preference ?? 'bedrock', + claudeModelPreference: user.claude_model_preference ?? 'default', + hasAnthropicApiKey: !!user.claude_anthropic_api_key_encrypted, + anthropicApiKeyUpdatedAt: user.claude_anthropic_api_key_updated_at, + hasBedrockBearerToken: !!user.claude_bedrock_bearer_token_encrypted, + bedrockBearerTokenUpdatedAt: user.claude_bedrock_bearer_token_updated_at, + } + }, +}) + +const GPT_MODEL_PREFERENCES = ['default', 'gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini'] + +const CLAUDE_MODEL_PREFERENCES = [ + 'default', + 'claude-opus-4-8', + 'claude-sonnet-4-6', + 'claude-haiku-4-5', + 'us.anthropic.claude-opus-4-8', + 'us.anthropic.claude-sonnet-4-6', + 'us.anthropic.claude-haiku-4-5-20251001-v1:0', +] + +const CLI_PREFERENCE_VALUES = { + gpt_auth_method: ['oauth', 'byok'], + claude_provider_preference: ['anthropic', 'bedrock'], + gpt_model_preference: GPT_MODEL_PREFERENCES, + claude_model_preference: CLAUDE_MODEL_PREFERENCES, +} as const + +export const setCliPreference = mutation({ + args: { + key: v.union( + v.literal('gpt_auth_method'), + v.literal('claude_provider_preference'), + v.literal('gpt_model_preference'), + v.literal('claude_model_preference'), + ), + value: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + const allowedValues = CLI_PREFERENCE_VALUES[args.key] + if (!(allowedValues as readonly string[]).includes(args.value)) { + throw new Error(`Invalid value for ${args.key}`) + } + const before = user + await ctx.db.patch(user._id, { [args.key]: args.value } as any) + const after = await ctx.db.get(user._id) + if (after && args.key === 'gpt_auth_method') { + await applyUserAuthMetricDelta(ctx, before, after) + } + return { success: true } + }, +}) + +export const clearCodexOauthAuth = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + + const before = user + await ctx.db.patch(user._id, { + codex_auth_fingerprint: undefined, + codex_auth_encrypted_payload: undefined, + codex_auth_encryption_version: undefined, + codex_auth_mode: undefined, + codex_auth_last_refresh: undefined, + codex_auth_updated_at: undefined, + codex_oauth_revoked: true, + }) + const after = await ctx.db.get(user._id) + if (after) { + await applyUserAuthMetricDelta(ctx, before, after) + } + + return { success: true } + }, +}) + +export const clearCliByokCredential = mutation({ + args: { + credential: v.union( + v.literal('openai'), + v.literal('anthropic'), + v.literal('bedrock'), + ), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + + const before = user + + if (args.credential === 'openai') { + await ctx.db.patch(user._id, { + gpt_openai_api_key_encrypted: undefined, + gpt_openai_api_key_encryption_version: undefined, + gpt_openai_api_key_updated_at: undefined, + }) + } else if (args.credential === 'anthropic') { + await ctx.db.patch(user._id, { + claude_anthropic_api_key_encrypted: undefined, + claude_anthropic_api_key_encryption_version: undefined, + claude_anthropic_api_key_updated_at: undefined, + }) + } else { + await ctx.db.patch(user._id, { + claude_bedrock_bearer_token_encrypted: undefined, + claude_bedrock_bearer_token_encryption_version: undefined, + claude_bedrock_bearer_token_updated_at: undefined, + }) + } + + const after = await ctx.db.get(user._id) + if (after) { + await applyUserAuthMetricDelta(ctx, before, after) + } + + return { success: true } + }, +}) + +export const getOrCreateSignedInUser = mutation({ + args: { + referralCode: v.optional(v.string()), + }, + handler: async (ctx, args) => { + return await signedInUser(ctx, args.referralCode) + }, +}) + +export const signedInUser = async (ctx: MutationCtx, referralCode?: string) => { + const identity = await ctx.auth.getUserIdentity() + if (!identity) { + throw new Error('Called storeUser without authentication present') + } + + const email = identity.email + if (!email) { + throw new Error('User has no email') + } + const normalizedEmail = normalizeEmail(email) + const freebuffUserId = identity.subject + + // Web referral score claim minted by the convex-token route from the + // shared Postgres referral ledger. Synced onto the user doc so rate limits + // and perks (which may look up users other than the caller, e.g. project + // owners) can read it without auth context. + const claims = identity as Record<string, unknown> + const webReferralScore = + typeof claims.web_referral_score === 'number' + ? claims.web_referral_score + : undefined + + const { user, userByFreebuffId } = await resolveUserByFreebuffIdOrEmail(ctx, { + freebuffUserId, + email: normalizedEmail, + }) + + if (user !== null) { + if (userByFreebuffId && userByFreebuffId._id !== user._id) { + await ctx.db.patch(userByFreebuffId._id, { + freebuff_user_id: undefined, + }) + } + + const patchData: { + name?: string + email?: string + profile_image?: string + freebuff_user_id?: string + qualified_referral_count?: number + } = {} + + if (user.freebuff_user_id !== freebuffUserId) { + patchData.freebuff_user_id = freebuffUserId + } + + if (webReferralScore !== undefined) { + patchData.qualified_referral_count = webReferralScore + } + + // If we've seen this identity before but the name has changed, patch the value. + if (typeof identity.name === 'string' && user.name !== identity.name) { + patchData.name = identity.name + } + + if ( + typeof identity.pictureUrl === 'string' && + user.profile_image !== identity.pictureUrl + ) { + patchData.profile_image = identity.pictureUrl + } + + if (user.email !== normalizedEmail) { + const existingUserWithEmail = await ctx.db + .query('users') + .withIndex('by_email', (q) => q.eq('email', normalizedEmail)) + .first() + + if (existingUserWithEmail && existingUserWithEmail._id !== user._id) { + throw new Error('An account with this email already exists') + } + + patchData.email = normalizedEmail + } + + if (Object.keys(patchData).length > 0) { + await ctx.db.patch(user._id, patchData) + } + + return user._id + } + + // Check if referral code is valid + let validReferralCode: string | undefined + let referrerUserId: Id<'users'> | undefined + if (referralCode) { + const referralRecord = await ctx.db + .query('referral_codes') + .withIndex('by_code', (q) => q.eq('code', referralCode)) + .filter((q) => q.eq(q.field('active'), true)) + .unique() + + if (referralRecord) { + // Update the uses count + await ctx.db.patch(referralRecord._id, { + uses_count: referralRecord.uses_count + 1, + }) + validReferralCode = referralCode + referrerUserId = referralRecord.owner + } + } + + // If it's a new identity, create a new `User`. The web referral ledger + // itself lives in Postgres (shared with the CLI program); the score claim + // is just denormalized here for tier-scaled limits and perks. + const newUserId = await ctx.db.insert('users', { + name: identity.name ?? '<Anonymous>', + clerk_id: freebuffUserId, + freebuff_user_id: freebuffUserId, + profile_image: identity.pictureUrl, + email: normalizedEmail, + referral_code: validReferralCode, + qualified_referral_count: webReferralScore, + }) + + // Update aggregates for new user + const newUser = await ctx.db.get(newUserId) + if (newUser) { + await allUsers.insert(ctx, newUser) + await usersByRole.insert(ctx, newUser) + await usersByTier.insert(ctx, newUser) + await usersByDay.insert(ctx, newUser) + } + + // Every new user gets one free spin. + await ctx.scheduler.runAfter(0, internal.earn.internalGrantWelcomeSpin, { + userId: newUserId, + }) + + await ctx.scheduler.runAfter(0, internal.email.sendWelcomeEmailInternal, { + userId: newUserId, + }) + + // Grant a referral spin to the referrer if a valid referral code was used. + if (validReferralCode && referrerUserId) { + await ctx.scheduler.runAfter(0, internal.earn.internalGrantReferralSpin, { + userId: referrerUserId, + referredUserId: newUserId, + referralCode: validReferralCode, + }) + } + + //return await ctx.db.get(newUserId); + return newUserId +} + +// Internal cacheable version - accepts Freebuff user id to enable caching +export const viewerInternal = internalQuery({ + args: { + freebuffUserId: v.string(), + email: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { user } = await resolveUserByFreebuffIdOrEmail(ctx, args) + return user + }, +}) + +export const viewer = query({ + handler: async (ctx): Promise<Doc<'users'> | null> => { + // Get JWT identity once + const identity = await ctx.auth.getUserIdentity() + if (!identity) { + return null + } + + // Delegate to internal cached version, falling back to email for legacy + // Clerk-era users until the next mutation patches freebuff_user_id. + const user = await ctx.runQuery(internal.users.viewerInternal, { + freebuffUserId: identity.subject, + email: identity.email, + }) + if (!user) { + return null + } + + const qualifiedReferralCount = getQualifiedReferralCount(identity, user) + if (qualifiedReferralCount === user.qualified_referral_count) { + return user + } + + return { ...user, qualified_referral_count: qualifiedReferralCount } + }, +}) + +/** Keep users.qualified_referral_count in sync with the JWT claim. */ +export const syncQualifiedReferralCount = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + await syncQualifiedReferralCountFromJwt(ctx, user) + return { success: true } + }, +}) + +export const getUserByFreebuffUserId = internalQuery({ + args: { + freebuffUserId: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query('users') + .withIndex('by_freebuff_user_id', (q) => + q.eq('freebuff_user_id', args.freebuffUserId), + ) + .unique() + }, +}) + +export const getUserByFreebuffUserIdOrEmail = internalQuery({ + args: { + freebuffUserId: v.string(), + email: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { user } = await resolveUserByFreebuffIdOrEmail(ctx, args) + return user + }, +}) + +export const getUserByClerkId = internalQuery({ + args: { + clerkId: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query('users') + .withIndex('by_clerk_id', (q) => q.eq('clerk_id', args.clerkId)) + .unique() + }, +}) + +export const getUserByEmail = internalQuery({ + args: { + email: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query('users') + .withIndex('by_email', (q) => q.eq('email', normalizeEmail(args.email))) + .unique() + }, +}) + +/** Qualified web referral count minted on the Convex JWT by convex-token. */ +export function getWebReferralScoreFromIdentity( + identity: { [key: string]: unknown } | null, +): number | undefined { + if (!identity) return undefined + const score = identity.web_referral_score + return typeof score === 'number' ? score : undefined +} + +/** Prefer the fresh JWT claim; fall back to the denormalized users row. */ +export function getQualifiedReferralCount( + identity: { [key: string]: unknown } | null, + user: Doc<'users'> | null, +): number { + const fromJwt = getWebReferralScoreFromIdentity(identity) + if (fromJwt !== undefined) return fromJwt + return user?.qualified_referral_count ?? 0 +} + +export async function syncQualifiedReferralCountFromJwt( + ctx: MutationCtx, + user: Doc<'users'>, +): Promise<void> { + const identity = await ctx.auth.getUserIdentity() + const score = getWebReferralScoreFromIdentity(identity) + if (score === undefined || user.qualified_referral_count === score) { + return + } + await ctx.db.patch(user._id, { qualified_referral_count: score }) +} + +/** + * Get the user from the database and require authentication + * @param ctx + * @returns + */ +export async function getAuthUser( + ctx: QueryCtx | MutationCtx | ActionCtx, +): Promise<Doc<'users'> | null> { + const identity = await ctx.auth.getUserIdentity() + + if (!identity) { + return null + } + + const user: Doc<'users'> | null = await ctx.runQuery( + internal.users.getUserByFreebuffUserIdOrEmail, + { + freebuffUserId: identity.subject, + email: identity.email, + }, + ) + + return user +} + +// Mutation to set onboarding_completed to true for the current user +export const setOnboardingCompleted = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + await ctx.db.patch(user._id, { onboarding_completed: true }) + return true + }, +}) + +// Mutation to record interest in hiring developers +export const recordHiringInterest = mutation({ + args: {}, + handler: async (ctx) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + await ctx.db.patch(user._id, { interested_in_hiring: true }) + return true + }, +}) + +// Mutation to submit hiring interest form +export const submitHiringInterestForm = mutation({ + args: { + companyName: v.string(), + whatBuilding: v.string(), + budget: v.string(), + phoneNumber: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + await ctx.db.insert('hiring_interest_forms', { + userId: user._id, + formType: 'hiring', + companyName: args.companyName, + whatBuilding: args.whatBuilding, + budget: args.budget, + phoneNumber: args.phoneNumber, + submittedAt: Date.now(), + }) + return true + }, +}) + +// Mutation to submit developer application form +export const submitDeveloperApplicationForm = mutation({ + args: { + name: v.string(), + linkedin: v.string(), + github: v.string(), + pitch: v.string(), + phoneNumber: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + + // Check if form already exists + const existingForm = await ctx.db + .query('hiring_interest_forms') + .withIndex('by_user_and_form_type', (q) => + q.eq('userId', user._id).eq('formType', 'developer_application'), + ) + .first() + + if (existingForm) { + // Update existing form + await ctx.db.patch(existingForm._id, { + name: args.name, + linkedin: args.linkedin, + github: args.github, + pitch: args.pitch, + phoneNumber: args.phoneNumber, + submittedAt: Date.now(), + }) + } else { + // Insert new form + await ctx.db.insert('hiring_interest_forms', { + userId: user._id, + formType: 'developer_application', + name: args.name, + linkedin: args.linkedin, + github: args.github, + pitch: args.pitch, + phoneNumber: args.phoneNumber, + submittedAt: Date.now(), + }) + } + return true + }, +}) + +// Mutation to submit enterprise interest form +export const submitEnterpriseInterestForm = mutation({ + args: { + companyName: v.string(), + whatBuilding: v.string(), + budget: v.string(), + phoneNumber: v.string(), + }, + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + throw new Error('Not authenticated') + } + + // Check if form already exists + const existingForm = await ctx.db + .query('hiring_interest_forms') + .withIndex('by_user_and_form_type', (q) => + q.eq('userId', user._id).eq('formType', 'enterprise'), + ) + .first() + + if (existingForm) { + // Update existing form + await ctx.db.patch(existingForm._id, { + companyName: args.companyName, + whatBuilding: args.whatBuilding, + budget: args.budget, + phoneNumber: args.phoneNumber, + submittedAt: Date.now(), + }) + } else { + // Insert new form + await ctx.db.insert('hiring_interest_forms', { + userId: user._id, + formType: 'enterprise', + companyName: args.companyName, + whatBuilding: args.whatBuilding, + budget: args.budget, + phoneNumber: args.phoneNumber, + submittedAt: Date.now(), + }) + } + return true + }, +}) + +/** + * Get a form by type for the current authenticated user + */ +export const getFormByType = query({ + args: { + formType: v.union( + v.literal('hiring'), + v.literal('developer_application'), + v.literal('enterprise'), + ), + }, + returns: v.union( + v.object({ + _id: v.id('hiring_interest_forms'), + _creationTime: v.number(), + userId: v.id('users'), + formType: v.union( + v.literal('hiring'), + v.literal('developer_application'), + v.literal('enterprise'), + ), + companyName: v.optional(v.string()), + whatBuilding: v.optional(v.string()), + budget: v.optional(v.string()), + phoneNumber: v.optional(v.string()), + name: v.optional(v.string()), + linkedin: v.optional(v.string()), + github: v.optional(v.string()), + pitch: v.optional(v.string()), + submittedAt: v.number(), + }), + v.null(), + ), + handler: async (ctx, args) => { + const user = await getAuthUser(ctx) + if (!user) { + return null + } + + const form = await ctx.db + .query('hiring_interest_forms') + .withIndex('by_user_and_form_type', (q) => + q.eq('userId', user._id).eq('formType', args.formType), + ) + .first() + + return form ?? null + }, +}) + +// Returns the total number of users (using aggregates for real-time counting) +export const getUserCount = query({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + // Use aggregates for efficient, real-time counting + return await allUsers.count(ctx, { bounds: {} }) + }, +}) diff --git a/freebuff/web/convex/users_byok.ts b/freebuff/web/convex/users_byok.ts new file mode 100644 index 0000000000..c7e5a3b62f --- /dev/null +++ b/freebuff/web/convex/users_byok.ts @@ -0,0 +1,56 @@ +"use node"; + +import { v } from "convex/values"; +import { action } from "./_generated/server"; +import { internal } from "./_generated/api"; +import { getAuthUser } from "./users"; +import { + encryptByokSecret, + getByokEncryptionSecret, +} from "./coding_agent/cli_agent/byokAuth"; + +const KIND = v.union( + v.literal("openai"), + v.literal("anthropic"), + v.literal("bedrock"), +); + +export const saveByokSecret = action({ + args: { kind: KIND, secret: v.string() }, + returns: v.object({ success: v.boolean() }), + handler: async (ctx, args) => { + const secret = args.secret.trim(); + if (!secret) throw new Error(`${args.kind} secret is required`); + + const encryptionSecret = getByokEncryptionSecret(); + if (!encryptionSecret) + throw new Error("BYOK encryption secret is not configured"); + + const encrypted = encryptByokSecret(secret, encryptionSecret); + if (!encrypted) throw new Error(`Failed to encrypt ${args.kind} secret`); + + const user = await getAuthUser(ctx); + if (!user) throw new Error("Not authenticated"); + + await ctx.runMutation(internal.users.patchByokSecretInternal, { + userId: user._id, + kind: args.kind, + encrypted: encrypted.encryptedPayload, + version: encrypted.encryptionVersion, + }); + + const byokFeature = + args.kind === "openai" + ? "openai_byok" + : args.kind === "anthropic" + ? "anthropic_byok" + : "bedrock_byok"; + await ctx.scheduler.runAfter( + 0, + internal.cloud_feature_usage.recordCloudFeatureUsage, + { userId: user._id, feature: byokFeature }, + ); + + return { success: true }; + }, +}); diff --git a/freebuff/web/convex/utils/context7.ts b/freebuff/web/convex/utils/context7.ts new file mode 100644 index 0000000000..f3aa1c3a90 --- /dev/null +++ b/freebuff/web/convex/utils/context7.ts @@ -0,0 +1,91 @@ +// Context7 API helper functions for fetching library documentation + +/** + * Search Context7 API for libraries and services + * @param query Search query to find relevant libraries + * @returns Search results with library information + */ +export async function searchContext7Libraries(query: string) { + try { + const url = new URL("https://context7.com/api/v1/search"); + url.searchParams.set("query", query); + + const response = await fetch(url); + if (!response.ok) { + const errorCode = response.status; + if (errorCode === 429) { + console.error(`Context7 rate limited. Please try again later.`); + return { + results: [], + error: `Context7 rate limited. Please try again later.`, + }; + } + console.error( + `Failed to search Context7 libraries. Error code: ${errorCode}`, + ); + return { + results: [], + error: `Failed to search Context7 libraries. Error code: ${errorCode}`, + }; + } + + return await response.json(); + } catch (error) { + console.error("Error searching Context7 libraries:", error); + return { + results: [], + error: `Error searching Context7 libraries: ${error}`, + }; + } +} + +/** + * Fetch detailed documentation for a specific library from Context7 + * @param libraryId Library ID from Context7 (e.g., "/twilio/twilio-node") + * @param options Optional parameters for tokens and topic + * @returns Documentation text or null if not available + * @throws Error if API request fails + */ +export async function fetchContext7Documentation( + libraryId: string, + options: { + tokens?: number; + topic?: string; + } = {}, +) { + if (libraryId.startsWith("/")) { + libraryId = libraryId.slice(1); + } + + const url = new URL(`https://context7.com/api/v1/${libraryId}`); + if (options.tokens) url.searchParams.set("tokens", options.tokens.toString()); + if (options.topic) url.searchParams.set("topic", options.topic); + url.searchParams.set("type", "txt"); + + const response = await fetch(url, { + headers: { + "X-Context7-Source": "mcp-server", + }, + }); + + if (!response.ok) { + const errorCode = response.status; + if (errorCode === 429) { + throw new Error(`Context7 rate limited. Please try again later.`); + } + throw new Error( + `Failed to fetch Context7 documentation. Error code: ${errorCode}`, + ); + } + + const text = await response.text(); + if ( + !text || + text === "No content available" || + text === "No context data available" + ) { + return null; + } + + return text; +} diff --git a/freebuff/web/convex/utils/cross_rank.ts b/freebuff/web/convex/utils/cross_rank.ts new file mode 100644 index 0000000000..dec992702a --- /dev/null +++ b/freebuff/web/convex/utils/cross_rank.ts @@ -0,0 +1,58 @@ +"use node"; + +/** + * Interface for file data to be ranked + */ +export interface FileData { + filename: string; + code: string; +} + +/** + * Ranks files based on relevance to a query using the ranker API + * @param query The search query to rank files against + * @param files Array of file data objects containing filename and code + * @param tokenLimit Optional token limit for the API request + * @param threshold Optional threshold for the score of the files to be returned: https://docs.relace.ai/docs/code-reranker/overview + * @returns Promise resolving to an array of filenames sorted by relevance + */ +export async function crossRank( + query: string, + files: FileData[], + tokenLimit: number = 12000, + threshold: number = 0.1, +): Promise<string[]> { + const url = "https://ranker.endpoint.relace.run/v2/code/rank"; + const headers = { + "Content-Type": "application/json", + Authorization: "Bearer rlc-6C1yrgfI5loSt9VgXNvLRop0pDm7TTvsmpASyA", + }; + + const data = { + query, + codebase: files, + token_limit: tokenLimit, + }; + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(data), + }); + + const result = await response.json(); + const rankedFiles = result.results as { filename: string; score: number }[]; + + // filter out files with score below threshold + // sort with least important first + const filteredFiles = rankedFiles + .filter((file) => file.score > threshold) + .sort((a, b) => a.score - b.score); + + // return only file paths, decending order + return filteredFiles.map((file) => file.filename); + } catch (error) { + throw new Error(`Ranking API call failed: ${error}`); + } +} diff --git a/freebuff/web/convex/utils/crypto.ts b/freebuff/web/convex/utils/crypto.ts new file mode 100644 index 0000000000..00bfda1c48 --- /dev/null +++ b/freebuff/web/convex/utils/crypto.ts @@ -0,0 +1,73 @@ +"use node"; +import { v } from "convex/values"; +import crypto from "crypto"; +import { action } from "../_generated/server"; + +if (typeof globalThis.crypto === "undefined") { + globalThis.crypto = crypto as any; +} + +/** + * Generate a cryptographically secure random string + */ +export const generateSecureState = action({ + args: {}, + returns: v.string(), + handler: async () => { + return crypto.randomBytes(32).toString("hex"); + }, +}); + +/** + * Verify GitHub webhook HMAC signature + */ +export const verifyGitHubSignature = action({ + args: { + payload: v.string(), + signature: v.string(), + secret: v.string(), + }, + returns: v.boolean(), + handler: async (_, args) => { + const { payload, signature, secret } = args; + + console.log("GitHub signature verification started", { + payloadLength: payload.length, + signaturePrefix: signature.substring(0, 12) + "...", + hasSecret: !!secret, + }); + + if (!signature.startsWith("sha256=")) { + console.log("Invalid signature format - missing sha256= prefix"); + return false; + } + + const expectedSignature = crypto + .createHmac("sha256", secret) + .update(payload, "utf8") + .digest("hex"); + + const receivedSignature = signature.replace("sha256=", ""); + + // Use crypto.timingSafeEqual for constant-time comparison + if ( + Buffer.byteLength(expectedSignature, "hex") !== + Buffer.byteLength(receivedSignature, "hex") + ) { + console.log("Signature length mismatch"); + return false; + } + const isValid = crypto.timingSafeEqual( + Buffer.from(expectedSignature, "hex"), + Buffer.from(receivedSignature, "hex"), + ); + + console.log("GitHub signature verification completed", { + isValid, + expectedPrefix: expectedSignature.substring(0, 8) + "...", + receivedPrefix: receivedSignature.substring(0, 8) + "...", + }); + + return isValid; + }, +}); diff --git a/freebuff/web/convex/utils/divmagic.ts b/freebuff/web/convex/utils/divmagic.ts new file mode 100644 index 0000000000..97f8615298 --- /dev/null +++ b/freebuff/web/convex/utils/divmagic.ts @@ -0,0 +1,98 @@ +import { internal } from "!/_generated/api"; +import { Id } from "!/_generated/dataModel"; +import { ActionCtx } from "!/_generated/server"; + +/** + * Scrape a website using DivMagic API and store the results + * + * @param ctx - The action context from the parent function + * @param url - The URL to scrape + * @param projectId - The project ID to associate the scraped content with + * @returns The cleaned content from the scrape + */ +export async function scrapeWebsite( + ctx: ActionCtx, + args: { + url: string; + projectId: Id<"project">; + }, +): Promise<string> { + const { url, projectId } = args; + + // Get DivMagic API key from environment variables + const apiKey = process.env.DIVMAGIC_API_KEY; + if (!apiKey) { + throw new Error("DIVMAGIC_API_KEY is not set in environment variables"); + } + + // Call DivMagic API + try { + const response = await fetch("https://api.divmagic.com/v1/magic", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: apiKey, // Using the apiKey from environment variables + url: url, + settings: { + componentFormat: "jsx", + styleFormat: "tailwind", + includeMediaQuery: true, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`DivMagic API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + + // Extract the content - DivMagic API may return data in different formats + const rawContent = + data.component || data.content || data.html || data.result || ""; + + // Check if content is too large (exceeds 80,000 characters) + if (rawContent.length > 80000) { + return "Website is too big. Coming soon: cleaning up large HTML styling."; + } + + // Clean up the content + const cleanedContent = cleanContent(rawContent); + + // Store in database directly + await ctx.runMutation(internal.scraped_site_logs.create, { + url, + raw_content: rawContent, + cleaned_content: cleanedContent, + date: Date.now(), + project: projectId, + }); + + return rawContent; + } catch (error) { + console.error("Error scraping website:", error); + throw error; + } +} + +/** + * Clean up the content from DivMagic + */ +function cleanContent(content: string): string { + // Remove excessive whitespace + let cleaned = content.replace(/\s+/g, " "); + + // Normalize line breaks + cleaned = cleaned.replace(/\n{3,}/g, "\n\n"); + + // Remove any HTML tags that might remain + cleaned = cleaned.replace(/<[^>]*>/g, ""); + + // Trim extra whitespace + cleaned = cleaned.trim(); + + return cleaned; +} diff --git a/freebuff/web/convex/utils/fast_apply.ts b/freebuff/web/convex/utils/fast_apply.ts new file mode 100644 index 0000000000..6c37f14636 --- /dev/null +++ b/freebuff/web/convex/utils/fast_apply.ts @@ -0,0 +1,64 @@ +"use node"; + +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { generateText } from "ai"; +import axios from "axios"; +/** + * Applies semantic edits given the edit block and the original code + * @param editBlock + * @param originalCode + * @param filePath + * @param proejctId + */ +export async function fastApply( + editBlock: string, + originalCode: string, + //filePath?: string, +): Promise<string> { + try { + const response = await axios.post( + "https://instantapply.endpoint.relace.run/v1/code/apply", + { + initialCode: originalCode, + editSnippet: editBlock, + stream: false, + }, + { + headers: { + "Content-Type": "application/json", + Authorization: "Bearer rlc-6C1yrgfI5loSt9VgXNvLRop0pDm7TTvsmpASyA", + }, + }, + ); + + const { mergedCode } = response.data; + + return mergedCode as string; + } catch (error) { + throw new Error(`API request failed: ${error}`); + } +} + +export async function morphFastApply( + editBlock: string, + originalCode: string, + //filePath?: string, +): Promise<string> { + const morph = createOpenAICompatible({ + apiKey: "sk-yuAK3dJlNdPFbd9Q_4rupsX9h6DGt0kvJ3qHv7JRPHkyKKbq", + baseURL: "https://api.morphllm.com/v1", + name: "morph", + }); + + const response = await generateText({ + model: morph("morph-v3-large"), + messages: [ + { + role: "user", + content: `<code>${originalCode}</code>\n<update>${editBlock}</update>`, + }, + ], + }); + + return response.text; +} diff --git a/freebuff/web/convex/utils/firecrawl_scrape.ts b/freebuff/web/convex/utils/firecrawl_scrape.ts new file mode 100644 index 0000000000..fa9d8834b9 --- /dev/null +++ b/freebuff/web/convex/utils/firecrawl_scrape.ts @@ -0,0 +1,113 @@ +"use node"; +import FirecrawlApp from "@mendable/firecrawl-js"; + +async function extractWithFirecrawl(url: string): Promise<string> { + try { + console.log(`Starting extraction for URL: ${url}`); + + // Check API key early + if (!process.env.FIRECRAWL_API_KEY) { + throw new Error( + "Firecrawl API key is missing. Please set the FIRECRAWL_API_KEY environment variable.", + ); + } + + // Initialize Firecrawl with your API key + console.log("Initializing FirecrawlApp..."); + const app = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY }); + + // Set up parameters for the extraction + const params: any = { + formats: ["markdown"], + onlyMainContent: true, + }; + + console.log("Params for scrapeUrl:", JSON.stringify(params, null, 2)); + + // Perform the extraction + console.log("Calling scrapeUrl..."); + const result = await app.scrapeUrl(url, params); + + // Log the result for debugging + console.log("Firecrawl result:", JSON.stringify(result, null, 2)); + + // Check if result indicates an error + if ("error" in result && result.error) { + throw new Error(`Firecrawl error: ${result.error}`); + } + + // Check if success is explicitly false + if ("success" in result && result.success === false) { + throw new Error("Firecrawl error: The request was not successful"); + } + + // Extract markdown content + const extractedContent = result.markdown || ""; + + console.log( + `Extraction complete. Content length: ${extractedContent.length} characters`, + ); + return extractedContent; + } catch (error) { + console.error("Error extracting data with Firecrawl:", error); + + // Check if API key is missing + if (!process.env.FIRECRAWL_API_KEY) { + throw new Error( + "Firecrawl API key is missing. Please set the FIRECRAWL_API_KEY environment variable.", + ); + } + + throw error; + } +} + +async function crawlUrlLinks(url: string): Promise<string[]> { + try { + console.log(`Starting mapping for URL: ${url}`); + + // Check API key early + if (!process.env.FIRECRAWL_API_KEY) { + throw new Error( + "Firecrawl API key is missing. Please set the FIRECRAWL_API_KEY environment variable.", + ); + } + + // Initialize Firecrawl with your API key + const app = new FirecrawlApp({ apiKey: process.env.FIRECRAWL_API_KEY }); + + // Call crawlUrl with the simplified parameter structure + const crawlResponse = await app.crawlUrl(url, { + limit: 1, // Just crawl the main URL + scrapeOptions: { + formats: ["links"], + onlyMainContent: true, + }, + }); + + if (!crawlResponse.success) { + throw new Error( + `Failed to crawl: ${crawlResponse.error || "Unknown error"}`, + ); + } + + // Extract links directly from the response + let links: string[] = []; + if (crawlResponse.data && Array.isArray(crawlResponse.data)) { + crawlResponse.data.forEach((page) => { + if (page.links && Array.isArray(page.links)) { + links = links.concat(page.links); + } + }); + } + + console.log(`Mapping complete. Found ${links.length} links.`); + return links; + } catch (error) { + console.error("Error mapping URL with Firecrawl:", error); + throw error; + } +} + +// Export all functions +export { crawlUrlLinks, extractWithFirecrawl }; diff --git a/freebuff/web/convex/utils/gemini_search.ts b/freebuff/web/convex/utils/gemini_search.ts new file mode 100644 index 0000000000..5114e7792c --- /dev/null +++ b/freebuff/web/convex/utils/gemini_search.ts @@ -0,0 +1,53 @@ +"use node"; + +import { GoogleGenAI } from "@google/genai"; +import { generateText, stepCountIs } from "ai"; +import { MODELS } from "./registry"; +import { google } from "@ai-sdk/google"; + +export const geminiSearch = async (query: string) => { + const ai = new GoogleGenAI({ + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY, + }); + + // const response = await ai.models.generateContent({ + // model: "gemini-2.5-flash", + // contents: [ + // `You are a technical researcher that can search the web for information and extract relevant documentation, code examples, code snippets, etc. + // You are being queried by a coding agent in need of information relevant to writing code. + // Focus on relevant documentation that can solve their problem. + // For context, the codebase they are working with is a Typescript, React, Tailwind CSS frontend running a Node js Convex backend. + // Do not ever mention these tech stacks but stay relevant to it, ie search for typescript documentation and for convex related integrations. + // Pull additional from information from any relevant URLs. + // Immediately return the results + // `, + // `Query: ${query}`, + // ], + // config: { + // tools: [{ urlContext: {} } as any, { googleSearch: {} }], + // thinkingConfig: { + // thinkingBudget: 1024, + // } as any, + // }, + // }); + + const { text, sources, providerMetadata } = await generateText({ + model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + system: `You are a technical researcher that can search the web for information and extract relevant documentation, code examples, code snippets, etc. + You are being queried by a coding agent in need of information relevant to writing code. + Focus on relevant documentation that can solve their problem. + For context, the codebase they are working with is a Typescript, React, Tailwind CSS frontend running a Node js Convex backend. + Do not ever mention these tech stacks but stay relevant to it, ie search for typescript documentation and for convex related integrations. + Pull additional from information from any relevant URLs. + Immediately return the results. Keep on searching until you have found all the information you need, up to 4 times.`, + tools: { + google_search: google.tools.googleSearch({}) as any, + }, + prompt: "Here is the query to search for: " + query, + stopWhen: stepCountIs(5), + }); + + return text; +}; + +export const deepResearch = async (query: string) => {}; diff --git a/freebuff/web/convex/utils/haiku_search.ts b/freebuff/web/convex/utils/haiku_search.ts new file mode 100644 index 0000000000..cb71f45c73 --- /dev/null +++ b/freebuff/web/convex/utils/haiku_search.ts @@ -0,0 +1,57 @@ +"use node"; + +import { generateText, stepCountIs, tool } from "ai"; +import { MODELS } from "./registry"; +import { anthropic } from "@ai-sdk/anthropic"; +import { z } from "zod"; +import { ActionCtx } from "../_generated/server"; + +export const haikuSearch = async ( + ctx: ActionCtx, + query: string, + deepResearch: boolean = false, +) => { + const webSearchTool = anthropic.tools.webSearch_20250305({ + maxUses: 25, + }); + // have the AI choose from the existing integrations which one to add based on reference id + const response = await generateText({ + model: MODELS.PRIMARY_MODELS.GEMINI_2_5_FLASH, + providerOptions: { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: 4000, + }, + }, + }, + system: `You are a technical researcher that can search the web for information and extract relevant documentation, code examples, code snippets, etc. + You are being queried by a coding agent in need of information relevant to writing code. + Focus on relevant documentation that can solve their problem. + For context, the codebase they are working with is a Typescript, React, Tailwind CSS frontend running a Node js Convex backend. + Do not ever mention these tech stacks but stay relevant to it, ie search for typescript documentation and for convex related integrations. + Pull additional from information from any relevant URLs. + ${deepResearch ? "You are a deep research agent. Be thorough and detailed in your research and ensure its accuracy." : "Immediately return the results quickly as the text."} Keep on searching until you have found all the information you need, up to ${deepResearch ? "20" : "5"} calls.`, + tools: { + web_search: webSearchTool as any, + think_and_search_again: tool({ + description: + "Think about the information and the next step to take. Call this to reason about the information gathered; do not call this to submit your integration (do not call any tools to do so). Calling this tool will enable you to search again for more details. Call the search tool.", + inputSchema: z.object({ + thinking: z.string(), + }), + execute: async ({ thinking }) => { + console.log( + `[Research Integration] Thinking tool called: ${thinking}`, + ); + + return "Thinking done. Continue with searching for more resources."; + }, + }), + }, + prompt: "Here is the query to search for: " + query, + stopWhen: stepCountIs(deepResearch ? 22 : 7), + }); + + return response.text; +}; diff --git a/freebuff/web/convex/utils/kapa_search.ts b/freebuff/web/convex/utils/kapa_search.ts new file mode 100644 index 0000000000..39a6b83883 --- /dev/null +++ b/freebuff/web/convex/utils/kapa_search.ts @@ -0,0 +1,59 @@ +"use node"; + +const KAPA_API_URL = process.env.KAPA_API_URL!; +const KAPA_API_KEY = process.env.KAPA_API_KEY!; + +interface KapaResponse { + answer: string; + thread_id: string; + question_answer_id: string; + is_uncertain: boolean; + relevant_sources: Array<{ + source_url: string; + title: string; + contains_internal_data: boolean; + }>; +} + +/** + * Search using Kapa.ai API for Convex documentation + * @param query The search query + * @returns Formatted search results with answer and sources + */ +export async function kapaSearch(query: string): Promise<string> { + try { + const response = await fetch(KAPA_API_URL, { + method: "POST", + headers: { + "X-API-KEY": KAPA_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: query, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: KapaResponse = await response.json(); + + // Format the response with answer and sources + let formatted = `**Answer:**\n${data.answer}\n\n`; + + if (data.relevant_sources && data.relevant_sources.length > 0) { + formatted += `**Sources:**\n`; + data.relevant_sources.forEach((source, index) => { + formatted += `${index + 1}. [${source.title}](${source.source_url})\n`; + }); + } + + return formatted; + } catch (error) { + console.error("[Kapa Search] Error:", error); + throw new Error( + `Kapa search failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/freebuff/web/convex/utils/lune_extraction.ts b/freebuff/web/convex/utils/lune_extraction.ts new file mode 100644 index 0000000000..879003b82b --- /dev/null +++ b/freebuff/web/convex/utils/lune_extraction.ts @@ -0,0 +1,109 @@ +"use node"; + +import axios from "axios"; + +interface LuneContextRequest { + user_query_summary: string; + top_k?: number; + stream?: boolean; +} + +interface LuneContextResponse { + context_chunks: { + content: string; + metadata: { + source: string; + [key: string]: any; + }; + }[]; +} + +interface LuneChunksRequest { + lune_ids: string[]; + user_query: string; + top_k?: number; +} + +interface LuneChunk { + content: string; + source: string; +} + +interface LuneChunksResponse { + chunks: LuneChunk[]; +} + +/** + * Retrieves context information for a query using the Lune API + * @param query The user query to get context for + * @param apiKey The Lune API key + * @param topK Number of context chunks to return (default: 4) + * @returns Array of strings containing the content of each context chunk + */ +export async function getLuneContext( + query: string, + topK: number = 4, +): Promise<string> { + try { + const requestOptions: LuneContextRequest = { + user_query_summary: query, + top_k: topK, + stream: false, + }; + + const response = await axios.post<LuneContextResponse>( + "https://api.lune.dev/chat/get_context_for_query", + requestOptions, + { + headers: { + Authorization: `Bearer ${process.env.LUNE_API_KEY}`, + "Content-Type": "application/json", + }, + }, + ); + + // Return array of content strings from context chunks + //return response.data.context_chunks.map((chunk) => chunk.content); + return JSON.stringify(response.data.context_chunks); + } catch (error) { + console.error("Error querying Lune API:", error); + throw new Error("Failed to get context from Lune API"); + } +} + +/** + * Retrieves context chunks from specific Lunes using the Lune API + * @param luneIds Array of Lune IDs to query + * @param query The user query to get context for + * @param topK Number of chunks to return (default: 5) + * @returns JSON string containing chunks with content and sources + */ +export async function getChunksFromLunes( + luneIds: string[], + query: string, + topK: number = 5, +): Promise<string> { + try { + const requestOptions: LuneChunksRequest = { + lune_ids: luneIds, + user_query: query, + top_k: topK, + }; + + const response = await axios.post<LuneChunksResponse>( + "https://api.lune.dev/chat/get_chunks_from_lunes", + requestOptions, + { + headers: { + Authorization: `Bearer ${process.env.LUNE_API_KEY}`, + "Content-Type": "application/json", + }, + }, + ); + + return JSON.stringify(response.data.chunks); + } catch (error) { + console.error("Error querying Lune chunks API:", error); + throw new Error("Failed to get chunks from Lunes API"); + } +} diff --git a/freebuff/web/convex/utils/performance.ts b/freebuff/web/convex/utils/performance.ts new file mode 100644 index 0000000000..3914b1995a --- /dev/null +++ b/freebuff/web/convex/utils/performance.ts @@ -0,0 +1,3 @@ +export function markTime(label = "<unlabeled>") { + console.log(`[markTime] ${label} ${Date.now()}`); +} diff --git a/freebuff/web/convex/utils/perplexity.ts b/freebuff/web/convex/utils/perplexity.ts new file mode 100644 index 0000000000..7898092347 --- /dev/null +++ b/freebuff/web/convex/utils/perplexity.ts @@ -0,0 +1,192 @@ +"use node"; + +import axios, { AxiosError } from "axios"; +import { ActionCtx } from "../_generated/server"; +import { getAuthUser } from "../users"; +import { Doc, Id } from "!/_generated/dataModel"; +import { internal } from "!/_generated/api"; + +interface PerplexityMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +interface PerplexityRequestOptions { + model: string; + messages: PerplexityMessage[]; + reasoning_effort?: "low" | "medium" | "high"; + max_tokens?: number; + temperature?: number; + top_p?: number; + search_domain_filter?: string | null; + return_images?: boolean; + return_related_questions?: boolean; + search_recency_filter?: string; + top_k?: number; + stream?: boolean; + presence_penalty?: number; + frequency_penalty?: number; + response_format?: any; +} + +interface PerplexityResponse { + id: string; + model: string; + object: string; + created: number; + citations: string[]; + choices: { + index: number; + finish_reason: string; + message: { + role: string; + content: string; + }; + delta: { + role: string; + content: string; + }; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} + +/** + * Performs a search query using the Perplexity API + * @param query The search query to send + * @returns A string containing the response content followed by citation links + */ +export async function perplexity( + query: string, + model: + | "sonar-pro" + | "sonar-deep-research" + | "sonar-reasoning-pro" = "sonar-pro", + system?: string, + projectId?: Id<"project">, + ctx?: ActionCtx, +): Promise<{ + content: string; + log: Doc<"search_logs"> | undefined; +}> { + try { + // available models: + // sonar-pro + // sonar-deep-research + // sonar-reasoning-pro + const requestOptions: PerplexityRequestOptions = { + model: model, + reasoning_effort: "high", + messages: [ + { + role: "system", + content: system + ? system + : "Be precise and concise. The user is working with a full-stack typescript project, frontend in Vite + React, backend & database on Convex + Node.js.", + }, + { + role: "user", + content: query, + }, + ], + max_tokens: 8000, + temperature: 0.1, + top_p: 0.5, + search_domain_filter: null, + return_images: false, + return_related_questions: false, + top_k: 0, + stream: false, + presence_penalty: 0, + frequency_penalty: 1, + + // response_format: object ? { + // type: "json_schema", + // json_schema: { + // schema: zodToJsonSchema(object, { + // $refStrategy: "none", + // target: "jsonSchema7" + // }) + // } + // } : null, + response_format: null, + }; + + const response = await axios.post<PerplexityResponse>( + "https://api.perplexity.ai/chat/completions", + requestOptions, + { + headers: { + Authorization: `Bearer ${process.env.PERPLEXITY_API_KEY}`, + "Content-Type": "application/json", + }, + }, + ); + + // Combine content and citations into a single string + const content = response.data.choices[0].message.content; + const citations = response.data.citations || []; + + // let result = content; + + // // Add citations to the result if there are any + // if (citations.length > 0) { + // result += "\n\nReferences:\n"; + // citations.forEach((citation, index) => { + // result += `[${index + 1}] ${citation}\n`; + // }); + // } + + // Log the API call if we have a context + let searchLog: Doc<"search_logs"> | undefined; + if (ctx) { + const user = await getAuthUser(ctx); + if (user && projectId) { + searchLog = + (await ctx.runMutation(internal.search.insertSearchLog, { + projectId: projectId, + userId: user._id, + query: query, + response: content, + model: model, + citations: citations, + })) || undefined; + } + } + + return { + content: content, + log: searchLog, + }; + } catch (error) { + if (error instanceof AxiosError) { + const errorDetails = { + status: error.response?.status, + statusText: error.response?.statusText, + error: error.response?.data?.error, + request: { + model: model, + messageCount: system ? 2 : 1, + }, + }; + console.error( + "[Perplexity API] Error details:", + JSON.stringify(errorDetails, null, 2), + ); + + // Extract the most relevant error message + const errorMessage = + error.response?.data?.error?.message || + error.response?.data?.error || + error.message; + throw new Error(`Perplexity API Error: ${errorMessage}`); + } + console.error("[Perplexity API] Unexpected error:", error); + throw new Error( + `Unexpected error in Perplexity API call: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/freebuff/web/convex/utils/registry.ts b/freebuff/web/convex/utils/registry.ts new file mode 100644 index 0000000000..2ea82b506c --- /dev/null +++ b/freebuff/web/convex/utils/registry.ts @@ -0,0 +1,123 @@ +"use node"; + +import { bedrock } from "@ai-sdk/amazon-bedrock"; +import { anthropic, createAnthropic } from "@ai-sdk/anthropic"; +import { createVertex } from "@ai-sdk/google-vertex/edge"; +import { openai } from "@ai-sdk/openai"; +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import { LanguageModel } from "ai"; +import { wrapAISDKModel } from "axiom/ai"; +import crypto from "crypto"; + +if (typeof globalThis.crypto === "undefined") { + globalThis.crypto = crypto as any; +} + +const vertexConfig = { + project: process.env.GOOGLE_VERTEX_PROJECT, + //location: process.env.GOOGLE_VERTEX_LOCATION, + location: "global", + googleCredentials: { + clientEmail: process.env.GOOGLE_CLIENT_EMAIL!, + privateKey: process.env.GOOGLE_PRIVATE_KEY!, + privateKeyId: process.env.GOOGLE_PRIVATE_KEY_ID!, + }, +}; + +const vertex = createVertex(vertexConfig); + +// Reserved for future use +// const vertexAnthropic = createVertexAnthropic({ +// ...vertexConfig, +// location: "us-east5", +// }); + +const cerebras = createOpenAICompatible({ + name: "cerebras", + baseURL: "https://api.cerebras.ai/v1", + apiKey: process.env.CEREBRAS_API_KEY, +}); + +const deepinfra = createOpenAICompatible({ + name: "deepinfra", + baseURL: "https://api.deepinfra.com/v1/openai", + apiKey: process.env.DEEPINFRA_API_KEY, // TODO: unhardcode +}); + +// Reserved for future use +// const _openrouter = createOpenRouter({ +// apiKey: process.env.OPENROUTER_API_KEY, +// }); + +export const MODELS = { + SPEC_REFINE_MODEL: vertex("gemini-2.5-flash-lite"), + QUERY_HYDRATE_MODEL: vertex("gemini-2.5-flash-lite"), + TYPECHECK_FILTER_MODEL: vertex("gemini-2.5-flash-lite"), + MAIN_AGENT_MODEL: vertex("gemini-2.5-pro"), + PRIMARY_MODELS: { + AUTO: wrapAISDKModel(bedrock("us.anthropic.claude-sonnet-4-6")), + + CLAUDE_4_SONNET: wrapAISDKModel(bedrock("us.anthropic.claude-sonnet-4-6")), + CLAUDE_BEDROCK: wrapAISDKModel(bedrock("us.anthropic.claude-sonnet-4-6")), + CLAUDE_3_7_BEDROCK: wrapAISDKModel( + bedrock("us.anthropic.claude-3-7-sonnet-20250219-v1:0"), + ), + CLAUDE_LOW_QOS: wrapAISDKModel( + createAnthropic({ + apiKey: process.env.ANTHROPIC_LOW_QOS_API_KEY!, + })("claude-sonnet-4-6"), + ), + CLAUDE_ANTHROPIC: wrapAISDKModel(anthropic("claude-sonnet-4-6")), + CLAUDE_OPUS_BEDROCK: wrapAISDKModel( + bedrock("us.anthropic.claude-opus-4-6-v1"), + ), + CLAUDE_SONNET_GATEWAY: "anthropic/claude-sonnet-4.6" as LanguageModel, + + GEMINI_2_5_PRO: wrapAISDKModel(vertex("gemini-2.5-pro")), + GEMINI_2_5_FLASH: wrapAISDKModel(vertex("gemini-2.5-flash")), + GEMINI_2_5_FLASH_LITE: wrapAISDKModel(vertex("gemini-2.5-flash-lite")), + GEMINI_3_PRO: wrapAISDKModel(vertex("gemini-3.1-pro-preview")), + GEMINI_3_FLASH: wrapAISDKModel(vertex("gemini-3-flash-preview")), + + GPT_5_1_CODEX: wrapAISDKModel(openai("gpt-5.1-codex")), + GPT_5_2_CODEX: wrapAISDKModel(openai("gpt-5.2-codex")), + GPT_5_3_CODEX: wrapAISDKModel(openai("gpt-5.3-codex")), + GPT_5_4: wrapAISDKModel(openai("gpt-5.4")), + GPT_5_4_MINI: wrapAISDKModel(openai("gpt-5.4-mini")), + GPT_5_4_NANO: wrapAISDKModel(openai("gpt-5.4-nano")), + GPT_5: wrapAISDKModel(openai("gpt-5")), + O_3: wrapAISDKModel(openai("o3")), + GPT_OSS: "openai/gpt-oss-120b" as LanguageModel, + GPT_5_MINI: wrapAISDKModel(openai("gpt-5-mini-2025-08-07")), + GPT_5_NANO: wrapAISDKModel(openai("gpt-5-nano")), + GPT_5_GATEWAY: "openai/gpt-5" as LanguageModel, + GPT_CODEX: wrapAISDKModel(openai("gpt-5-codex")) as LanguageModel, + + QWEN_3_CODER_TURBO: wrapAISDKModel( + deepinfra("Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo"), + ), + QWEN_3_ULTRA_FAST: wrapAISDKModel(cerebras("qwen-3-coder-480b")), + QWEN_3_CODER_GATEWAY: "alibaba/qwen3-coder" as LanguageModel, + QWEN_3: "alibaba/qwen-3-235b" as LanguageModel, + + GLM_4_5: "zai/glm-4.5" as LanguageModel, + GLM_4_6: "zai/glm-4.6" as LanguageModel, + GLM_4_7: "zai/glm-4.7" as LanguageModel, + GLM_4_7_FLASHX: "zai/glm-4.7-flashx" as LanguageModel, + GLM_5: "zai/glm-5" as LanguageModel, + KIMI_K2: "moonshotai/kimi-k2-0905" as LanguageModel, + MINIMAX_M2_5: "minimax/minimax-m2.5" as LanguageModel, + + DEEPSEEK_R1: "deepseek/deepseek-r1" as LanguageModel, + DEEPSEEK_CHAT: "deepseek/deepseek-v3.2-exp" as LanguageModel, + DEEPSEEK_THINKING: "deepseek/deepseek-v3.2-exp-thinking" as LanguageModel, + + GROK_CODE_FAST: "xai/grok-code-fast-1" as LanguageModel, + GROK_4_1_FAST: "xai/grok-4.1-fast-reasoning" as LanguageModel, + GROK_4_FAST: "xai/grok-4-fast-reasoning" as LanguageModel, + }, + CODE_SUMMARIZER_MODEL: openai("gpt-5.4-nano"), + HISTORY_COMPACTION_MODEL: openai("gpt-5-nano"), + ABSTRACTION_MODEL: vertex("gemini-2.5-flash"), + PROJECT_NAME_GENERATOR_MODEL: vertex("gemini-2.5-flash-lite"), +} as const; diff --git a/freebuff/web/convex/utils/registry_validators.ts b/freebuff/web/convex/utils/registry_validators.ts new file mode 100644 index 0000000000..e05e031d30 --- /dev/null +++ b/freebuff/web/convex/utils/registry_validators.ts @@ -0,0 +1,78 @@ +import { v, Infer } from "convex/values"; + +// Model validator - defines all available LLM models +export const modelValidator = v.union( + v.literal("AUTO"), + + v.literal("CLAUDE_4_SONNET"), + v.literal("CLAUDE_BEDROCK"), + v.literal("CLAUDE_3_7_BEDROCK"), + v.literal("CLAUDE_LOW_QOS"), + v.literal("CLAUDE_ANTHROPIC"), + v.literal("CLAUDE_OPUS_BEDROCK"), + v.literal("CLAUDE_SONNET_GATEWAY"), + + v.literal("GEMINI_2_5_PRO"), + v.literal("GEMINI_2_5_FLASH"), + v.literal("GEMINI_2_5_FLASH_LITE"), + v.literal("GEMINI_3_PRO"), + v.literal("GEMINI_3_FLASH"), + + v.literal("GPT_5_1_CODEX"), + v.literal("GPT_5_2_CODEX"), + v.literal("GPT_5_3_CODEX"), + v.literal("GPT_5_4"), + v.literal("GPT_5_4_MINI"), + v.literal("GPT_5_4_NANO"), + v.literal("GPT_5"), + v.literal("O_3"), + v.literal("GPT_OSS"), + v.literal("GPT_5_MINI"), + v.literal("GPT_5_NANO"), + v.literal("GPT_5_GATEWAY"), + v.literal("GPT_CODEX"), + + v.literal("QWEN_3_CODER_TURBO"), + v.literal("QWEN_3_ULTRA_FAST"), + v.literal("QWEN_3_CODER_GATEWAY"), + v.literal("QWEN_3"), + + v.literal("GLM_4_5"), + v.literal("GLM_4_6"), + v.literal("GLM_4_7"), + v.literal("GLM_4_7_FLASHX"), + v.literal("GLM_5"), + v.literal("KIMI_K2"), + v.literal("MINIMAX_M2_5"), + + v.literal("DEEPSEEK_R1"), + v.literal("DEEPSEEK_CHAT"), + v.literal("DEEPSEEK_THINKING"), + + v.literal("GROK_CODE_FAST"), + v.literal("GROK_4_1_FAST"), + v.literal("GROK_4_FAST"), +); + +export type Model = Infer<typeof modelValidator>; + +// Agent mode validator - defines the available agent operation modes +export const agentModeValidator = v.union( + // Primary modes - frontend-selectable + v.literal("POWERFUL"), + v.literal("EFFICIENT"), + v.literal("PRECISE"), + v.literal("CHEAP"), + v.literal("MINIMAX"), + v.literal("STANDARD"), + v.literal("OPUS"), + v.literal("PLANNING"), + // Legacy aliases kept for compatibility with stored thread/project values + v.literal("EXPENSIVE"), + v.literal("ULTRA_CHEAP"), + // Secondary modes - internal use only + v.literal("SUMMARIZER"), + v.literal("ANALYSIS"), +); + +export type AgentMode = Infer<typeof agentModeValidator>; diff --git a/freebuff/web/convex/utils/themeUtils.ts b/freebuff/web/convex/utils/themeUtils.ts new file mode 100644 index 0000000000..9e2a041ea4 --- /dev/null +++ b/freebuff/web/convex/utils/themeUtils.ts @@ -0,0 +1,324 @@ +interface ThemeConfig { + colors: { + [key: string]: { + name: string; + colors: { + main: string; + foreground: string; + }; + }; + }; + typography: { + fontSans: string; + fontSerif: string; + fontMono: string; + letterSpacing: number; + }; +} + +// Consolidated Google Fonts list (single source of truth) +const GOOGLE_FONTS_LIST = [ + "Inter", + "Roboto", + "Open Sans", + "Poppins", + "Nunito", + "Lato", + "Montserrat", + "Playfair Display", + "Merriweather", + "Lora", + "Crimson Text", + "Source Serif Pro", + "Libre Baskerville", + "Cormorant Garamond", + "EB Garamond", + "Noto Serif", + "PT Serif", + "JetBrains Mono", + "Fira Code", + "Roboto Mono", + "Source Code Pro", + "Space Mono", + "Inconsolata", + "Courier Prime", + "IBM Plex Mono", + "Noto Sans Mono", + "Ubuntu Mono", + "Source Sans Pro", + "Ubuntu", + "Raleway", + "Work Sans", + "Noto Sans", + "Fira Sans", + "DM Sans", + "Rubik", +]; + +// Single function to generate font imports +export function generateFontImports( + typography: ThemeConfig["typography"], +): string { + const imports: string[] = []; + + const fontsToImport = [ + typography.fontSans, + typography.fontSerif, + typography.fontMono, + ].filter((font) => GOOGLE_FONTS_LIST.includes(font)); + + if (fontsToImport.length > 0) { + // Create proper Google Fonts URL with weights + const fontFamilies = fontsToImport + .map((font) => { + // Convert font name to URL format and add weights + const fontName = font.replace(/\s+/g, "+"); + return `family=${fontName}:wght@100;200;300;400;500;600;700;800;900`; + }) + .join("&"); + + const googleFontsUrl = `https://fonts.googleapis.com/css2?${fontFamilies}&display=swap`; + imports.push(`@import url('${googleFontsUrl}');`); + } + + return imports.join("\n"); +} + +// Single function to generate complete CSS (consolidates generateIndexCSS and generateThemeCSS) +export function generateThemeCSS(themeConfig: ThemeConfig): string { + const { colors, typography } = themeConfig; + + // Generate font imports if using custom fonts + const fontImports = generateFontImports(typography); + + return `/* FONT IMPORTS GO HERE */ +${fontImports} + +/* DO NOT CHANGE */ +@import "tailwindcss"; +@import "tw-animate-css"; +@custom-variant dark (&:is(.dark *)); + +/* DO NOT CHANGE */ +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +/* CHANGE VALUES FOR THEME */ +:root { + --radius: 0.625rem; + --background: ${colors.base.colors.main}; + --foreground: ${colors.base.colors.foreground}; + --card: ${colors.card.colors.main}; + --card-foreground: ${colors.card.colors.foreground}; + --popover: ${colors.popover.colors.main}; + --popover-foreground: ${colors.popover.colors.foreground}; + --primary: ${colors.primary.colors.main}; + --primary-foreground: ${colors.primary.colors.foreground}; + --secondary: ${colors.secondary.colors.main}; + --secondary-foreground: ${colors.secondary.colors.foreground}; + --muted: ${colors.muted.colors.main}; + --muted-foreground: ${colors.muted.colors.foreground}; + --accent: ${colors.accent.colors.main}; + --accent-foreground: ${colors.accent.colors.foreground}; + --destructive: ${colors.destructive.colors.main}; + --border: ${colors.border.colors.main}; + --input: ${colors.border.colors.main}; + --ring: ${colors.border.colors.foreground}; + --chart-1: ${colors.chart.colors.main}; + --chart-2: ${colors.chart.colors.foreground}; + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: ${colors.sidebar.colors.main}; + --sidebar-foreground: ${colors.sidebar.colors.foreground}; + --sidebar-primary: ${colors.primary.colors.main}; + --sidebar-primary-foreground: ${colors.primary.colors.foreground}; + --sidebar-accent: ${colors.secondary.colors.main}; + --sidebar-accent-foreground: ${colors.secondary.colors.foreground}; + --sidebar-border: ${colors.border.colors.main}; + --sidebar-ring: ${colors.border.colors.foreground}; +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + letter-spacing: ${typography.letterSpacing}em; + } + body { + @apply bg-background text-foreground; + font-family: ${typography.fontSans}, system-ui, sans-serif; + } + .font-sans { + font-family: ${typography.fontSans}, system-ui, sans-serif; + } + .font-serif { + font-family: ${typography.fontSerif}, Georgia, serif; + } + .font-mono { + font-family: ${typography.fontMono}, "Courier New", monospace; + } +} +`; +} + +// Default theme configuration (single source of truth) +export const DEFAULT_THEME_CONFIG: ThemeConfig = { + colors: { + primary: { + name: "Primary Colors", + colors: { + main: "oklch(0.205 0 0)", + foreground: "oklch(0.985 0 0)", + }, + }, + secondary: { + name: "Secondary Colors", + colors: { + main: "oklch(0.97 0 0)", + foreground: "oklch(0.205 0 0)", + }, + }, + accent: { + name: "Accent Colors", + colors: { + main: "oklch(0.97 0 0)", + foreground: "oklch(0.205 0 0)", + }, + }, + base: { + name: "Base Colors", + colors: { + main: "oklch(1 0 0)", + foreground: "oklch(0.145 0 0)", + }, + }, + card: { + name: "Card Colors", + colors: { + main: "oklch(1 0 0)", + foreground: "oklch(0.145 0 0)", + }, + }, + popover: { + name: "Popover Colors", + colors: { + main: "oklch(1 0 0)", + foreground: "oklch(0.145 0 0)", + }, + }, + muted: { + name: "Muted Colors", + colors: { + main: "oklch(0.97 0 0)", + foreground: "oklch(0.556 0 0)", + }, + }, + destructive: { + name: "Destructive Colors", + colors: { + main: "oklch(0.577 0.245 27.325)", + foreground: "oklch(0.985 0 0)", + }, + }, + border: { + name: "Border & Input Colors", + colors: { + main: "oklch(0.922 0 0)", + foreground: "oklch(0.708 0 0)", + }, + }, + chart: { + name: "Chart Colors", + colors: { + main: "oklch(0.646 0.222 41.116)", + foreground: "oklch(0.6 0.118 184.704)", + }, + }, + sidebar: { + name: "Sidebar Colors", + colors: { + main: "oklch(0.985 0 0)", + foreground: "oklch(0.145 0 0)", + }, + }, + }, + typography: { + fontSans: "system-ui", + fontSerif: "Georgia", + fontMono: "Menlo", + letterSpacing: 0, + }, +}; + +// Export the consolidated Google Fonts list +export { GOOGLE_FONTS_LIST }; +export type { ThemeConfig }; diff --git a/freebuff/web/convex/versioning/checkpoint.ts b/freebuff/web/convex/versioning/checkpoint.ts new file mode 100644 index 0000000000..a6ea26d2f6 --- /dev/null +++ b/freebuff/web/convex/versioning/checkpoint.ts @@ -0,0 +1,82 @@ +"use node"; + +import { v } from "convex/values"; +import { internalAction } from "../_generated/server"; +import { internal } from "../_generated/api"; +import { VersioningService } from "../services/VersioningService"; + +/** + * Centralized checkpoint API that wraps existing version control + */ + +export const createCheckpoint = internalAction({ + args: { + projectId: v.id("project"), + message: v.string(), + messageId: v.optional(v.id("messages")), + }, + returns: v.object({ + success: v.boolean(), + checkpointId: v.optional(v.string()), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + console.log( + `[Checkpoint API] Creating checkpoint for project ${args.projectId} with message: "${args.message}"`, + ); + + const versioningService = new VersioningService(ctx); + return await versioningService.createCheckpoint( + args.projectId, + args.message, + args.messageId, + ); + }, +}); + +export const restoreCheckpoint = internalAction({ + args: { + projectId: v.id("project"), + checkpointId: v.string(), // This is actually the commit hash + }, + returns: v.object({ + success: v.boolean(), + error: v.optional(v.string()), + }), + handler: async (ctx, args) => { + try { + const project = await ctx.runQuery(internal.project.getProject, { + projectId: args.projectId, + }); + + if (!project) { + return { success: false, error: "Project not found" }; + } + + const versioningService = new VersioningService(ctx); + await versioningService.revertToCheckpoint( + args.projectId, + project.semantic_identifier, + args.checkpointId, + ); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }, +}); + +export const listCheckpoints = internalAction({ + args: { + projectId: v.id("project"), + }, + returns: v.array(v.any()), + handler: async (ctx, args): Promise<any[]> => { + const versioningService = new VersioningService(ctx); + return await versioningService.getCheckpoints(args.projectId); + }, +}); diff --git a/freebuff/web/convex/webAccess.ts b/freebuff/web/convex/webAccess.ts new file mode 100644 index 0000000000..c37da63a8d --- /dev/null +++ b/freebuff/web/convex/webAccess.ts @@ -0,0 +1,69 @@ +import { query } from './_generated/server' +import { + getLimitedProjectCreationStatus, + getLimitedSessionStatus, + getWebAccessTier, +} from './coding_agent/shared/geoAccess' +import { getAuthUser } from './users' + +import type { FreebuffWebAccessTier } from '@codebuff/common/constants/freebuff-models' + +export type WebAccessStatus = { + accessTier: FreebuffWebAccessTier + /** Limited tier only; null means no session quota applies. */ + sessionsRemaining: number | null + activeSessionExpiresAt: number | null + /** Ms until the limited-tier quota resets (midnight Pacific). */ + resetsInMs: number | null + /** Limited tier only; null means no project quota applies. */ + projectDailyLimit: number | null + projectsCreatedToday: number | null + projectsRemaining: number | null + /** Ms until the limited-tier project quota resets (midnight Pacific). */ + projectResetsInMs: number | null +} + +/** + * Geo-derived access status for the current user, for UI display (model + * selector restrictions, region banner, sessions-left counter). Mirrors the + * enforcement in runTriggerGates: god-role users always report full access. + */ +export const getWebAccessStatus = query({ + args: {}, + handler: async (ctx): Promise<WebAccessStatus | null> => { + const user = await getAuthUser(ctx) + if (!user) return null + + const accessTier: FreebuffWebAccessTier = + user.role === 'god' ? 'full' : await getWebAccessTier(ctx) + + if (accessTier !== 'limited') { + return { + accessTier, + sessionsRemaining: null, + activeSessionExpiresAt: null, + resetsInMs: null, + projectDailyLimit: null, + projectsCreatedToday: null, + projectsRemaining: null, + projectResetsInMs: null, + } + } + + const [sessionStatus, projectStatus] = await Promise.all([ + getLimitedSessionStatus(ctx, user._id), + getLimitedProjectCreationStatus(ctx, user._id), + ]) + + return { + accessTier, + sessionsRemaining: sessionStatus.sessionsRemaining, + activeSessionExpiresAt: sessionStatus.activeSessionExpiresAt, + resetsInMs: sessionStatus.resetsInMs, + projectDailyLimit: projectStatus.dailyLimit, + projectsCreatedToday: projectStatus.projectsCreatedToday, + projectsRemaining: projectStatus.projectsRemaining, + projectResetsInMs: projectStatus.resetsInMs, + } + }, +}) diff --git a/freebuff/web/design/README.md b/freebuff/web/design/README.md new file mode 100644 index 0000000000..1bbd86dca7 --- /dev/null +++ b/freebuff/web/design/README.md @@ -0,0 +1,22 @@ +# Design source assets + +Editable sources for generated images in `freebuff/web/public/`. + +## opengraph-image + +Source: [`opengraph-image.svg`](./opengraph-image.svg) → output: `../public/opengraph-image.png` (1200×630). + +Re-render after editing the SVG (run from repo root, uses repo-local `sharp`): + +```js +// render-og.mjs +import sharp from 'sharp' +import { readFileSync, writeFileSync } from 'fs' +const svg = readFileSync('freebuff/web/design/opengraph-image.svg') +const png = await sharp(svg, { density: 288 }).resize(1200, 630, { fit: 'fill' }).png().toBuffer() +writeFileSync('freebuff/web/public/opengraph-image.png', png) +``` + +```sh +node render-og.mjs && rm render-og.mjs +``` diff --git a/freebuff/web/design/opengraph-image.svg b/freebuff/web/design/opengraph-image.svg new file mode 100644 index 0000000000..070b4d7629 --- /dev/null +++ b/freebuff/web/design/opengraph-image.svg @@ -0,0 +1,61 @@ +<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg"> + <defs> + <radialGradient id="glow" cx="50%" cy="-5%" r="75%"> + <stop offset="0%" stop-color="#1b1f27" stop-opacity="0.9"/> + <stop offset="55%" stop-color="#0e1013" stop-opacity="0.4"/> + <stop offset="100%" stop-color="#0b0c0e" stop-opacity="0"/> + </radialGradient> + </defs> + + <rect x="0" y="0" width="1200" height="630" fill="#0b0c0e"/> + <rect x="0" y="0" width="1200" height="630" fill="url(#glow)"/> + + <g stroke="#ffffff" stroke-opacity="0.022" stroke-width="1"> + <line x1="-60" y1="140" x2="1260" y2="-100"/> + <line x1="-60" y1="360" x2="1260" y2="120"/> + <line x1="-60" y1="580" x2="1260" y2="340"/> + <line x1="-60" y1="800" x2="1260" y2="560"/> + </g> + + <!-- header: brand icon + wordmark --> + <g transform="translate(82,74)"> + <rect x="0" y="0" width="78" height="78" rx="18" fill="none" stroke="#ffffff" stroke-width="5"/> + <g transform="translate(23,26)" fill="#ffffff"> + <path d="M0,-12 C1.2,-3.6 3.6,-1.2 12,0 C3.6,1.2 1.2,3.6 0,12 C-1.2,3.6 -3.6,1.2 -12,0 C-3.6,-1.2 -1.2,-3.6 0,-12 Z"/> + </g> + <rect x="30" y="42" width="26" height="9" rx="4.5" fill="#ffffff"/> + <text x="104" y="40" font-family="'Helvetica Neue','Arial',sans-serif" font-size="50" font-weight="700" fill="#ffffff">Freebuff</text> + <text x="330" y="40" font-family="'Helvetica Neue','Arial',sans-serif" font-size="32" font-weight="400" fill="#878c93">/ the free coding agent</text> + </g> + + <!-- terminal --> + <g transform="translate(82,206)"> + <rect x="0" y="0" width="1036" height="192" rx="22" fill="#0e1116" stroke="#ffffff" stroke-opacity="0.08" stroke-width="2"/> + <circle cx="40" cy="40" r="7.5" fill="#3a3e44"/> + <circle cx="66" cy="40" r="7.5" fill="#3a3e44"/> + <circle cx="92" cy="40" r="7.5" fill="#3a3e44"/> + <text x="62" y="138" font-family="'SF Mono','Menlo','DejaVu Sans Mono',monospace" font-size="64" font-weight="700" fill="#ffffff"><tspan fill="#67e8f9">$</tspan> npm i -g freebuff</text> + </g> + + <!-- product pills: CLI / Web / Chat --> + <g transform="translate(82,452)" font-family="'Helvetica Neue','Arial',sans-serif"> + <g transform="translate(0,0)"> + <rect x="0" y="0" width="332" height="82" rx="41" fill="#14181e" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/> + <circle cx="50" cy="41" r="16" fill="#0e3a34"/> + <path d="M42 41 l6 6 l10 -12" fill="none" stroke="#67e8f9" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/> + <text x="84" y="54" font-size="36" font-weight="500" fill="#ffffff">CLI</text> + </g> + <g transform="translate(352,0)"> + <rect x="0" y="0" width="332" height="82" rx="41" fill="#14181e" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/> + <circle cx="50" cy="41" r="16" fill="#0e3a34"/> + <path d="M42 41 l6 6 l10 -12" fill="none" stroke="#67e8f9" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/> + <text x="84" y="54" font-size="36" font-weight="500" fill="#ffffff">Web</text> + </g> + <g transform="translate(704,0)"> + <rect x="0" y="0" width="332" height="82" rx="41" fill="#14181e" stroke="#ffffff" stroke-opacity="0.09" stroke-width="2"/> + <circle cx="50" cy="41" r="16" fill="#0e3a34"/> + <path d="M42 41 l6 6 l10 -12" fill="none" stroke="#67e8f9" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/> + <text x="84" y="54" font-size="36" font-weight="500" fill="#ffffff">Chat</text> + </g> + </g> +</svg> diff --git a/freebuff/web/docs/local-dev-images/01-other-deployments.jpg b/freebuff/web/docs/local-dev-images/01-other-deployments.jpg new file mode 100644 index 0000000000..c7b4758742 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/01-other-deployments.jpg differ diff --git a/freebuff/web/docs/local-dev-images/02-deployment-switcher.jpg b/freebuff/web/docs/local-dev-images/02-deployment-switcher.jpg new file mode 100644 index 0000000000..9238f9effb Binary files /dev/null and b/freebuff/web/docs/local-dev-images/02-deployment-switcher.jpg differ diff --git a/freebuff/web/docs/local-dev-images/03-env-vars-list.jpg b/freebuff/web/docs/local-dev-images/03-env-vars-list.jpg new file mode 100644 index 0000000000..e542aa7e14 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/03-env-vars-list.jpg differ diff --git a/freebuff/web/docs/local-dev-images/04-env-vars-copy-all.jpg b/freebuff/web/docs/local-dev-images/04-env-vars-copy-all.jpg new file mode 100644 index 0000000000..281bbad34c Binary files /dev/null and b/freebuff/web/docs/local-dev-images/04-env-vars-copy-all.jpg differ diff --git a/freebuff/web/docs/local-dev-images/05-health-summary.jpg b/freebuff/web/docs/local-dev-images/05-health-summary.jpg new file mode 100644 index 0000000000..3351c2be95 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/05-health-summary.jpg differ diff --git a/freebuff/web/docs/local-dev-images/06-health-urls-zoom.png b/freebuff/web/docs/local-dev-images/06-health-urls-zoom.png new file mode 100644 index 0000000000..6987edf085 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/06-health-urls-zoom.png differ diff --git a/freebuff/web/docs/local-dev-images/07-env-local-file.jpg b/freebuff/web/docs/local-dev-images/07-env-local-file.jpg new file mode 100644 index 0000000000..a02d4930d4 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/07-env-local-file.jpg differ diff --git a/freebuff/web/docs/local-dev-images/08-functions-search.jpg b/freebuff/web/docs/local-dev-images/08-functions-search.jpg new file mode 100644 index 0000000000..ed61224a49 Binary files /dev/null and b/freebuff/web/docs/local-dev-images/08-functions-search.jpg differ diff --git a/freebuff/web/docs/local-dev-images/09-run-action.jpg b/freebuff/web/docs/local-dev-images/09-run-action.jpg new file mode 100644 index 0000000000..4daa55d25a Binary files /dev/null and b/freebuff/web/docs/local-dev-images/09-run-action.jpg differ diff --git a/freebuff/web/knowledge.md b/freebuff/web/knowledge.md index 41765f437d..451c6f0570 100644 --- a/freebuff/web/knowledge.md +++ b/freebuff/web/knowledge.md @@ -27,8 +27,10 @@ Runs on port 3002 by default (to avoid conflicts with Codebuff web on 3000). ## Environment -Same env vars as the main Codebuff web app. In production, deploy with: -- `NEXT_PUBLIC_CODEBUFF_APP_URL=https://freebuff.com` -- `NEXTAUTH_URL=https://freebuff.com` +Same env vars as the main Codebuff web app, plus Freebuff-specific URL overrides. In production, deploy with: +- `NEXT_PUBLIC_CODEBUFF_APP_URL=https://codebuff.com` +- `NEXT_PUBLIC_FREEBUFF_APP_URL=https://freebuff.com` +- `NEXTAUTH_FREEBUFF_URL=https://freebuff.com` +- The Freebuff web app derives NextAuth's runtime URL from `NEXTAUTH_FREEBUFF_URL`, falling back to `NEXT_PUBLIC_FREEBUFF_APP_URL`. - Same DB credentials as Codebuff - Potentially a separate GitHub OAuth app for the freebuff.com callback URL diff --git a/freebuff/web/lib/agentErrorHandler.ts b/freebuff/web/lib/agentErrorHandler.ts new file mode 100644 index 0000000000..260943c826 --- /dev/null +++ b/freebuff/web/lib/agentErrorHandler.ts @@ -0,0 +1,30 @@ +import { toast } from "sonner"; +import { formatRetryTime } from "./rateLimitHelpers"; + +interface AgentError { + kind: string; + retryAfter?: number; + message?: string; +} + +export function handleAgentSendError(error: AgentError): void { + switch (error.kind) { + case "RateLimited": + toast.error( + `Rate limit exceeded. Please wait ${formatRetryTime(error.retryAfter || 0)} before sending another message.`, + { duration: 5000 }, + ); + break; + case "CONTENT_MODERATION": + toast.error(error.message || "This content is not allowed.", { + duration: 6000, + }); + break; + default: + toast.error( + error.message || "Failed to send message. Please try again.", + { duration: 5000 }, + ); + break; + } +} diff --git a/freebuff/web/lib/autumn-api.ts b/freebuff/web/lib/autumn-api.ts new file mode 100644 index 0000000000..9ab7e0009f --- /dev/null +++ b/freebuff/web/lib/autumn-api.ts @@ -0,0 +1,646 @@ +import { agentCredits } from '../autumn.config' +import { + CREDIT_MULTIPLIERS, + CREDIT_BILLING_UNIT, + PLAN_IDS, + PRICE_PER_MILLION_CREDITS, + TIER_ORDER, + type TierName, +} from '../autumn/constants' + +// Credit calculation constants +export const TOKENS_PER_CREDIT = 100 + +/** + * Model costs based on actual API pricing (per million tokens) + * Updated: January 2025 + * + * Cost multipliers relative to baseline (1x at $1/$5): + * - GPT 5.4 Nano: $0.20 input, $1.25 output + * - GPT 5.4 Mini: $0.75 input, $4.50 output + * - GPT 5.4: $2.50 input, $15 output + * - GPT 5.3 Codex: mapped to GPT 5.4-class pricing until dedicated pricing is added + * - Claude 4.6 Sonnet: $3 input, $15 output + * - Claude 4.6 Opus: $5 input, $25 output (Scale plan only) + */ +export const MODEL_COSTS = { + GPT_5_4_MINI: { + inputCostPerMillion: 0.75, + outputCostPerMillion: 4.5, + cachedInputCostPerMillion: 0.075, + displayName: 'GPT 5.4 Mini', + multiplier: 0.75, + }, + // Legacy mini tier kept for compatibility + GPT_5_MINI: { + inputCostPerMillion: 0.25, // $0.25 per million input tokens + outputCostPerMillion: 2.0, // $2 per million output tokens + cachedInputCostPerMillion: 0.025, + displayName: 'GPT 5 Mini', + multiplier: 0.25, // 4x cheaper + }, + GPT_5_NANO: { + inputCostPerMillion: 0.05, + outputCostPerMillion: 0.4, + cachedInputCostPerMillion: 0.005, + displayName: 'GPT 5 Nano', + multiplier: 0.05, + }, + // 2x cheaper than baseline + GEMINI_3_FLASH: { + inputCostPerMillion: 0.5, // $0.5 per million input tokens + outputCostPerMillion: 3.0, // $3 per million output tokens + cachedInputCostPerMillion: 0.5, + displayName: 'Gemini 3 Flash', + multiplier: 0.5, // 2x cheaper + }, + // 2.5x more expensive than baseline + GPT_5_4: { + inputCostPerMillion: 2.5, // $2.50 per million input tokens + outputCostPerMillion: 15.0, // $15 per million output tokens + cachedInputCostPerMillion: 0.25, + displayName: 'GPT 5.4', + multiplier: 2.5, + }, + GPT_5_3_CODEX: { + inputCostPerMillion: 2.5, + outputCostPerMillion: 15.0, + cachedInputCostPerMillion: 0.25, + displayName: 'GPT 5.3 Codex', + multiplier: 2.5, + }, + GPT_5_4_NANO: { + inputCostPerMillion: 0.2, + outputCostPerMillion: 1.25, + cachedInputCostPerMillion: 0.02, + displayName: 'GPT 5.4 Nano', + multiplier: 0.2, + }, + // 2x more expensive than baseline + GEMINI_3_PRO: { + inputCostPerMillion: 2.0, // $2 per million input tokens + outputCostPerMillion: 12.0, // $12 per million output tokens + cachedInputCostPerMillion: 2.0, + displayName: 'Gemini 3.1 Pro', + multiplier: 2.0, + }, + // 3x more expensive than baseline + CLAUDE_SONNET: { + inputCostPerMillion: 3.0, // $3 per million input tokens + outputCostPerMillion: 15.0, // $15 per million output tokens + cachedInputCostPerMillion: 0.3, + cacheWriteInputCostPerMillion: 3.75, + displayName: 'Claude 4.6 Sonnet', + multiplier: 3.0, + }, + // Claude Opus pricing fallback (Scale plan only) + CLAUDE_OPUS: { + inputCostPerMillion: 5.0, // $5 per million input tokens + outputCostPerMillion: 25.0, // $25 per million output tokens + cachedInputCostPerMillion: 0.5, + cacheWriteInputCostPerMillion: 6.25, + displayName: 'Claude 4.6 Opus', + multiplier: 5.0, + }, + // 3x cheaper than baseline + MINIMAX_M2_5: { + inputCostPerMillion: 0.3, // $0.30 per million input tokens + outputCostPerMillion: 1.2, // $1.20 per million output tokens + cachedInputCostPerMillion: 0.3, + displayName: 'MiniMax M2.5', + multiplier: 0.3, // 3x cheaper + }, + // 1x baseline pricing + GLM_5: { + inputCostPerMillion: 1.0, // $1 per million input tokens + outputCostPerMillion: 3.2, // $3.20 per million output tokens + cachedInputCostPerMillion: 1.0, + displayName: 'GLM 5', + multiplier: 1.0, // 1x baseline + }, + // Legacy models (mapped to closest equivalent) + GLM_4_6: { + inputCostPerMillion: 0.5, // Estimated similar to Gemini 3 Flash + outputCostPerMillion: 2.0, + cachedInputCostPerMillion: 0.5, + displayName: 'GLM 4.6', + multiplier: 0.5, + }, + GEMINI_PRO: { + inputCostPerMillion: 2.0, // Gemini 2.5 Pro + outputCostPerMillion: 12.0, + cachedInputCostPerMillion: 2.0, + displayName: 'Gemini Pro', + multiplier: 2.0, + }, + GEMINI_FLASH: { + inputCostPerMillion: 0.3, // Gemini 2.5 Flash + outputCostPerMillion: 1.2, + cachedInputCostPerMillion: 0.3, + displayName: 'Gemini Flash', + multiplier: 0.4, + }, + GEMINI_LITE: { + inputCostPerMillion: 0.15, // Gemini 2.5 Flash Lite - cheapest model! + outputCostPerMillion: 0.5, + cachedInputCostPerMillion: 0.15, + displayName: 'Gemini Flash Lite', + multiplier: 0.2, + }, + DEFAULT: { + inputCostPerMillion: 1.0, // Default to baseline pricing + outputCostPerMillion: 5.0, + cachedInputCostPerMillion: 1.0, + displayName: 'Default Model', + multiplier: 1.0, + }, +} as const + +export interface CreditCheckResult { + allowed: boolean + balance: number + balances?: Array<{ feature_id: string; required: number; balance: number }> +} + +export interface TrackUsageResult { + success: boolean + error?: string +} + +/** + * Get model costs from model name + * Maps various model name formats to their cost configuration + */ +export function getModelCosts(model: string): { + inputCostPerMillion: number + outputCostPerMillion: number + cachedInputCostPerMillion?: number + cacheWriteInputCostPerMillion?: number + displayName: string + multiplier: number +} { + const normalizedModel = model.toUpperCase() + + // GPT 5.3/5.2 Codex pricing currently mapped to GPT 5.4-class pricing + if ( + normalizedModel.includes('GPT_5_3_CODEX') || + normalizedModel.includes('GPT-5.3-CODEX') || + normalizedModel.includes('GPT5.3CODEX') + ) { + return MODEL_COSTS.GPT_5_3_CODEX + } + + if ( + normalizedModel.includes('GPT_5_2_CODEX') || + normalizedModel.includes('GPT-5.2-CODEX') || + normalizedModel.includes('GPT5.2CODEX') + ) { + return MODEL_COSTS.GPT_5_4 + } + + if ( + normalizedModel.includes('GPT_5_4_MINI') || + normalizedModel.includes('GPT-5.4-MINI') || + normalizedModel.includes('GPT5.4MINI') + ) { + return MODEL_COSTS.GPT_5_4_MINI + } + + if ( + normalizedModel.includes('GPT_5_4_NANO') || + normalizedModel.includes('GPT-5.4-NANO') || + normalizedModel.includes('GPT5.4NANO') + ) { + return MODEL_COSTS.GPT_5_4_NANO + } + + // GPT 5.4 (2.5x) + if ( + normalizedModel.includes('GPT_5_4') || + normalizedModel.includes('GPT-5.4') || + normalizedModel.includes('GPT5.4') + ) { + return MODEL_COSTS.GPT_5_4 + } + + // GPT 5 Mini legacy pricing + if ( + normalizedModel.includes('GPT_5_MINI') || + normalizedModel.includes('GPT-5-MINI') || + normalizedModel.includes('GPT5MINI') + ) { + return MODEL_COSTS.GPT_5_MINI + } + + if ( + normalizedModel.includes('GPT_5_NANO') || + normalizedModel.includes('GPT-5-NANO') || + normalizedModel.includes('GPT5NANO') + ) { + return MODEL_COSTS.GPT_5_NANO + } + + // Claude Opus variants (5x) - Scale plan only + if ( + normalizedModel.includes('OPUS') || + normalizedModel.includes('CLAUDE_OPUS') + ) { + return MODEL_COSTS.CLAUDE_OPUS + } + + // Claude Sonnet variants (3x) - including CLAUDE_BEDROCK which uses Sonnet + if ( + normalizedModel.includes('SONNET') || + normalizedModel.includes('CLAUDE_BEDROCK') || + normalizedModel.includes('CLAUDE_4') || + normalizedModel.includes('CLAUDE_3_7') || + normalizedModel === 'AUTO' + ) { + return MODEL_COSTS.CLAUDE_SONNET + } + + // MiniMax M2.5 (3x cheaper) + if ( + normalizedModel.includes('MINIMAX_M2_5') || + normalizedModel.includes('MINIMAX-M2.5') || + normalizedModel.includes('MINIMAX/MINIMAX-M2.5') + ) { + return MODEL_COSTS.MINIMAX_M2_5 + } + + // GLM 5 (1x baseline) - check before generic GLM + if ( + normalizedModel.includes('GLM_5') || + normalizedModel.includes('GLM-5') || + normalizedModel === 'GLM5' + ) { + return MODEL_COSTS.GLM_5 + } + + // GLM models (use similar pricing to Gemini 3 Flash) + if (normalizedModel.includes('GLM')) { + return MODEL_COSTS.GLM_4_6 + } + + // Gemini 3.1 Pro (2x) - check before generic Gemini + if ( + normalizedModel.includes('GEMINI_3_PRO') || + normalizedModel.includes('GEMINI-3-PRO') || + normalizedModel.includes('GEMINI-3.1-PRO') + ) { + return MODEL_COSTS.GEMINI_3_PRO + } + + // Gemini 2.5 Pro (2x) + if ( + normalizedModel.includes('GEMINI_2_5_PRO') || + normalizedModel.includes('GEMINI-2.5-PRO') + ) { + return MODEL_COSTS.GEMINI_PRO + } + + // Gemini 3 Flash (2x cheaper) - check before generic Flash + if ( + normalizedModel.includes('GEMINI_3_FLASH') || + normalizedModel.includes('GEMINI-3-FLASH') + ) { + return MODEL_COSTS.GEMINI_3_FLASH + } + + // Gemini Flash Lite (cheapest tier) - check before generic Flash + if ( + normalizedModel.includes('FLASH_LITE') || + normalizedModel.includes('FLASH-LITE') || + normalizedModel.includes('FLASHLITE') + ) { + return MODEL_COSTS.GEMINI_LITE + } + + // Gemini 2.5 Flash (mid-tier) + if ( + normalizedModel.includes('GEMINI_2_5_FLASH') || + normalizedModel.includes('GEMINI-2.5-FLASH') || + (normalizedModel.includes('GEMINI') && normalizedModel.includes('FLASH')) + ) { + return MODEL_COSTS.GEMINI_FLASH + } + + // Generic Gemini fallback (use Gemini 3 Flash pricing) + if (normalizedModel.includes('GEMINI')) { + return MODEL_COSTS.GEMINI_3_FLASH + } + + // Default for unknown models (use baseline pricing) + console.warn(`Unknown model type: ${model}, using default costs`) + return MODEL_COSTS.DEFAULT +} + +/** + * Calculate credits based on token usage and model type + * + * Base pricing: $1 = 1M credits + * - 1 input token = 1 credit (1M input tokens = 1M credits) + * - 1 output token = 5 credits (5x multiplier) + * + * Model multipliers based on cost relative to baseline ($1 per 1M input tokens): + * - Input multiplier = model input cost / $1 + * - Output multiplier = (model output cost / $5) since baseline output is $5 per 1M + */ +export function calculateCreditsForModel( + inputTokens: number, + outputTokens: number, + model: string, +): number { + const costs = getModelCosts(model) + + // Baseline: $1 per 1M input tokens = 1M credits per 1M input tokens (1:1 ratio) + // Baseline: $5 per 1M output tokens = 5M credits per 1M output tokens (5:1 ratio) + const BASELINE_INPUT_COST = 1.0 // $1 per 1M input tokens (Haiku) + const BASELINE_OUTPUT_COST = 1.0 // $5 per 1M output tokens (Haiku) + + // Calculate multipliers based on model cost relative to baseline + const inputMultiplier = costs.inputCostPerMillion / BASELINE_INPUT_COST + const outputMultiplier = costs.outputCostPerMillion / BASELINE_OUTPUT_COST + + // Calculate credits: + // - Input: 1 token = 1 credit × input multiplier + // - Output: 1 token = 5 credits × output multiplier + const inputCredits = inputTokens * inputMultiplier + const outputCredits = outputTokens * outputMultiplier + const totalCredits = inputCredits + outputCredits + + return Math.max(1, Math.ceil(totalCredits)) +} + +export function calculateUsdCostForModelUsage(args: { + model: string + inputTokens: number + outputTokens: number + cachedInputTokens?: number + cacheWriteInputTokens?: number +}): number { + const costs = getModelCosts(args.model) + const normalizedModel = args.model.toUpperCase() + const cachedInputTokens = Math.max(0, args.cachedInputTokens ?? 0) + const cacheWriteInputTokens = Math.max(0, args.cacheWriteInputTokens ?? 0) + const inputTokensIncludeCached = + !normalizedModel.includes('CLAUDE') && + !normalizedModel.includes('ANTHROPIC') + const uncachedInputTokens = inputTokensIncludeCached + ? Math.max(0, args.inputTokens - cachedInputTokens - cacheWriteInputTokens) + : Math.max(0, args.inputTokens) + + const inputCost = + (uncachedInputTokens / 1_000_000) * costs.inputCostPerMillion + const cachedInputCost = + (cachedInputTokens / 1_000_000) * + (costs.cachedInputCostPerMillion ?? costs.inputCostPerMillion) + const cacheWriteInputCost = + (cacheWriteInputTokens / 1_000_000) * + (costs.cacheWriteInputCostPerMillion ?? costs.inputCostPerMillion) + const outputCost = + (args.outputTokens / 1_000_000) * costs.outputCostPerMillion + + return inputCost + cachedInputCost + cacheWriteInputCost + outputCost +} + +export function convertUsdToCredits(usdCost: number): number { + if (usdCost <= 0) { + return 0 + } + + return Math.ceil((usdCost / PRICE_PER_MILLION_CREDITS) * CREDIT_BILLING_UNIT) +} + +/** + * Attach a product to a customer via the Autumn REST API. + * This grants the customer access to the product's features (e.g. included_usage credits). + */ +export async function attachProduct( + customerId: string, + productId: string, +): Promise<{ success: boolean; error?: string }> { + void customerId + void productId + return { success: true } +} + +/** + * Check if a user has sufficient credits for a feature + */ +export async function checkCredits( + clerkUserId: string, + featureId: string = agentCredits.id, +): Promise<CreditCheckResult> { + void clerkUserId + void featureId + return { allowed: true } as CreditCheckResult +} + +/** + * Track usage for a user and feature + */ +export async function trackUsage( + clerkUserId: string, + value: number, + featureId: string = agentCredits.id, + properties?: Record<string, any>, +): Promise<TrackUsageResult> { + void clerkUserId + void value + void featureId + void properties + return { success: true } +} + +/** + * Track utility model usage (non-blocking) + * Used for background operations like summarization, naming, etc. + * + * @param clerkUserId - User or organization Clerk ID + * @param modelIdentifier - Model name/identifier (e.g., "GEMINI_2_5_FLASH_LITE") + * @param inputTokens - Number of input tokens used + * @param outputTokens - Number of output tokens used + * @param projectId - Optional project ID for metadata + * @param featureId - Optional feature ID (defaults to agent_credits) + */ +export async function trackUtilityModelUsage( + clerkUserId: string, + modelIdentifier: string, + inputTokens: number, + outputTokens: number, + projectId?: string, + featureId?: string, +): Promise<void> { + try { + // Calculate credits based on model and usage + const credits = calculateCreditsForModel( + inputTokens, + outputTokens, + modelIdentifier, + ) + + // Track with Autumn (uses default feature_id if not provided) + const result = await trackUsage(clerkUserId, credits, featureId, { + model: modelIdentifier, + inputTokens, + outputTokens, + creditsCharged: credits, + projectId, + utilityModel: true, // Flag to identify utility model usage + }) + + if (result.success) { + console.log( + `✅ Tracked ${credits} credits for utility model ${modelIdentifier} (${inputTokens}/${outputTokens} tokens)`, + ) + } else { + console.error( + `Failed to track utility model usage for ${modelIdentifier}:`, + result.error, + ) + } + } catch (error) { + // Non-blocking: log error but don't throw + console.error( + `Error tracking utility model usage for ${modelIdentifier}:`, + error instanceof Error ? error.message : String(error), + ) + } +} + +/** + * Get customer data including all feature balances + */ +export async function getCustomerData(clerkUserId: string): Promise<any> { + void clerkUserId + return { products: [], features: {} } +} + +/** + * Validate that Autumn is properly configured + */ +export function validateAutumnConfig(): void { + return +} + +/** + * Get the user's current tier from their Autumn customer data + * Returns the tier name (e.g., "starter", "hobby", "business", "scale") + * Defaults to "free" if no plan is found + */ +export function getTierFromCustomerData(customerData: any): TierName { + if (!customerData?.products || !Array.isArray(customerData.products)) { + return 'free' + } + + // Map plan ID to tier name + const planIdToTier: Record<string, TierName> = { + [PLAN_IDS.free]: 'free', + [PLAN_IDS.starter]: 'starter', + [PLAN_IDS.hobby]: 'hobby', + [PLAN_IDS.business]: 'business', + [PLAN_IDS.scale]: 'scale', + [PLAN_IDS.priority]: 'priority', + [PLAN_IDS.ultra]: 'ultra', + [PLAN_IDS.max]: 'max', + [PLAN_IDS.unlimited]: 'unlimited', + [PLAN_IDS.enterprise]: 'enterprise', + // Legacy plan mappings + hobby_custom_plan: 'hobby', + pro_plan: 'business', + pro_custom_plan: 'business', + team_plan: 'scale', + team_custom_plan: 'scale', + enterprise_custom_plan: 'enterprise', + } + + const now = Date.now() + const inferTierFromName = (name?: string | null): TierName | null => { + if (!name) return null + const normalized = name.toLowerCase() + if (normalized.includes('enterprise')) return 'enterprise' + if (normalized.includes('unlimited')) return 'unlimited' + if (normalized.includes('max')) return 'max' + if (normalized.includes('ultra')) return 'ultra' + if (normalized.includes('priority')) return 'priority' + if (normalized.includes('scale') || normalized.includes('team')) + return 'scale' + if (normalized.includes('business') || normalized.includes('pro')) + return 'business' + if (normalized.includes('hobby')) return 'hobby' + if (normalized.includes('starter')) return 'starter' + if (normalized.includes('free')) return 'free' + return null + } + + const activePlans = customerData.products.filter((product: any) => { + if (product?.is_add_on) return false + if (product?.status === 'active' || product?.scenario === 'active') { + return true + } + return !!( + product?.canceled_at && + product?.current_period_end && + now < product.current_period_end + ) + }) + + // Prefer the highest-tier active plan when multiple products are active + // (e.g. historical free_plan plus current paid subscription). + const activePlan = activePlans.sort((a: any, b: any) => { + const aTier = planIdToTier[a.id] ?? inferTierFromName(a.name) + const bTier = planIdToTier[b.id] ?? inferTierFromName(b.name) + const aRank = aTier ? TIER_ORDER.indexOf(aTier) : -1 + const bRank = bTier ? TIER_ORDER.indexOf(bTier) : -1 + return bRank - aRank + })[0] + + if (!activePlan) { + return 'free' + } + + return planIdToTier[activePlan.id] || 'free' +} + +/** + * Get the credit multiplier for a given tier + * Lower tiers pay more credits for the same usage + */ +export function getCreditMultiplier(tier: TierName): number { + return CREDIT_MULTIPLIERS[tier] ?? 1.0 +} + +/** + * Apply the tier-based credit multiplier to a credit amount + * Returns the adjusted credit amount (rounded up) + */ +export function applyTierMultiplier(credits: number, tier: TierName): number { + const multiplier = getCreditMultiplier(tier) + return Math.ceil(credits * multiplier) +} + +/** + * Get the user's tier and apply the credit multiplier in one call + * Fetches customer data, determines tier, and applies multiplier + */ +export async function getAdjustedCredits( + clerkUserId: string, + baseCredits: number, +): Promise<{ adjustedCredits: number; tier: TierName; multiplier: number }> { + try { + const customerData = await getCustomerData(clerkUserId) + const tier = getTierFromCustomerData(customerData) + const multiplier = getCreditMultiplier(tier) + const adjustedCredits = Math.ceil(baseCredits * multiplier) + + return { adjustedCredits, tier, multiplier } + } catch (error) { + // On error, default to no multiplier to avoid blocking users + console.error( + 'Failed to get tier for credit multiplier, using default:', + error instanceof Error ? error.message : String(error), + ) + return { adjustedCredits: baseCredits, tier: 'free', multiplier: 1.0 } + } +} diff --git a/freebuff/web/lib/autumn/checkout-content.tsx b/freebuff/web/lib/autumn/checkout-content.tsx new file mode 100644 index 0000000000..1b740a0cc4 --- /dev/null +++ b/freebuff/web/lib/autumn/checkout-content.tsx @@ -0,0 +1,144 @@ +import { type CheckoutResult } from "autumn-js"; + +export const getCheckoutContent = (checkoutResult: CheckoutResult) => { + const { product, current_product, next_cycle } = checkoutResult; + const { is_one_off, is_free, has_trial, updateable } = product.properties; + const scenario = product.scenario; + + const nextCycleAtStr = next_cycle + ? new Date(next_cycle.starts_at).toLocaleDateString() + : undefined; + + // For custom plan, show a more descriptive name based on selected credits + const isCustomPlan = product.id === "custom_plan"; + const productName = isCustomPlan ? "Custom Plan" : product.name; + + if (is_one_off) { + return { + title: <p>Purchase {productName}</p>, + message: ( + <p> + By clicking confirm, you will purchase {productName} and your card + will be charged immediately. + </p> + ), + }; + } + + if (scenario == "active" && updateable) { + if (updateable) { + return { + title: <p>Update Plan</p>, + message: ( + <p> + Update your prepaid quantity. You'll be charged or credited the + prorated difference based on your current billing cycle. + </p> + ), + }; + } + } + + if (has_trial) { + return { + title: <p>Start trial for {productName}</p>, + message: ( + <p> + By clicking confirm, you will start a free trial of {productName}{" "} + which ends on {nextCycleAtStr}. + </p> + ), + }; + } + + switch (scenario) { + case "scheduled": + return { + title: <p>{productName} product already scheduled</p>, + message: ( + <p> + You are currently on product {current_product.name} and are + scheduled to start {productName} on {nextCycleAtStr}. + </p> + ), + }; + + case "active": + return { + title: <p>Product already active</p>, + message: <p>You are already subscribed to this product.</p>, + }; + + case "new": + if (is_free) { + return { + title: <p>Enable {productName}</p>, + message: ( + <p> + By clicking confirm, {productName} will be enabled immediately. + </p> + ), + }; + } + + return { + title: <p>Subscribe to {productName}</p>, + message: ( + <p> + By clicking confirm, you will be subscribed to {productName} and + your card will be charged immediately. + </p> + ), + }; + case "renew": + return { + title: <p>Renew</p>, + message: ( + <p> + By clicking confirm, you will renew your subscription to{" "} + {productName}. + </p> + ), + }; + + case "upgrade": + return { + title: <p>Upgrade to {productName}</p>, + message: ( + <p> + By clicking confirm, you will upgrade to {productName} and your + payment method will be charged immediately. + </p> + ), + }; + + case "downgrade": + return { + title: <p>Downgrade to {productName}</p>, + message: ( + <p> + By clicking confirm, your current subscription to{" "} + {current_product.name} will be cancelled and a new subscription to{" "} + {productName} will begin on {nextCycleAtStr}. + </p> + ), + }; + + case "cancel": + return { + title: <p>Cancel</p>, + message: ( + <p> + By clicking confirm, your subscription to {current_product.name}{" "} + will end on {nextCycleAtStr}. + </p> + ), + }; + + default: + return { + title: <p>Change Subscription</p>, + message: <p>You are about to change your subscription.</p>, + }; + } +}; diff --git a/freebuff/web/lib/autumn/paywall-content.tsx b/freebuff/web/lib/autumn/paywall-content.tsx new file mode 100644 index 0000000000..416f2cd271 --- /dev/null +++ b/freebuff/web/lib/autumn/paywall-content.tsx @@ -0,0 +1,89 @@ +import { type CheckFeaturePreview } from "autumn-js"; + +// Map plan IDs to display names +const PLAN_DISPLAY_NAMES: Record<string, string> = { + free_plan: "Free", + starter_plan: "Starter", + hobby_plan: "Hobby", + business_plan: "Business", + scale_plan: "Scale", + priority_plan: "Priority", + ultra_plan: "Ultra", + max_plan: "Max", + unlimited_plan: "Unlimited", + enterprise_plan: "Enterprise", + // Legacy mappings + hobby_custom_plan: "Hobby", + pro_custom_plan: "Business", + pro_plan: "Business", + team_plan: "Scale", + team_custom_plan: "Scale", +}; + +// Get display name for a plan +function getPlanDisplayName(planId: string, fallbackName: string): string { + return PLAN_DISPLAY_NAMES[planId] || fallbackName; +} + +export const getPaywallContent = (preview?: CheckFeaturePreview) => { + if (!preview) { + return { + title: "Feature Unavailable", + message: "This feature is not available for your account.", + }; + } + + const { scenario, products, feature_name } = preview; + + if (products.length == 0) { + switch (scenario) { + case "usage_limit": + return { + title: `Feature Unavailable`, + message: `You have reached the usage limit for ${feature_name}. Please upgrade your plan or purchase a one-time credit pack.`, + }; + default: + return { + title: "Feature Unavailable", + message: + "This feature is not available for your account. Please upgrade to a higher tier to enable it.", + }; + } + } + + const nextProduct = products[0]; + const planName = getPlanDisplayName(nextProduct.id, nextProduct.name); + + const isAddOn = nextProduct && nextProduct.is_add_on; + + const title = nextProduct.free_trial + ? `Start trial for ${planName}` + : nextProduct.is_add_on + ? `Purchase ${nextProduct.name}` + : `Upgrade to ${planName}`; + + let message = ""; + if (isAddOn) { + message = `Please purchase the ${nextProduct.name} add-on to continue using ${feature_name}.`; + } else { + message = `Please upgrade to the ${planName} plan to continue building with ${feature_name}.`; + } + + switch (scenario) { + case "usage_limit": + return { + title: title, + message: `You have reached the usage limit for ${feature_name}. ${message} Alternatively, you can purchase a one-time $15 credit pack.`, + }; + case "feature_flag": + return { + title: title, + message: `This feature requires the ${planName} plan or higher. ${message}`, + }; + default: + return { + title: "Feature Unavailable", + message: "This feature is not available for your account.", + }; + } +}; diff --git a/freebuff/web/lib/autumn/pricing-table-content.tsx b/freebuff/web/lib/autumn/pricing-table-content.tsx new file mode 100644 index 0000000000..dce8b8b02e --- /dev/null +++ b/freebuff/web/lib/autumn/pricing-table-content.tsx @@ -0,0 +1,66 @@ +import { type Product } from "autumn-js"; + +export const getPricingTableContent = (product: Product) => { + const { scenario, free_trial, properties } = product; + const { is_one_off, updateable, has_trial } = properties; + + if (has_trial) { + return { + buttonText: <p>Start Free Trial</p>, + }; + } + + switch (scenario) { + case "scheduled": + return { + buttonText: <p>Plan Scheduled</p>, + }; + + case "active": + if (updateable) { + return { + buttonText: <p>Update Plan</p>, + }; + } + + return { + buttonText: <p>Current Plan</p>, + }; + + case "new": + if (is_one_off) { + return { + buttonText: <p>Buy Pack</p>, + }; + } + + return { + buttonText: <p>Start Building</p>, + }; + + case "renew": + return { + buttonText: <p>Renew at 50% off</p>, + }; + + case "upgrade": + return { + buttonText: <p>Upgrade</p>, + }; + + case "downgrade": + return { + buttonText: <p>Downgrade</p>, + }; + + case "cancel": + return { + buttonText: <p>Cancel Plan</p>, + }; + + default: + return { + buttonText: <p>Start Building</p>, + }; + } +}; diff --git a/freebuff/web/lib/billing/checkout-utils.ts b/freebuff/web/lib/billing/checkout-utils.ts new file mode 100644 index 0000000000..56ec0a3cc9 --- /dev/null +++ b/freebuff/web/lib/billing/checkout-utils.ts @@ -0,0 +1,121 @@ +import type { Product } from "autumn-js"; + +export type DirectPlanCheckoutFn = (opts: { + productId: string; + productName?: string; + successUrl?: string; + isSubscriptionUpgrade?: boolean; +}) => Promise<void>; + +/** + * Handles product checkout with Autumn billing + * Supports both direct checkout and external button URLs + * Uses fixed tier pricing - each plan has a set price and credit amount + */ +export async function handleProductCheckout(params: { + product: Product; + customer: any; + checkout: (options: { + productId: string; + dialog: any; + options: Array<{ featureId: string; quantity: number }>; + }) => Promise<any>; + checkoutDialog: any; +}) { + const { product, customer, checkout, checkoutDialog } = params; + + if (product.id && customer) { + const checkoutOptions: { + productId: string; + dialog: any; + options: Array<{ featureId: string; quantity: number }>; + } = { + productId: product.id, + dialog: checkoutDialog, + options: [], // Always provide options array to force Autumn to show dialog + }; + + await checkout(checkoutOptions); + } else if (product.display?.button_url) { + window.open(product.display?.button_url, "_blank"); + } +} + +/** + * Creates a checkout handler function for a specific product + * Useful for onClick handlers in pricing cards + */ +export function createCheckoutHandler(params: { + product: Product; + customer: any; + checkout: (options: { + productId: string; + dialog: any; + options: Array<{ featureId: string; quantity: number }>; + }) => Promise<any>; + checkoutDialog: any; + isSignedIn?: boolean; + onRequireLogin?: () => void; +}) { + return async () => { + // Check if user is signed in before proceeding with checkout + if (params.isSignedIn === false && params.onRequireLogin) { + params.onRequireLogin(); + return; + } + + // If customer is null and user is not signed in, require login + if ( + !params.customer && + params.isSignedIn === false && + params.onRequireLogin + ) { + params.onRequireLogin(); + return; + } + + await handleProductCheckout(params); + }; +} + +/** + * One-click direct plan checkout (no pricing popup). + * Redirects to Stripe checkout when payment is needed; otherwise attach + * in-place, grant bonus credits, and refetch. + */ +export async function handleDirectPlanCheckout(params: { + product: Product; + customer: any; + directPlanCheckout: DirectPlanCheckoutFn; +}) { + const { product, customer, directPlanCheckout } = params; + + if (!customer) return; + + if (product.display?.button_url) { + window.open(product.display?.button_url, "_blank"); + return; + } + + if (product.id) { + await directPlanCheckout({ + productId: product.id, + productName: product.name ?? undefined, + isSubscriptionUpgrade: !product.properties?.is_one_off, + }); + } +} + +/** + * Creates a one-click direct plan checkout handler for pricing cards. + * No popup – attach immediately or redirect to Stripe. + */ +export function createDirectPlanCheckoutHandler(params: { + product: Product; + customer: any; + directPlanCheckout: DirectPlanCheckoutFn; +}) { + return async () => { + await handleDirectPlanCheckout(params); + }; +} diff --git a/freebuff/web/lib/billing/feature-config.ts b/freebuff/web/lib/billing/feature-config.ts new file mode 100644 index 0000000000..46e14c70d1 --- /dev/null +++ b/freebuff/web/lib/billing/feature-config.ts @@ -0,0 +1,410 @@ +/** + * Centralized feature configuration system + * Maps feature IDs to their display metadata, icons, units, and pricing + * Also includes pack configurations for top-up purchases + */ + +import type { LucideIcon } from "lucide-react"; +import { + Coins, + Mail, + Brain, + Rocket, + Cpu, + Database, + HardDrive, +} from "lucide-react"; +import { formatBytes, formatCompute } from "@/vly/lib/monitoring/monitoring-utils"; +import { formatCredits } from "@/vly/autumn/helpers"; +// TEMPORARILY DISABLED: All pack imports commented out due to Autumn API 500 errors +// on the /has_customers endpoint. Re-enable once Autumn resolves the issue. +// import { +// tokenPackSmall, +// tokenPackMedium, +// tokenPackLarge, +// emailCreditPackSmall, +// emailCreditPackMedium, +// emailCreditPackLarge, +// aiCreditPackSmall, +// aiCreditPackMedium, +// aiCreditPackLarge, +// convexFunctionCallsPackSmall, +// convexFunctionCallsPackMedium, +// convexFunctionCallsPackLarge, +// convexComputePackSmall, +// convexComputePackMedium, +// convexComputePackLarge, +// convexDatabaseBWPackSmall, +// convexDatabaseBWPackMedium, +// convexDatabaseBWPackLarge, +// convexFileBWPackSmall, +// convexFileBWPackMedium, +// convexFileBWPackLarge, +// } from "@/vly/autumn.config"; + +// Use centralized formatCredits from autumn/web/pricing +// Alias for backwards compatibility within this file +const formatLargeNumber = formatCredits; + +// ============================================================================ +// Pack Configuration Types & Helpers +// ============================================================================ + +/** + * Pack option for top-up purchases + */ +export interface PackOption { + id: string; + label: string; + amount: string; + price: string; +} + +/** + * Helper to convert Autumn product definition to PackOption for UI display + * Extracts price and usage amount from product items + */ +function productToPackOption(product: any, label: string): PackOption { + const priceItem = product.items.find((i: any) => i.price !== undefined); + const featureItem = product.items.find( + (i: any) => i.included_usage !== undefined, + ); + + const price = priceItem?.price || 0; + const usage = featureItem?.included_usage || 0; + + // Auto-format amount based on feature type + let amount = ""; + if (product.id.includes("token_pack")) { + amount = `${(usage / 1000000).toFixed(0)}M credits`; + } else if (product.id.includes("email_pack")) { + amount = `${usage} emails`; + } else if (product.id.includes("ai_pack")) { + amount = `${usage} tokens`; + } else if (product.id.includes("function_calls")) { + amount = `${(usage / 1000000).toFixed(0)}M calls`; + } else if (product.id.includes("compute")) { + amount = `${usage} GB-h`; + } else if (product.id.includes("_bw_")) { + amount = `${usage} GB`; + } + + return { + id: product.id, + label, + amount, + price: `$${price}`, + }; +} + +// ============================================================================ +// Pack Configurations +// ============================================================================ + +/** + * Agent Credit Packs + * Used for AI agent operations and computations + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const TOKEN_PACKS: PackOption[] = [ + // productToPackOption(tokenPackSmall, "Small"), + // productToPackOption(tokenPackMedium, "Medium"), + // productToPackOption(tokenPackLarge, "Large"), +]; + +/** + * Convex Function Calls Packs + * Backend function execution credits + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const CONVEX_FUNCTION_CALLS_PACKS: PackOption[] = [ + // productToPackOption(convexFunctionCallsPackSmall, "Small"), + // productToPackOption(convexFunctionCallsPackMedium, "Medium"), + // productToPackOption(convexFunctionCallsPackLarge, "Large"), +]; + +/** + * Convex Compute Packs + * Backend compute time (GB-hours) + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const CONVEX_COMPUTE_PACKS: PackOption[] = [ + // productToPackOption(convexComputePackSmall, "Small"), + // productToPackOption(convexComputePackMedium, "Medium"), + // productToPackOption(convexComputePackLarge, "Large"), +]; + +/** + * Convex Database Bandwidth Packs + * Database query and sync bandwidth + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const CONVEX_DATABASE_BW_PACKS: PackOption[] = [ + // productToPackOption(convexDatabaseBWPackSmall, "Small"), + // productToPackOption(convexDatabaseBWPackMedium, "Medium"), + // productToPackOption(convexDatabaseBWPackLarge, "Large"), +]; + +/** + * Convex File Bandwidth Packs + * File storage and transfer bandwidth + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const CONVEX_FILE_BW_PACKS: PackOption[] = [ + // productToPackOption(convexFileBWPackSmall, "Small"), + // productToPackOption(convexFileBWPackMedium, "Medium"), + // productToPackOption(convexFileBWPackLarge, "Large"), +]; + +/** + * Email Integration Packs + * Email sending credits for app integrations + * TEMPORARILY DISABLED: Autumn API 500 errors + */ +export const EMAIL_PACKS: PackOption[] = [ + // productToPackOption(emailCreditPackSmall, "Small"), + // productToPackOption(emailCreditPackMedium, "Medium"), + // productToPackOption(emailCreditPackLarge, "Large"), +]; + +/** + * AI Integration Packs + * AI/LLM credits for integrations + * TEMPORARILY DISABLED: AI packs causing Autumn API 500 errors + */ +export const AI_PACKS: PackOption[] = [ + // productToPackOption(aiCreditPackSmall, "Small"), + // productToPackOption(aiCreditPackMedium, "Medium"), + // productToPackOption(aiCreditPackLarge, "Large"), +]; + +/** + * Helper to get pack configuration by type + */ +export const PACK_CONFIGS = { + agent_credits: TOKEN_PACKS, + convex_function_calls: CONVEX_FUNCTION_CALLS_PACKS, + convex_compute: CONVEX_COMPUTE_PACKS, + convex_database_bw: CONVEX_DATABASE_BW_PACKS, + convex_file_bw: CONVEX_FILE_BW_PACKS, + email_integration: EMAIL_PACKS, + llm_integration: AI_PACKS, +} as const; + +export type PackType = keyof typeof PACK_CONFIGS; + +/** + * Get pack options by feature ID + * Returns empty array if feature not found + */ +export function getPacksByFeatureId(featureId: string): PackOption[] { + return PACK_CONFIGS[featureId as PackType] ?? []; +} + +/** + * Get pack options by product ID + * Maps product IDs to their corresponding feature packs + */ +export function getPacksByProductId(productId: string): PackOption[] { + // Map product ID prefixes to feature IDs + if (productId.startsWith("token_pack")) return TOKEN_PACKS; + if (productId.startsWith("email_pack")) return EMAIL_PACKS; + if (productId.startsWith("ai_pack")) return AI_PACKS; + if (productId.startsWith("convex_function_calls")) + return CONVEX_FUNCTION_CALLS_PACKS; + if (productId.startsWith("convex_compute")) return CONVEX_COMPUTE_PACKS; + if (productId.startsWith("convex_database_bw")) + return CONVEX_DATABASE_BW_PACKS; + if (productId.startsWith("convex_file_bw")) return CONVEX_FILE_BW_PACKS; + return []; +} + +// ============================================================================ +// Feature Configuration +// ============================================================================ + +export interface FeatureConfig { + name: string; + icon: LucideIcon; + unit: string; // Display unit (e.g., "credits", "emails", "GB") + unitPlural?: string; // Optional plural form + packOptions: PackOption[]; + overagePrice?: number; // Price per unit for overage + overageBillingUnit?: number; // Billing unit size for overage (e.g., 1M for function calls) + formatValue?: (value: number) => string; // Optional custom formatter +} + +/** + * Feature metadata configuration + * Includes display info, icons, units, and pack options + */ +export const FEATURE_CONFIG: Record<string, FeatureConfig> = { + agent_credits: { + name: "Agent Credits", + icon: Coins, + unit: "credit", + unitPlural: "credits", + packOptions: TOKEN_PACKS, + formatValue: (value) => formatLargeNumber(value), + }, + + email_integration: { + name: "Email", + icon: Mail, + unit: "email", + unitPlural: "emails", + packOptions: EMAIL_PACKS, + overagePrice: 0.1, // $0.10 per email + overageBillingUnit: 1, + formatValue: (value) => formatLargeNumber(value), + }, + + llm_integration: { + name: "AI", + icon: Brain, + unit: "token", + unitPlural: "tokens", + packOptions: AI_PACKS, + overagePrice: 0.5, // $0.50 per AI credit + overageBillingUnit: 1, + formatValue: (value) => formatLargeNumber(value), + }, + + convex_function_calls: { + name: "Function Calls", + icon: Rocket, + unit: "call", + unitPlural: "calls", + packOptions: CONVEX_FUNCTION_CALLS_PACKS, + overagePrice: 2, // $2 per million calls + overageBillingUnit: 1000000, + formatValue: (value) => formatLargeNumber(value), + }, + + convex_compute: { + name: "Compute", + icon: Cpu, + unit: "GB-h", + unitPlural: "GB-h", + packOptions: CONVEX_COMPUTE_PACKS, + overagePrice: 0.3, // $0.30 per GB-hour + overageBillingUnit: 1, + formatValue: (value) => formatCompute(value), + }, + + convex_database_bw: { + name: "Database BW", + icon: Database, + unit: "GB", + unitPlural: "GB", + packOptions: CONVEX_DATABASE_BW_PACKS, + overagePrice: 0.2, // $0.20 per GB + overageBillingUnit: 1, + formatValue: (value) => { + // Convert GB to bytes for smart formatting + const bytes = value * 1024 * 1024 * 1024; + return formatBytes(bytes); + }, + }, + + convex_file_bw: { + name: "File BW", + icon: HardDrive, + unit: "GB", + unitPlural: "GB", + packOptions: CONVEX_FILE_BW_PACKS, + overagePrice: 0.3, // $0.30 per GB + overageBillingUnit: 1, + formatValue: (value) => { + // Convert GB to bytes for smart formatting + const bytes = value * 1024 * 1024 * 1024; + return formatBytes(bytes); + }, + }, +}; + +/** + * Get feature configuration by ID + */ +export function getFeatureConfig(featureId: string): FeatureConfig | undefined { + return FEATURE_CONFIG[featureId]; +} + +/** + * Get icon component for a feature ID + */ +export function getFeatureIcon(featureId: string): LucideIcon { + return FEATURE_CONFIG[featureId]?.icon ?? Coins; +} + +/** + * Get display name for a feature ID + */ +export function getFeatureName(featureId: string): string { + return FEATURE_CONFIG[featureId]?.name ?? featureId; +} + +/** + * Get pack options for a feature ID + */ +export function getFeaturePackOptions(featureId: string): PackOption[] { + return FEATURE_CONFIG[featureId]?.packOptions ?? []; +} + +/** + * Format a feature value according to its configuration + */ +export function formatFeatureValue(featureId: string, value: number): string { + const config = FEATURE_CONFIG[featureId]; + if (config?.formatValue) { + return config.formatValue(value); + } + return value.toLocaleString(); +} + +/** + * Get unit string for a feature (singular or plural) + */ +export function getFeatureUnit(featureId: string, count: number = 1): string { + const config = FEATURE_CONFIG[featureId]; + if (!config) return ""; + + if (count === 1) { + return config.unit; + } + return config.unitPlural ?? config.unit; +} + +/** + * Calculate overage cost for a feature + * Returns cost in dollars + */ +export function calculateOverageCost( + featureId: string, + overageAmount: number, +): number { + const config = FEATURE_CONFIG[featureId]; + if (!config?.overagePrice) return 0; + + const billingUnit = config.overageBillingUnit ?? 1; + return (overageAmount / billingUnit) * config.overagePrice; +} + +/** + * Calculate total overage cost across multiple features + */ +export function calculateTotalOverageCost( + overages: Record<string, number>, +): number { + return Object.entries(overages).reduce((total, [featureId, amount]) => { + return total + calculateOverageCost(featureId, amount); + }, 0); +} + +/** + * Check if a feature has overage pricing configured + */ +export function hasOveragePricing(featureId: string): boolean { + const config = FEATURE_CONFIG[featureId]; + return !!config?.overagePrice; +} diff --git a/freebuff/web/lib/billing/index.ts b/freebuff/web/lib/billing/index.ts new file mode 100644 index 0000000000..991485d11e --- /dev/null +++ b/freebuff/web/lib/billing/index.ts @@ -0,0 +1,211 @@ +/** + * Billing module - Centralized exports + * Clean import path: import { ... } from '@/vly/lib/billing' + * + * SOURCE OF TRUTH: + * - Data: @/autumn/constants + * - Functions: @/autumn/helpers + */ + +// ============================================================================ +// Types and Constants from autumn/constants.ts +// ============================================================================ + +export { + // Types + type TierName, + type TierFeatures, + type TierDefinition, + type BooleanFeatureId, + type AutumnCustomer, + type PricingCalculation, + type PlatformType, + + // Pricing constants + PLAN_PRICES, + PLAN_BASE_CREDITS, + PRICE_PER_MILLION_CREDITS, + CREDIT_BILLING_UNIT, + ORIGINAL_PRICES, + TIER_ORDER, + PLAN_IDS, + HIDDEN_TIERS, + TIER_LIMITS, + + // Tier definitions + TIER_DEFINITIONS, + + // Free tier credits (one-time grant) + FREE_TIER_CREDITS, + + // Feature IDs and mappings + ALL_BOOLEAN_FEATURE_IDS, + FEATURE_DISPLAY_NAMES, + FEATURE_MINIMUM_TIER, + + // Platform costs + PLATFORM_CREDIT_COSTS, +} from "@/vly/autumn/constants"; + +// ============================================================================ +// Functions from autumn/helpers.ts +// ============================================================================ + +export { + // Tier functions + getTierById, + getNextTier, + getPublicTiers, + getUpgradeTiersFor, + getPlanIdForTier, + isTierRecurring, + compareTiers, + isTierAtOrAbove, + getAvailableTiers, + + // Feature access functions + hasFeatureAccess, + hasAllFeaturesAccess, + hasAnyFeatureAccess, + getMissingFeatures, + getMinimumTierForFeature, + getFeatureAccessErrorMessage, + getPlanName, + getActivePlan, + + // Pricing functions + getPriceForTier, + getPricingForTier, + getPricingBreakdownForTier, + calculatePricePerCreditForTier, + calculateDiscountForTier, + getUpgradeOptions, + compareTierPricing, + calculateAnnualPriceForTier, + getHighlightedPlanId, + validateCredits, + + // Formatting utilities + formatCredits, + formatLargeNumber, + formatPrice, + formatCost, + getFormattedPrice, + getFormattedPriceWithPeriod, + getFormattedOriginalPrice, + + // Platform costs + calculatePlatformCost, +} from "@/vly/autumn/helpers"; + +// ============================================================================ +// Feature configuration, metadata, and pack configurations +// ============================================================================ + +export { + FEATURE_CONFIG, + getFeatureConfig, + getFeatureIcon, + getFeatureName, + getFeaturePackOptions, + formatFeatureValue, + getFeatureUnit, + calculateOverageCost, + calculateTotalOverageCost, + hasOveragePricing, + TOKEN_PACKS, + EMAIL_PACKS, + AI_PACKS, + CONVEX_FUNCTION_CALLS_PACKS, + CONVEX_COMPUTE_PACKS, + CONVEX_DATABASE_BW_PACKS, + CONVEX_FILE_BW_PACKS, + PACK_CONFIGS, + getPacksByFeatureId, + getPacksByProductId, + type FeatureConfig, + type PackOption, + type PackType, +} from "./feature-config"; + +// ============================================================================ +// Usage and activity utilities +// ============================================================================ + +export { + transformCustomerToUsageActivity, + calculateFeatureOverageCost, + calculateGroupOverageCost, + calculateGroupAverageUsage, + calculateGroupMaxUsage, + getUsageColorClasses, + extractCustomerFeatures, + calculateCreditPercentage, + type UsageActivity, + type FeatureWithId, + type ExtractedFeatures, +} from "./usage-utils"; + +// ============================================================================ +// Product configuration, icons, and utilities +// ============================================================================ + +export { + getProductIcon, + getProductColorClasses, + getProductButtonHoverClasses, + isKnownPackProduct, + filterProductsByInterval, + separateProductsByType, + PRODUCT_ICON_CONFIG, + type ProductIconConfig, +} from "./product-config"; + +// ============================================================================ +// Checkout utilities +// ============================================================================ + +export { + handleProductCheckout, + createCheckoutHandler, + handleDirectPlanCheckout, + createDirectPlanCheckoutHandler, + type DirectPlanCheckoutFn, +} from "./checkout-utils"; + +// ============================================================================ +// Workspace quota utilities +// ============================================================================ + +export { + getSandboxFeatureId, + checkSandboxQuota, + checkProjectWorkspaceQuota, + getAvailableDowngradeTiers, + getAutoDowngradeTier, + getTierQuotaInfo, + compareTierSize, + isUpgrade, + isDowngrade, + type SandboxQuotaCheck, + type ProjectForQuotaCheck, + type ProjectWorkspaceQuotaCheck, + type AvailableTier, +} from "./workspace-quota-utils"; + +// ============================================================================ +// Shared types +// ============================================================================ + +export type { + CustomerFeature, + BooleanFeature, + PaymentMethod, + CustomerProduct, + UsageColorScheme, + PlanConfig, + OverageResult, + UsageMetric, + BillingSectionProps, + CheckoutHandlerParams, +} from "./types"; diff --git a/freebuff/web/lib/billing/product-config.ts b/freebuff/web/lib/billing/product-config.ts new file mode 100644 index 0000000000..c55e10b20d --- /dev/null +++ b/freebuff/web/lib/billing/product-config.ts @@ -0,0 +1,305 @@ +/** + * Centralized product configuration system + * Maps product IDs to their display metadata, icons, and styling + * Also includes product filtering and organization utilities + */ + +import { + EmailIcon, + AIIcon, + ConvexFunctionCallsIcon, + ConvexComputeIcon, + ConvexDatabaseIcon, + ConvexFileIcon, +} from "@/vly/components/billing/icons"; +import { VlyCoin } from "@/vly/components/ui/vly-coin"; +import type { Product } from "autumn-js"; + +export interface ProductIconConfig { + icon: React.ComponentType<{ size?: "sm" | "md" | "lg" }>; + colorClasses: string; // Text color classes for product name + buttonHoverClasses?: string; // Optional button hover classes +} + +/** + * Product icon and styling configuration + * Used for add-on packs and credit products + */ +export const PRODUCT_ICON_CONFIG: Record<string, ProductIconConfig> = { + // Agent Credit Packs + token_pack: { + icon: VlyCoin, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + token_pack_small: { + icon: VlyCoin, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + token_pack_medium: { + icon: VlyCoin, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + token_pack_large: { + icon: VlyCoin, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // Credit Packs (one-time and recurring) + one_time_credit_pack: { + icon: VlyCoin, + colorClasses: "text-amber-700", + buttonHoverClasses: + "hover:border-amber-300/60 hover:bg-amber-100/60 hover:text-amber-900", + }, + recurring_credit_pack: { + icon: VlyCoin, + colorClasses: "text-green-700", + buttonHoverClasses: + "hover:border-green-300/60 hover:bg-green-100/60 hover:text-green-900", + }, + + // Email Packs + email_pack: { + icon: EmailIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + email_pack_small: { + icon: EmailIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + email_pack_medium: { + icon: EmailIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + email_pack_large: { + icon: EmailIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // AI Packs + ai_pack: { + icon: AIIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + ai_pack_small: { + icon: AIIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + ai_pack_medium: { + icon: AIIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + ai_pack_large: { + icon: AIIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // Convex Function Calls Packs + convex_function_calls_pack: { + icon: ConvexFunctionCallsIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_function_calls_pack_small: { + icon: ConvexFunctionCallsIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_function_calls_pack_medium: { + icon: ConvexFunctionCallsIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_function_calls_pack_large: { + icon: ConvexFunctionCallsIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // Convex Compute Packs + convex_compute_pack: { + icon: ConvexComputeIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_compute_pack_small: { + icon: ConvexComputeIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_compute_pack_medium: { + icon: ConvexComputeIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_compute_pack_large: { + icon: ConvexComputeIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // Convex Database BW Packs + convex_database_bw_pack: { + icon: ConvexDatabaseIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_database_bw_pack_small: { + icon: ConvexDatabaseIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_database_bw_pack_medium: { + icon: ConvexDatabaseIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_database_bw_pack_large: { + icon: ConvexDatabaseIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + + // Convex File BW Packs + convex_file_bw_pack: { + icon: ConvexFileIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_file_bw_pack_small: { + icon: ConvexFileIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_file_bw_pack_medium: { + icon: ConvexFileIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, + convex_file_bw_pack_large: { + icon: ConvexFileIcon, + colorClasses: "text-purple-700", + buttonHoverClasses: + "hover:border-purple-300/60 hover:bg-purple-100/60 hover:text-purple-900", + }, +}; + +/** + * Get icon component for a product ID + * Falls back to VlyCoin for unknown products + */ +export function getProductIcon( + productId: string, +): React.ComponentType<{ size?: "sm" | "md" | "lg" }> { + const config = PRODUCT_ICON_CONFIG[productId]; + return config?.icon ?? VlyCoin; +} + +/** + * Get color classes for a product ID's name/title + * Falls back to default zinc-700 for unknown products + */ +export function getProductColorClasses(productId: string): string { + const config = PRODUCT_ICON_CONFIG[productId]; + return config?.colorClasses ?? "text-zinc-700"; +} + +/** + * Get button hover classes for a product ID + * Returns empty string if no special hover classes defined + */ +export function getProductButtonHoverClasses(productId: string): string { + const config = PRODUCT_ICON_CONFIG[productId]; + return config?.buttonHoverClasses ?? ""; +} + +/** + * Check if a product ID is a known add-on pack + */ +export function isKnownPackProduct(productId: string): boolean { + return productId in PRODUCT_ICON_CONFIG; +} + +// ============================================================================ +// Product Filtering & Organization Utilities +// ============================================================================ + +/** + * Filter products by billing interval (monthly/annual) + * Used for interval toggle in pricing tables + */ +export function filterProductsByInterval( + products: Product[] | undefined | null, + isAnnual: boolean, + multiInterval: boolean, +): Product[] { + if (!products) return []; + + const intervalFilter = (product: Product) => { + if (!product.properties?.interval_group) { + return true; + } + + if (multiInterval) { + if (isAnnual) { + return product.properties?.interval_group === "year"; + } else { + return product.properties?.interval_group === "month"; + } + } + + return true; + }; + + return products.filter(intervalFilter); +} + +/** + * Separate products into plans and add-ons + * Plans are primary subscriptions, add-ons are optional extras + */ +export function separateProductsByType(products: Product[]) { + return { + plans: products.filter((product) => !product.is_add_on), + addOns: products.filter((product) => product.is_add_on), + }; +} diff --git a/freebuff/web/lib/billing/types.ts b/freebuff/web/lib/billing/types.ts new file mode 100644 index 0000000000..2ec122bf8b --- /dev/null +++ b/freebuff/web/lib/billing/types.ts @@ -0,0 +1,207 @@ +/** + * Shared TypeScript types for billing system + * + * This is the SINGLE SOURCE OF TRUTH for billing-related types. + * All billing files should import types from here instead of defining their own. + * + * Type Categories: + * - Product types: ProductIconConfig + * - Customer types: AutumnCustomer, CustomerProduct, PaymentMethod, CustomerFeature, BooleanFeature + * - Usage types: UsageActivity, UsageColorScheme, UsageMetric + * - Billing types: PlanConfig, OverageResult, CheckoutHandlerParams + * + * Note: PackOption and FeatureConfig are defined in feature-config.ts + */ + +import type { LucideIcon } from "lucide-react"; +import type { Product } from "autumn-js"; + +/** + * Product icon and styling configuration + */ +export interface ProductIconConfig { + icon: React.ComponentType<{ size?: "sm" | "md" | "lg" }>; + colorClasses: string; + buttonHoverClasses?: string; +} + +/** + * Usage activity entry for display + */ +export interface UsageActivity { + featureId: string; + featureName: string; + usage: number; + balance: number; + icon: LucideIcon; +} + +/** + * Customer feature data from Autumn + */ +export interface CustomerFeature { + usage?: number; + balance?: number | null; // null allowed to match Autumn API responses + included_usage?: number | "inf"; // "inf" represents unlimited usage + unlimited?: boolean; // Alternative way Autumn API indicates unlimited + next_reset_at?: number; +} + +/** + * Autumn payment method data + */ +export interface PaymentMethod { + card?: { + brand?: string; + last4?: string; + exp_month?: number; + exp_year?: number; + }; +} + +/** + * Autumn product subscription data + */ +export interface CustomerProduct { + id: string; + name?: string | null; + group?: string | null; + status?: string; + scenario?: string; + is_add_on?: boolean; + canceled_at?: number | null; + started_at?: number; + current_period_start?: number | null; + current_period_end?: number | null; + items?: Array<{ + feature_id?: string; + included_usage?: number | "inf"; + }>; +} + +/** + * Boolean feature data from Autumn (for boolean-type features like GitHub integration) + */ +export interface BooleanFeature { + has_access?: boolean; +} + +/** + * Autumn customer object with typed features + * Extended to support all tier features from autumn/constants.ts + */ +export interface AutumnCustomer { + id: string | null; + payment_method?: PaymentMethod | null; + products?: CustomerProduct[]; + features?: { + // Usage-based features + agent_credits?: CustomerFeature; + email_integration?: CustomerFeature; + llm_integration?: CustomerFeature; + convex_function_calls?: CustomerFeature; + convex_compute?: CustomerFeature; + convex_database_bw?: CustomerFeature; + convex_file_bw?: CustomerFeature; + seats?: CustomerFeature; + + // Starter tier boolean features + documentation_visualizer?: BooleanFeature; + database_preview?: BooleanFeature; + no_vlyai_branding?: BooleanFeature; + custom_domains?: BooleanFeature; + + // Hobby tier boolean features + team_collaboration?: BooleanFeature; + integrations_library?: BooleanFeature; + project_code_editor?: BooleanFeature; + agent_context_length?: BooleanFeature; + + // Business tier boolean features + github_integration?: BooleanFeature; + convex_logs?: BooleanFeature; + cli_agent_access?: BooleanFeature; + in_app_support?: BooleanFeature; + private_projects?: BooleanFeature; + + // Scale tier boolean features + claude_opus_access?: BooleanFeature; + ui_components_library?: BooleanFeature; + theme_customization?: BooleanFeature; + data_transfer?: BooleanFeature; + + // Priority tier boolean features + personal_phone_support?: BooleanFeature; + + // Max tier boolean features + hire_developers?: BooleanFeature; + unlimited_projects?: BooleanFeature; + + // Sandbox features + sandbox_small?: CustomerFeature; + sandbox_medium?: CustomerFeature; + sandbox_large?: CustomerFeature; + + // Team features + total_members?: CustomerFeature; + + // Legacy - kept for backwards compatibility + members_per_project?: CustomerFeature; + + // Legacy features (for backwards compatibility) + abstraction_document?: BooleanFeature; + convex_database?: BooleanFeature; + integrations_access?: BooleanFeature; + }; +} + +/** + * Usage color scheme for visual feedback + */ +export interface UsageColorScheme { + text: string; + bg: string; +} + +/** + * Plan configuration + */ +export interface PlanConfig { + id: string; + name: string; + displayName?: string; +} + +/** + * Overage calculation result + */ +export interface OverageResult { + featureId: string; + overageAmount: number; + cost: number; +} + +/** + * Metrics for usage calculation + */ +export interface UsageMetric { + usage: number; + includedUsage: number; +} + +/** + * Billing section props + */ +export interface BillingSectionProps { + organizationId?: string; +} + +/** + * Checkout handler params + */ +export interface CheckoutHandlerParams { + product: Product; + customer: any; + checkout: (options: { productId: string; dialog: any }) => Promise<any>; + checkoutDialog: any; +} diff --git a/freebuff/web/lib/billing/usage-utils.ts b/freebuff/web/lib/billing/usage-utils.ts new file mode 100644 index 0000000000..d83b77667c --- /dev/null +++ b/freebuff/web/lib/billing/usage-utils.ts @@ -0,0 +1,261 @@ +/** + * Customer & Usage Utilities + * + * Consolidated module for: + * - Customer data extraction and processing + * - Usage activity transformation + * - Credit calculations + */ + +import type { LucideIcon } from "lucide-react"; +import { FEATURE_CONFIG } from "./feature-config"; +import type { CustomerFeature, BooleanFeature } from "./types"; +import type { AutumnCustomer } from "@/vly/autumn/constants"; + +// ============================================================================ +// Customer Data Extraction +// ============================================================================ + +/** + * Extracted customer features with typed access + */ +export interface ExtractedFeatures { + agent_credits?: CustomerFeature; + emailIntegration?: CustomerFeature; + llmIntegration?: CustomerFeature; + convexFunctionCalls?: CustomerFeature; + convexCompute?: CustomerFeature; + convexDatabaseBW?: CustomerFeature; + convexFileBW?: CustomerFeature; + seats?: CustomerFeature; + github_integration?: BooleanFeature; +} + +/** + * Extract all customer features with type safety + * Provides centralized access to Autumn customer feature data + */ +export function extractCustomerFeatures( + customer: AutumnCustomer | null | undefined, +): ExtractedFeatures { + return { + agent_credits: customer?.features?.agent_credits, + emailIntegration: customer?.features?.email_integration, + llmIntegration: customer?.features?.llm_integration, + convexFunctionCalls: customer?.features?.convex_function_calls, + convexCompute: customer?.features?.convex_compute, + convexDatabaseBW: customer?.features?.convex_database_bw, + convexFileBW: customer?.features?.convex_file_bw, + seats: customer?.features?.seats, + github_integration: customer?.features?.github_integration, + }; +} + +/** + * Calculate credit usage percentage + * Returns percentage of credits used (0-100+) + */ +export function calculateCreditPercentage( + totalCredits: number, + remainingCredits: number, +): number { + if (totalCredits === 0) return 0; + return ((totalCredits - remainingCredits) / totalCredits) * 100; +} + +// ============================================================================ +// Usage Activity Utilities +// ============================================================================ + +export interface UsageActivity { + featureId: string; + featureName: string; + usage: number; + balance: number; + icon: LucideIcon; +} + +export interface FeatureWithId { + featureId: string; + feature: CustomerFeature | undefined; +} + +/** + * Transform customer features into usage activity entries + * Filters and sorts features by usage amount + */ +export function transformCustomerToUsageActivity( + customer: AutumnCustomer | null | undefined, + options: { + vlyIntegrationsEnabled?: boolean; + } = {}, +): UsageActivity[] { + const { vlyIntegrationsEnabled = false } = options; + const usageActivity: UsageActivity[] = []; + + if (!customer?.features) { + return usageActivity; + } + + // Build usage activity from customer features + for (const [featureId, feature] of Object.entries(customer.features)) { + // Skip integration features if vly_integrations_enabled flag is off + if ( + !vlyIntegrationsEnabled && + (featureId === "email_integration" || featureId === "llm_integration") + ) { + continue; + } + + const config = FEATURE_CONFIG[featureId]; + const featureData = feature as CustomerFeature | undefined; + + // Only include features with actual usage + if (config && featureData?.usage && featureData.usage > 0) { + usageActivity.push({ + featureId, + featureName: config.name, + usage: featureData.usage || 0, + balance: featureData.balance || 0, + icon: config.icon, + }); + } + } + + // Sort by usage (highest first) + usageActivity.sort((a, b) => b.usage - a.usage); + + return usageActivity; +} + +/** + * Generic overage cost calculation for a single feature + * Uses configuration from FEATURE_CONFIG instead of hardcoded prices + */ +export function calculateFeatureOverageCost( + featureId: string, + feature: CustomerFeature | undefined, +): number { + const config = FEATURE_CONFIG[featureId]; + if (!config?.overagePrice || !feature?.included_usage) { + return 0; + } + + // "inf" or unlimited flag means unlimited, so no overage + if (feature.unlimited === true || feature.included_usage === "inf") { + return 0; + } + + // Calculate consumed amount: consumed = included_usage - balance + // balance represents what's remaining, so consumed is the difference + const balance = feature.balance || 0; + const usedAmount = Math.max(0, feature.included_usage - balance); + + if (usedAmount <= feature.included_usage) { + return 0; + } + + const overage = usedAmount - feature.included_usage; + const billingUnit = config.overageBillingUnit ?? 1; + return (overage / billingUnit) * config.overagePrice; +} + +/** + * Calculate total overage cost across multiple features + * Generic replacement for calculateConvexOverageCost and calculateIntegrationOverageCost + */ +export function calculateGroupOverageCost(features: FeatureWithId[]): number { + return features.reduce((total, { featureId, feature }) => { + return total + calculateFeatureOverageCost(featureId, feature); + }, 0); +} + +/** + * Calculate average usage percentage across multiple features + * Generic replacement for calculateConvexAverageUsage and calculateIntegrationAverageUsage + */ +export function calculateGroupAverageUsage( + features: Array<{ feature: CustomerFeature | undefined }>, +): number { + const percentages = features + .map(({ feature }) => { + if ( + !feature?.included_usage || + feature.included_usage === 0 || + feature.unlimited === true || + feature.included_usage === "inf" + ) { + return null; + } + // Calculate consumed amount: consumed = included_usage - balance + // balance represents what's remaining, so consumed is the difference + const balance = feature.balance || 0; + const usedAmount = Math.max(0, feature.included_usage - balance); + return (usedAmount / feature.included_usage) * 100; + }) + .filter((p): p is number => p !== null); + + if (percentages.length === 0) { + return 0; + } + + return percentages.reduce((a, b) => a + b, 0) / percentages.length; +} + +/** + * Calculate maximum usage percentage across multiple features + * Returns the highest usage percentage along with the feature name + */ +export function calculateGroupMaxUsage( + features: Array<{ featureId: string; feature: CustomerFeature | undefined }>, +): { percentage: number; featureName: string } { + let maxPercentage = 0; + let maxFeatureId = ""; + + for (const { featureId, feature } of features) { + if ( + !feature?.included_usage || + feature.included_usage === 0 || + feature.unlimited === true || + feature.included_usage === "inf" + ) { + continue; + } + + // Calculate consumed amount: consumed = included_usage - balance + const balance = feature.balance || 0; + const usedAmount = Math.max(0, feature.included_usage - balance); + const percentage = (usedAmount / feature.included_usage) * 100; + + if (percentage > maxPercentage) { + maxPercentage = percentage; + maxFeatureId = featureId; + } + } + + // Get the feature name from config + const config = FEATURE_CONFIG[maxFeatureId]; + const featureName = config?.name || maxFeatureId; + + return { + percentage: maxPercentage, + featureName, + }; +} + +/** + * Get color classes based on usage percentage + * Used for displaying usage levels with appropriate visual feedback + */ +export function getUsageColorClasses(percentage: number): { + text: string; + bg: string; +} { + if (percentage >= 75) { + return { text: "text-orange-700", bg: "bg-orange-100" }; + } + if (percentage >= 50) { + return { text: "text-yellow-700", bg: "bg-yellow-100" }; + } + return { text: "text-green-700", bg: "bg-green-100" }; +} diff --git a/freebuff/web/lib/billing/workspace-quota-utils.ts b/freebuff/web/lib/billing/workspace-quota-utils.ts new file mode 100644 index 0000000000..73223fbd64 --- /dev/null +++ b/freebuff/web/lib/billing/workspace-quota-utils.ts @@ -0,0 +1,206 @@ +/** + * Workspace quota utilities for checking and managing workspace tier limits + * Includes sandbox feature ID mapping and quota checking + */ + +import type { SandboxSize } from "@/vly/lib/sandbox-specs"; +import type { AutumnCustomer } from "@/vly/autumn/constants"; +import { sandboxSmall, sandboxMedium, sandboxLarge } from "@/vly/autumn.config"; + +// ============================================================================ +// Sandbox Feature ID Mapping +// ============================================================================ + +/** + * Maps sandbox size to Autumn feature ID + */ +export function getSandboxFeatureId(size: SandboxSize): string { + switch (size) { + case "small": + return sandboxSmall.id; + case "medium": + return sandboxMedium.id; + case "large": + return sandboxLarge.id; + default: + // This should never happen with proper typing + return sandboxSmall.id; + } +} + +/** + * Result of checking sandbox quota + */ +export type SandboxQuotaCheck = { + allowed: boolean; + current: number; + limit: number | "inf"; // "inf" means unlimited + reason?: string; +}; + +/** + * Checks if a user can upgrade/create a sandbox of a given size + * This function should be called from the frontend with Autumn data + */ +export function checkSandboxQuota( + size: SandboxSize, + currentUsage: number, + limit: number | "inf", +): SandboxQuotaCheck { + void size; + void limit; + + return { + allowed: true, + current: currentUsage, + limit: "inf", + }; +} + +// ============================================================================ +// Project Workspace Quota Checking +// ============================================================================ + +/** + * Project type for quota checking (minimal required fields) + */ +export interface ProjectForQuotaCheck { + sandbox_size?: "small" | "medium" | "large"; + _id: string; +} + +/** + * Result of checking if a project's workspace exceeds plan quota + */ +export interface ProjectWorkspaceQuotaCheck { + allowed: boolean; + projectSize: SandboxSize; + reason?: string; + hasFeature: boolean; + currentUsage?: number; + limit?: number | "inf"; +} + +/** + * Available tier for downgrade + */ +export interface AvailableTier { + size: SandboxSize; + hasQuota: boolean; + currentUsage: number; + limit: number | "inf"; +} + +/** + * Checks if a project's workspace tier is allowed by the user's current plan + * This is used to determine if the project should be blocked from opening + */ +export function checkProjectWorkspaceQuota( + project: ProjectForQuotaCheck, + customer: AutumnCustomer | null | undefined, +): ProjectWorkspaceQuotaCheck { + void customer; + + const projectSize = project.sandbox_size ?? "small"; + + return { + allowed: true, + projectSize, + hasFeature: true, + limit: "inf", + }; +} + +/** + * Gets available tiers that the user can downgrade to based on their plan quota + * Returns tiers in descending order (largest to smallest) + */ +export function getAvailableDowngradeTiers( + currentSize: SandboxSize, + customer: AutumnCustomer | null | undefined, +): AvailableTier[] { + const sizes: SandboxSize[] = ["large", "medium", "small"]; + const availableTiers: AvailableTier[] = []; + + for (const size of sizes) { + // Don't include current size or larger + if (compareTierSize(size, currentSize) >= 0) { + continue; + } + + const tierInfo = getTierQuotaInfo(size, customer); + availableTiers.push(tierInfo); + } + + return availableTiers; +} + +/** + * Gets the highest tier that the user's plan allows + * Returns "small" if no plan or only small is available + */ +export function getAutoDowngradeTier( + customer: AutumnCustomer | null | undefined, +): SandboxSize { + const sizes: SandboxSize[] = ["large", "medium", "small"]; + + for (const size of sizes) { + const tierInfo = getTierQuotaInfo(size, customer); + if (tierInfo.hasQuota) { + return size; + } + } + + // Fallback to small (always available) + return "small"; +} + +/** + * Gets quota information for a specific tier + */ +export function getTierQuotaInfo( + size: SandboxSize, + customer: AutumnCustomer | null | undefined, +): AvailableTier { + void customer; + + return { + size, + hasQuota: true, + currentUsage: 0, + limit: "inf", + }; +} + +/** + * Compares two tier sizes + * Returns: -1 if a < b, 0 if a === b, 1 if a > b + */ +export function compareTierSize(a: SandboxSize, b: SandboxSize): number { + const order: Record<SandboxSize, number> = { + small: 0, + medium: 1, + large: 2, + }; + return order[a] - order[b]; +} + +/** + * Checks if a tier change is an upgrade (target > current) + */ +export function isUpgrade( + currentSize: SandboxSize, + targetSize: SandboxSize, +): boolean { + return compareTierSize(targetSize, currentSize) > 0; +} + +/** + * Checks if a tier change is a downgrade (target < current) + */ +export function isDowngrade( + currentSize: SandboxSize, + targetSize: SandboxSize, +): boolean { + return compareTierSize(targetSize, currentSize) < 0; +} diff --git a/freebuff/web/lib/coding-agent/contextLengthPresets.ts b/freebuff/web/lib/coding-agent/contextLengthPresets.ts new file mode 100644 index 0000000000..2c4f987fe7 --- /dev/null +++ b/freebuff/web/lib/coding-agent/contextLengthPresets.ts @@ -0,0 +1,75 @@ +export const DEFAULT_CONTEXT_LENGTH = "small" as const; + +export type ContextLength = "small" | "medium" | "long"; + +export interface ContextLengthPreset { + label: string; + description: string; + /** Max chat messages loaded from DB for agent context (getContextData). */ + maxContextMessages: number; + chatThresholds: { + firstThreshold: number; + secondThreshold: number; + thirdThreshold: number; + }; + historyCompaction: { + compactAtTokens: number; + targetSummaryTokens: number; + }; + fileTokensInContext: number; + costMultiplier: number; +} + +export const CONTEXT_LENGTH_PRESETS: Record< + ContextLength, + ContextLengthPreset +> = { + small: { + label: "Short", + description: "Compact at 40k tokens and shrink summaries to 5k max", + maxContextMessages: 48, + chatThresholds: { + firstThreshold: 5000, + secondThreshold: 6000, + thirdThreshold: 9000, + }, + historyCompaction: { + compactAtTokens: 40_000, + targetSummaryTokens: 5_000, + }, + fileTokensInContext: 14000, + costMultiplier: 1, + }, + medium: { + label: "Medium", + description: "Compact at 80k tokens and shrink summaries to 10k max", + maxContextMessages: 100, + chatThresholds: { + firstThreshold: 7500, + secondThreshold: 9000, + thirdThreshold: 13500, + }, + historyCompaction: { + compactAtTokens: 80_000, + targetSummaryTokens: 10_000, + }, + fileTokensInContext: 21000, + costMultiplier: 1.5, + }, + long: { + label: "Long", + description: "Compact at 150k tokens and shrink summaries to 30k max", + maxContextMessages: 160, + chatThresholds: { + firstThreshold: 10000, + secondThreshold: 12000, + thirdThreshold: 18000, + }, + historyCompaction: { + compactAtTokens: 150_000, + targetSummaryTokens: 30_000, + }, + fileTokensInContext: 28000, + costMultiplier: 2, + }, +}; diff --git a/freebuff/web/lib/googleFonts.ts b/freebuff/web/lib/googleFonts.ts new file mode 100644 index 0000000000..3663548842 --- /dev/null +++ b/freebuff/web/lib/googleFonts.ts @@ -0,0 +1,236 @@ +// Google Fonts integration +export interface GoogleFont { + family: string; + variants: string[]; + subsets: string[]; + category: "sans-serif" | "serif" | "monospace" | "display" | "handwriting"; + popularity?: number; +} + +// Curated list of reliable Google Fonts (5-7 per category) +export const GOOGLE_FONTS: GoogleFont[] = [ + // Sans-serif fonts (most popular and reliable) + { + family: "Inter", + variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + category: "sans-serif", + popularity: 1, + }, + { + family: "Roboto", + variants: ["100", "300", "400", "500", "700", "900"], + subsets: ["latin"], + category: "sans-serif", + popularity: 2, + }, + { + family: "Open Sans", + variants: ["300", "400", "500", "600", "700", "800"], + subsets: ["latin"], + category: "sans-serif", + popularity: 3, + }, + { + family: "Poppins", + variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + category: "sans-serif", + popularity: 4, + }, + { + family: "Montserrat", + variants: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + category: "sans-serif", + popularity: 5, + }, + { + family: "Lato", + variants: ["100", "300", "400", "700", "900"], + subsets: ["latin"], + category: "sans-serif", + popularity: 6, + }, + + // Serif fonts (most popular and reliable) + { + family: "Playfair Display", + variants: ["400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + category: "serif", + popularity: 1, + }, + { + family: "Merriweather", + variants: ["300", "400", "700", "900"], + subsets: ["latin"], + category: "serif", + popularity: 2, + }, + { + family: "Lora", + variants: ["400", "500", "600", "700"], + subsets: ["latin"], + category: "serif", + popularity: 3, + }, + { + family: "Source Serif Pro", + variants: ["200", "300", "400", "600", "700", "900"], + subsets: ["latin"], + category: "serif", + popularity: 4, + }, + { + family: "Libre Baskerville", + variants: ["400", "700"], + subsets: ["latin"], + category: "serif", + popularity: 5, + }, + { + family: "Crimson Text", + variants: ["400", "600", "700"], + subsets: ["latin"], + category: "serif", + popularity: 6, + }, + + // Monospace fonts (most popular and reliable) + { + family: "JetBrains Mono", + variants: ["100", "200", "300", "400", "500", "600", "700", "800"], + subsets: ["latin"], + category: "monospace", + popularity: 1, + }, + { + family: "Fira Code", + variants: ["300", "400", "500", "600", "700"], + subsets: ["latin"], + category: "monospace", + popularity: 2, + }, + { + family: "Roboto Mono", + variants: ["100", "200", "300", "400", "500", "600", "700"], + subsets: ["latin"], + category: "monospace", + popularity: 3, + }, + { + family: "Source Code Pro", + variants: ["200", "300", "400", "500", "600", "700", "800", "900"], + subsets: ["latin"], + category: "monospace", + popularity: 4, + }, + { + family: "Space Mono", + variants: ["400", "700"], + subsets: ["latin"], + category: "monospace", + popularity: 5, + }, +]; + +// System fonts that don't need Google Fonts +export const SYSTEM_FONTS: GoogleFont[] = [ + { + family: "system-ui", + variants: ["400"], + subsets: ["latin"], + category: "sans-serif", + }, + { + family: "BlinkMacSystemFont", + variants: ["400"], + subsets: ["latin"], + category: "sans-serif", + }, + { + family: "Helvetica Neue", + variants: ["400"], + subsets: ["latin"], + category: "sans-serif", + }, + { + family: "Arial", + variants: ["400"], + subsets: ["latin"], + category: "sans-serif", + }, + { + family: "Georgia", + variants: ["400"], + subsets: ["latin"], + category: "serif", + }, + { + family: "Times New Roman", + variants: ["400"], + subsets: ["latin"], + category: "serif", + }, + { + family: "Menlo", + variants: ["400"], + subsets: ["latin"], + category: "monospace", + }, + { + family: "Monaco", + variants: ["400"], + subsets: ["latin"], + category: "monospace", + }, +]; + +export function getAllFonts(): GoogleFont[] { + return [...SYSTEM_FONTS, ...GOOGLE_FONTS]; +} + +export function getFontsByCategory( + category: GoogleFont["category"], +): GoogleFont[] { + return getAllFonts().filter((font) => font.category === category); +} + +export function searchFonts( + query: string, + category?: GoogleFont["category"], +): GoogleFont[] { + const fonts = category ? getFontsByCategory(category) : getAllFonts(); + if (!query.trim()) return fonts; + + return fonts.filter((font) => + font.family.toLowerCase().includes(query.toLowerCase()), + ); +} + +export function preloadFont(fontFamily: string): void { + if (typeof window === "undefined") return; + + const font = GOOGLE_FONTS.find((f) => f.family === fontFamily); + if (!font) return; + + // Use the consolidated font generation logic from themeUtils + // This avoids duplicating the Google Fonts URL generation logic + const fontName = fontFamily.replace(/\s+/g, "+"); + const url = `https://fonts.googleapis.com/css2?family=${fontName}:wght@100;200;300;400;500;600;700;800;900&display=swap`; + + // Check if already loaded + const existingLink = document.querySelector(`link[href="${url}"]`); + if (existingLink) return; + + // Create link element + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.onload = () => { + console.log(`Font ${fontFamily} loaded successfully`); + }; + + document.head.appendChild(link); +} diff --git a/freebuff/web/lib/hooks/use-debounce.ts b/freebuff/web/lib/hooks/use-debounce.ts new file mode 100644 index 0000000000..d39df3f066 --- /dev/null +++ b/freebuff/web/lib/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce<T>(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState<T>(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/freebuff/web/lib/image-utils.ts b/freebuff/web/lib/image-utils.ts new file mode 100644 index 0000000000..0b7e6cd869 --- /dev/null +++ b/freebuff/web/lib/image-utils.ts @@ -0,0 +1,19 @@ +import { Id } from "@/convex/_generated/dataModel"; + +/** + * Generate a secure image URL for a Convex storage ID. + * This is a client-side utility that constructs the URL to the Convex HTTP endpoint + * that serves images securely. + * + * @param storageId - The Convex storage ID for the image + * @returns The complete URL to fetch the image + */ +export function getImageUrl(storageId: Id<"_storage">): string { + const convexSiteUrl = process.env.NEXT_PUBLIC_CONVEX_SITE_URL; + + if (!convexSiteUrl) { + throw new Error("NEXT_PUBLIC_CONVEX_SITE_URL is not configured"); + } + + return `${convexSiteUrl}/image?storageId=${storageId}`; +} diff --git a/freebuff/web/lib/monitoring/chart-data-processors.ts b/freebuff/web/lib/monitoring/chart-data-processors.ts new file mode 100644 index 0000000000..bc8987c84f --- /dev/null +++ b/freebuff/web/lib/monitoring/chart-data-processors.ts @@ -0,0 +1,299 @@ +import type { + UsageMetricsResponse, + SandboxMetricsHistory, + TimeRange, +} from "./monitoring-types"; + +// Determine bucket size in milliseconds based on time range +function getBucketSize( + timeRangeType: TimeRange, + startTime: string, + endTime: string, +): number { + const durationMs = + new Date(endTime).getTime() - new Date(startTime).getTime(); + + switch (timeRangeType) { + case "billing_cycle": + // For billing cycle, use fine granularity to show individual events + if (durationMs <= 2 * 60 * 60 * 1000) { + return 5 * 60 * 1000; // 5 minutes + } else if (durationMs <= 7 * 24 * 60 * 60 * 1000) { + return 15 * 60 * 1000; // 15 minutes (for up to 7 days) + } else if (durationMs <= 30 * 24 * 60 * 60 * 1000) { + return 30 * 60 * 1000; // 30 minutes (for 7-30 days) + } else { + return 60 * 60 * 1000; // 1 hour (for > 30 days) + } + case "5m": + return 30 * 1000; // 30 seconds + case "1h": + return 5 * 60 * 1000; // 5 minutes + case "24h": + return 60 * 60 * 1000; // 1 hour + case "7d": + return 6 * 60 * 60 * 1000; // 6 hours + case "custom": + // Auto-determine based on duration with fine granularity + if (durationMs <= 2 * 60 * 60 * 1000) { + // <= 2 hours + return 5 * 60 * 1000; // 5 minutes + } else if (durationMs <= 7 * 24 * 60 * 60 * 1000) { + // <= 7 days + return 15 * 60 * 1000; // 15 minutes + } else if (durationMs <= 30 * 24 * 60 * 60 * 1000) { + // <= 30 days + return 30 * 60 * 1000; // 30 minutes + } else { + return 60 * 60 * 1000; // 1 hour + } + } +} + +// Format time label based on bucket size +function formatTimeLabel(timestamp: number, bucketSize: number): string { + const date = new Date(timestamp); + + // For buckets >= 1 day, show date + if (bucketSize >= 24 * 60 * 60 * 1000) { + return date.toLocaleDateString([], { + month: "short", + day: "numeric", + }); + } + + // For buckets >= 6 hours, show time with date + if (bucketSize >= 6 * 60 * 60 * 1000) { + return date.toLocaleString([], { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + // For smaller buckets, show time only + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +// Process Convex usage data for charting with time-based bucketing +export function processConvexChartData( + timeSeries: UsageMetricsResponse["timeSeries"], + timeRangeType: TimeRange, + startTime: string, + endTime: string, + deploymentNames?: { + devDeploymentName?: string; + prodDeploymentName?: string; + }, +) { + const bucketSize = getBucketSize(timeRangeType, startTime, endTime); + const startMs = new Date(startTime).getTime(); + const endMs = new Date(endTime).getTime(); + + // Check if we have dev/prod separation in the data + const hasDeploymentTypes = timeSeries.some( + (point) => point.deploymentType !== undefined, + ); + + // Create buckets for the entire time range + const buckets = new Map< + number, + { + time: number; + timeLabel: string; + timeRangeLabel: string; + executions: number; + devExecutions: number; + prodExecutions: number; + compute: number; + dbBandwidth: number; + fileBandwidth: number; + } + >(); + + // Initialize all buckets with zeros + for ( + let bucketStart = startMs; + bucketStart < endMs; + bucketStart += bucketSize + ) { + const bucketEnd = Math.min(bucketStart + bucketSize, endMs); + buckets.set(bucketStart, { + time: bucketStart, + timeLabel: formatTimeLabel(bucketStart, bucketSize), + timeRangeLabel: `${formatTimeLabel(bucketStart, bucketSize)} - ${formatTimeLabel(bucketEnd, bucketSize)}`, + executions: 0, + devExecutions: 0, + prodExecutions: 0, + compute: 0, + dbBandwidth: 0, + fileBandwidth: 0, + }); + } + + // Aggregate data points into buckets + timeSeries.forEach((point) => { + const pointTime = new Date(point.timestamp).getTime(); + + // Find which bucket this point belongs to + const bucketStart = + Math.floor((pointTime - startMs) / bucketSize) * bucketSize + startMs; + + const bucket = buckets.get(bucketStart); + if (bucket) { + bucket.executions += point.executionCount; + + // Track dev/prod separately if deployment type is available + if (hasDeploymentTypes && point.deploymentType) { + if (point.deploymentType === "dev") { + bucket.devExecutions += point.executionCount; + } else if (point.deploymentType === "prod") { + bucket.prodExecutions += point.executionCount; + } + } + + bucket.compute += point.costs.compute.gbHours; + bucket.dbBandwidth += + (point.dbReadBytes + point.dbWriteBytes) / (1024 * 1024 * 1024); + bucket.fileBandwidth += + (point.fileStorageReadBytes + point.fileStorageWriteBytes) / + (1024 * 1024 * 1024); + } + }); + + const convexUsageData = Array.from(buckets.values()).sort( + (a, b) => a.time - b.time, + ); + + const timeRange: [number, number] | ["auto", "auto"] = + convexUsageData.length > 0 ? [startMs, endMs] : ["auto", "auto"]; + + // Calculate Y-axis domain with minimal padding + const maxExecutions = Math.max( + ...convexUsageData.map((d) => d.executions), + 0, + ); + const executionsMax = maxExecutions * 1.05 || 1; + + return { + data: convexUsageData, + timeRange, + executionsMax, + hasDeploymentTypes, + devDeploymentName: deploymentNames?.devDeploymentName, + prodDeploymentName: deploymentNames?.prodDeploymentName, + }; +} + +// Process sandbox CPU & Memory data for charting +export function processSandboxCpuMemoryData( + timeSeries: SandboxMetricsHistory["timeSeries"], + processWithGaps: (timeSeries: SandboxMetricsHistory["timeSeries"]) => Array<{ + timestamp: string; + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + diskUsagePercent: number | null; + [key: string]: number | string | null; + }>, +) { + const processed = processWithGaps(timeSeries).map((point) => ({ + time: new Date(point.timestamp).getTime(), + cpu: + point.cpuUsagePercent !== null ? point.cpuUsagePercent.toFixed(1) : null, + memory: + point.memoryUsagePercent !== null + ? point.memoryUsagePercent.toFixed(1) + : null, + })); + + // Deduplicate by timestamp + const seen = new Set<number>(); + return processed.filter((point) => { + if (seen.has(point.time)) return false; + seen.add(point.time); + return true; + }); +} + +// Process sandbox disk data for charting +export function processSandboxDiskData( + timeSeries: SandboxMetricsHistory["timeSeries"], + processWithGaps: (timeSeries: SandboxMetricsHistory["timeSeries"]) => Array<{ + timestamp: string; + diskUsagePercent: number | null; + [key: string]: number | string | null; + }>, +) { + const processed = processWithGaps(timeSeries).map((point) => ({ + time: new Date(point.timestamp).getTime(), + disk: point.diskUsagePercent, + })); + + // Deduplicate by timestamp + const seen = new Set<number>(); + return processed.filter((point) => { + if (seen.has(point.time)) return false; + seen.add(point.time); + return true; + }); +} + +// Process combined sandbox resources data (CPU, Memory, Disk) for charting +export function processSandboxResourcesData( + timeSeries: SandboxMetricsHistory["timeSeries"], + processWithGaps: (timeSeries: SandboxMetricsHistory["timeSeries"]) => Array<{ + timestamp: string; + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + diskUsagePercent: number | null; + [key: string]: number | string | null; + }>, +) { + const processed = processWithGaps(timeSeries).map((point) => ({ + time: new Date(point.timestamp).getTime(), + cpu: + point.cpuUsagePercent !== null ? point.cpuUsagePercent.toFixed(1) : null, + memory: + point.memoryUsagePercent !== null + ? point.memoryUsagePercent.toFixed(1) + : null, + disk: point.diskUsagePercent, + })); + + // Deduplicate by timestamp to prevent duplicate keys in chart + const seen = new Set<number>(); + return processed.filter((point) => { + if (seen.has(point.time)) { + return false; + } + seen.add(point.time); + return true; + }); +} + +// Calculate current resource usage from sandbox stats +export function calculateCurrentUsage(sandboxStats: { + cpu: { usage_percent: number; limit_cores: number }; + memory: { used_bytes: number }; + disk: { used_bytes: number }; +}) { + // CPU: Convert usage percentage and limit to actual cores + const cpuUsageCores = + (sandboxStats.cpu.usage_percent / 100) * sandboxStats.cpu.limit_cores; + + // RAM: Convert bytes to GB + const ramUsageGB = sandboxStats.memory.used_bytes / (1024 * 1024 * 1024); + + // Disk: Convert bytes to GB + const diskUsageGB = sandboxStats.disk.used_bytes / (1024 * 1024 * 1024); + + return { + cpu: cpuUsageCores, + ram: ramUsageGB, + disk: diskUsageGB, + }; +} diff --git a/freebuff/web/lib/monitoring/chart-utils.ts b/freebuff/web/lib/monitoring/chart-utils.ts new file mode 100644 index 0000000000..2df912ecc2 --- /dev/null +++ b/freebuff/web/lib/monitoring/chart-utils.ts @@ -0,0 +1,53 @@ +// Shared chart utility functions for consistent chart formatting + +/** + * Format timestamp for chart axes + * @param timestamp - Unix timestamp in milliseconds + * @param format - 'short' for HH:MM, 'full' for HH:MM:SS + */ +export function formatChartTimestamp( + timestamp: number, + format: "short" | "full" = "short", +): string { + const options: Intl.DateTimeFormatOptions = + format === "short" + ? { + hour: "2-digit", + minute: "2-digit", + } + : { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }; + + return new Date(timestamp).toLocaleTimeString([], options); +} + +/** + * Common chart configuration for styling consistency + */ +export const CHART_COLORS = { + purple: "#8b5cf6", + pink: "#ec4899", + blue: "#3b82f6", + amber: "#f59e0b", + green: "#10b981", + red: "#ef4444", +}; + +/** + * Common chart axis configuration + */ +export const CHART_AXIS_CONFIG = { + tick: { fill: "#71717a", fontSize: 10 }, + tickLine: { stroke: "#e5e7eb" }, +}; + +/** + * Common chart grid configuration + */ +export const CHART_GRID_CONFIG = { + strokeDasharray: "3 3", + stroke: "#e5e7eb", +}; diff --git a/freebuff/web/lib/monitoring/monitoring-constants.ts b/freebuff/web/lib/monitoring/monitoring-constants.ts new file mode 100644 index 0000000000..fd2dba6914 --- /dev/null +++ b/freebuff/web/lib/monitoring/monitoring-constants.ts @@ -0,0 +1,104 @@ +/** + * Monitoring Theme Constants + * Centralized color schemes and styling constants for monitoring components + */ + +// Chart Colors - Used for metrics visualization +export const CHART_COLORS = { + // Primary metric colors + purple: "#8b5cf6", + amber: "#f59e0b", + blue: "#3b82f6", + pink: "#ec4899", + green: "#22c55e", + + // Chart infrastructure colors + grid: "#e5e7eb", + tickText: "#71717a", + tickLine: "#e5e7eb", +} as const; + +// Metric-specific color mappings for consistency +export const METRIC_COLORS = { + // Convex metrics + executions: CHART_COLORS.purple, + compute: CHART_COLORS.amber, + databaseBandwidth: CHART_COLORS.blue, + fileBandwidth: CHART_COLORS.pink, + + // Sandbox metrics + cpu: CHART_COLORS.purple, + memory: CHART_COLORS.pink, + disk: CHART_COLORS.blue, +} as const; + +// Workspace tier colors - Used in upgrade/downgrade panels +export const TIER_COLORS = { + small: { + border: "border-zinc-300", + bg: "bg-zinc-50", + selectedBorder: "border-zinc-500", + selectedBg: "bg-zinc-100", + badge: "bg-zinc-200 text-zinc-700", + icon: "text-zinc-500", + }, + medium: { + border: "border-blue-300", + bg: "bg-blue-50", + selectedBorder: "border-blue-500", + selectedBg: "bg-blue-100", + badge: "bg-blue-200 text-blue-700", + icon: "text-blue-500", + }, + large: { + border: "border-purple-300", + bg: "bg-purple-50", + selectedBorder: "border-purple-500", + selectedBg: "bg-purple-100", + badge: "bg-purple-200 text-purple-700", + icon: "text-purple-500", + }, +} as const; + +// Chart configuration defaults +export const CHART_DEFAULTS = { + // Tick styling + tick: { + fill: CHART_COLORS.tickText, + fontSize: 10, + }, + tickLine: { + stroke: CHART_COLORS.tickLine, + }, + + // Grid styling + cartesianGrid: { + strokeDasharray: "3 3", + stroke: CHART_COLORS.grid, + }, + + // Axis label styling + axisLabel: { + fontSize: 10, + fill: CHART_COLORS.tickText, + }, +} as const; + +// Time range configurations for monitoring dashboards +export const TIME_RANGES = [ + { label: "Last 5 minutes", value: "5m", ms: 5 * 60 * 1000 }, + { label: "Last 1 hour", value: "1h", ms: 1 * 60 * 60 * 1000 }, + { label: "Last 6 hours", value: "6h", ms: 6 * 60 * 60 * 1000 }, + { label: "Last 24 hours", value: "24h", ms: 24 * 60 * 60 * 1000 }, + { label: "Last 7 days", value: "7d", ms: 7 * 24 * 60 * 60 * 1000 }, +] as const; + +// Time formatter for charts +export const formatChartTime = (timestamp: number, includeSeconds = false) => { + const options: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + ...(includeSeconds && { second: "2-digit" }), + }; + return new Date(timestamp).toLocaleTimeString([], options); +}; diff --git a/freebuff/web/lib/monitoring/monitoring-styles.ts b/freebuff/web/lib/monitoring/monitoring-styles.ts new file mode 100644 index 0000000000..410e85de56 --- /dev/null +++ b/freebuff/web/lib/monitoring/monitoring-styles.ts @@ -0,0 +1,67 @@ +// Shared styling constants for monitoring components + +/** + * Standard card styles used throughout monitoring interface + */ +export const CARD_STYLES = { + standard: + "rounded-2xl border border-zinc-200/50 bg-white/30 shadow-sm backdrop-blur-sm", + solid: + "rounded-2xl border border-zinc-200/40 bg-white/80 shadow-sm backdrop-blur-md", + gradient: + "rounded-2xl border border-zinc-200/50 bg-gradient-to-br from-white/70 via-white/50 to-zinc-50/60 shadow-sm backdrop-blur-md", + error: + "rounded-2xl border border-red-300/50 bg-gradient-to-br from-red-50/80 to-red-100/60 shadow-sm backdrop-blur-md", +}; + +/** + * Tier-specific color schemes for workspace tiers (small, medium, large) + */ +export const TIER_COLORS = { + small: { + border: "border-zinc-300", + bg: "bg-zinc-50", + selectedBorder: "border-zinc-500", + selectedBg: "bg-zinc-100", + badge: "bg-zinc-200 text-zinc-700", + icon: "text-zinc-500", + }, + medium: { + border: "border-blue-300", + bg: "bg-blue-50", + selectedBorder: "border-blue-500", + selectedBg: "bg-blue-100", + badge: "bg-blue-200 text-blue-700", + icon: "text-blue-500", + }, + large: { + border: "border-purple-300", + bg: "bg-purple-50", + selectedBorder: "border-purple-500", + selectedBg: "bg-purple-100", + badge: "bg-purple-200 text-purple-700", + icon: "text-purple-500", + }, +}; + +/** + * Common gradient backgrounds + */ +export const GRADIENTS = { + purple: + "bg-gradient-to-r from-purple-50/80 via-purple-100/70 to-purple-50/80", + upgrade: + "border-purple-200/50 bg-gradient-to-r from-purple-50/80 to-purple-100/60", + downgrade: + "border-orange-200/50 bg-gradient-to-r from-orange-50/80 to-orange-100/60", +}; + +/** + * Common button styles for actions + */ +export const BUTTON_STYLES = { + upgrade: + "bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700", + downgrade: + "bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700", +}; diff --git a/freebuff/web/lib/monitoring/monitoring-types.ts b/freebuff/web/lib/monitoring/monitoring-types.ts new file mode 100644 index 0000000000..65f80cb609 --- /dev/null +++ b/freebuff/web/lib/monitoring/monitoring-types.ts @@ -0,0 +1,137 @@ +import type { CostBreakdown } from "@/convex/lib/convex_pricing"; + +export type UsageMetricsResponse = { + deploymentName: string; + // Deployment type for single deployments + deploymentType?: "dev" | "prod" | "all"; + // Deployment names for dev and prod (only present when deploymentType is "all") + devDeploymentName?: string; + prodDeploymentName?: string; + summary: { + totalExecutions: number; + avgExecutionTimeMs: number; + totalActionMemoryMb: number; + totalDbReadBytes: number; + totalDbReadDocuments: number; + totalDbWriteBytes: number; + totalFileStorageReadBytes: number; + totalFileStorageWriteBytes: number; + }; + costs: CostBreakdown; + timeSeries: Array<{ + timestamp: string; + executionCount: number; + executionTimeMs: number; + avgExecutionTimeMs: number; + actionMemoryUsedMb: number; + dbReadBytes: number; + dbReadDocuments: number; + dbWriteBytes: number; + fileStorageReadBytes: number; + fileStorageWriteBytes: number; + costs: CostBreakdown; + // Optional deployment source for "all" deployment type + deploymentType?: "dev" | "prod"; + }>; + lastUpdated: string | null; +}; + +export type SandboxMetricsHistory = { + sandboxId: string; + timeSeries: Array<{ + timestamp: string; + cpuUsagePercent: number; + cpuLimitCores: number; + memoryUsagePercent: number; + memoryUsedBytes: number; + memoryLimitBytes: number; + diskUsagePercent: number; + diskUsedBytes: number; + diskSizeBytes: number; + diskAvailableBytes: number; + load1min: number; + load5min: number; + load15min: number; + }>; + startTime: string; + endTime: string; +}; + +export type TimeRange = "billing_cycle" | "5m" | "1h" | "24h" | "7d" | "custom"; + +// Incident Dashboard Types (for Failures tab) +export interface IncidentDashboard { + totalErrors: number; + topFailingFunctions: Array<{ + functionPath: string; + failureCount: number; + }>; + affectedDeployments: Array<{ + deploymentName: string; + errorCount: number; + }>; +} + +// Performance Dashboard Types +export interface QueryCacheMetric { + hitRate: number; + totalQueries: number; + cacheHits: number; + cacheMisses: number; +} + +export interface WriteConflictItem { + functionPath: string; + conflictCount: number; +} + +export interface SlowQueryItem { + functionPath: string; + avgExecutionTimeMs: number; + maxExecutionTimeMs: number; + executionCount: number; +} + +export interface ResourceUsageItem { + functionPath: string; + totalDocumentsRead: number; + totalDataReadBytes: number; +} + +export interface PerformanceDashboard { + queryCacheHitRate: QueryCacheMetric; + writeConflicts: WriteConflictItem[]; + slowestQueries: SlowQueryItem[]; + slowestMutations: SlowQueryItem[]; + slowestActions: SlowQueryItem[]; + mostDocumentsRead: ResourceUsageItem[]; + mostDataRead: ResourceUsageItem[]; +} + +// Cost Dashboard Types +export interface FunctionCostItem { + functionPath: string; + executionCount: number; + totalExecutionTimeMs: number; + totalBandwidthBytes: number; + estimatedCost: number; +} + +export interface CostDashboard { + summary: { + totalExecutions: number; + totalBandwidthBytes: number; + totalExecutionTimeMs: number; + estimatedTotalCost: number; + costBreakdown: { + functionCallsCost: number; + computeCost: number; + computeGBHours: number; + dbBandwidthCost: number; + dbBandwidthGB: number; + fileBandwidthCost: number; + fileBandwidthGB: number; + }; + }; + topCostFunctions: FunctionCostItem[]; +} diff --git a/freebuff/web/lib/monitoring/monitoring-utils.ts b/freebuff/web/lib/monitoring/monitoring-utils.ts new file mode 100644 index 0000000000..7048df17fb --- /dev/null +++ b/freebuff/web/lib/monitoring/monitoring-utils.ts @@ -0,0 +1,212 @@ +import type { SandboxSize } from "@/vly/lib/sandbox-specs"; +import type { SandboxMetricsHistory } from "./monitoring-types"; + +export const formatBytes = (bytes: number) => { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + let i = Math.floor(Math.log(bytes) / Math.log(k)); + + // Calculate the value for the current unit + let value = bytes / Math.pow(k, i); + + // If value >= 1000, bump up to next unit for better readability + // e.g., show "1.00 GB" instead of "1023.93 MB" + if (value >= 1000 && i < sizes.length - 1) { + i++; + value = bytes / Math.pow(k, i); + } + + return `${value.toFixed(2)} ${sizes[i]}`; +}; + +export const formatBandwidthWithUnit = (bytes: number) => { + if (bytes === 0) return { value: "0", unit: "B" }; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + let i = Math.floor(Math.log(bytes) / Math.log(k)); + + // Calculate the value for the current unit + let numValue = bytes / Math.pow(k, i); + + // If value >= 1000, bump up to next unit for better readability + if (numValue >= 1000 && i < sizes.length - 1) { + i++; + numValue = bytes / Math.pow(k, i); + } + + const value = numValue.toFixed(2); + return { value, unit: sizes[i] }; +}; + +export const formatTime = (ms: number) => { + if (ms < 1000) return `${ms.toFixed(0)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +}; + +export const formatCompute = (gbHours: number) => { + if (gbHours === 0) return "0 GB-h"; + // Convert to smaller units if very small + const mbHours = gbHours * 1024; + if (mbHours < 1) { + const kbHours = mbHours * 1024; + if (kbHours < 1) { + const bHours = kbHours * 1024; + return `${bHours.toFixed(2)} B-h`; + } + return `${kbHours.toFixed(2)} KB-h`; + } + // Use MB-h if less than 1000, otherwise bump to GB-h for readability + if (mbHours < 1000) { + return `${mbHours.toFixed(2)} MB-h`; + } + return `${gbHours.toFixed(2)} GB-h`; +}; + +export const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); +}; + +export const formatRelativeDateTime = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const dateOnly = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ); + + const time = date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + if (dateOnly.getTime() === today.getTime()) { + return `Today ${time}`; + } else if (dateOnly.getTime() === yesterday.getTime()) { + return `Yesterday ${time}`; + } else { + const monthDay = date.toLocaleDateString([], { + month: "short", + day: "numeric", + }); + return `${monthDay}, ${time}`; + } +}; + +export const formatUptime = (seconds: number) => { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + + return parts.length > 0 ? parts.join(" ") : "0m"; +}; + +export const getProgressColor = (percentage: number) => { + if (percentage >= 80) return "bg-red-500"; + if (percentage >= 60) return "bg-yellow-500"; + return "bg-green-500"; +}; + +export const getTierDirection = (fromTier: string, toTier: string) => { + const tierOrder: Record<string, number> = { small: 0, medium: 1, large: 2 }; + const from = tierOrder[fromTier] ?? 1; + const to = tierOrder[toTier] ?? 1; + if (to > from) return "upgrade"; + if (to < from) return "downgrade"; + return "same"; +}; + +export const tierToSize = (tier: string): SandboxSize => { + // Tiers now directly match sizes + if (tier === "small" || tier === "medium" || tier === "large") { + return tier as SandboxSize; + } + // Fallback for legacy tier names + switch (tier) { + case "free": + return "small"; + case "hobby": + return "medium"; + case "pro": + return "large"; + default: + return "small"; + } +}; + +// Process time series data to detect and handle gaps (sandbox stopped periods) +export const processTimeSeriesWithGaps = ( + timeSeries: SandboxMetricsHistory["timeSeries"], +) => { + const GAP_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes - normal sampling is every 10 seconds + type ProcessedPoint = Omit< + SandboxMetricsHistory["timeSeries"][number], + "cpuUsagePercent" | "memoryUsagePercent" | "diskUsagePercent" + > & { + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + diskUsagePercent: number | null; + }; + const result: ProcessedPoint[] = []; + + for (let i = 0; i < timeSeries.length; i++) { + const current = timeSeries[i]; + result.push(current); + + // Check if there's a gap before the next point + if (i < timeSeries.length - 1) { + const next = timeSeries[i + 1]; + const gap = + new Date(next.timestamp).getTime() - + new Date(current.timestamp).getTime(); + + if (gap > GAP_THRESHOLD_MS) { + // Insert null data point to break the line (sandbox was stopped) + // Use a timestamp 1ms after current to avoid duplicate keys + const gapTimestamp = new Date( + new Date(current.timestamp).getTime() + 1, + ).toISOString(); + result.push({ + ...current, + timestamp: gapTimestamp, + cpuUsagePercent: null, + memoryUsagePercent: null, + diskUsagePercent: null, + }); + } + } + } + + return result; +}; + +// Calculate actual time range from data (excluding gaps/null points) +// This ensures the chart only shows periods where the sandbox was actually running +export const getActualDataTimeRange = ( + data: Array<{ + time: number; + [key: string]: number | string | null | undefined; + }>, + dataKeys: string[], +): [number, number] | ["auto", "auto"] => { + const validTimestamps = data + .filter((point) => + dataKeys.some((key) => point[key] !== null && point[key] !== undefined), + ) + .map((point) => point.time); + + if (validTimestamps.length === 0) { + return ["auto", "auto"]; + } + + return [Math.min(...validTimestamps), Math.max(...validTimestamps)]; +}; diff --git a/freebuff/web/lib/partial-parser.ts b/freebuff/web/lib/partial-parser.ts new file mode 100644 index 0000000000..ce4e0437bc --- /dev/null +++ b/freebuff/web/lib/partial-parser.ts @@ -0,0 +1,157 @@ +export type FileEdit = { + type: "create" | "edit" | "replace"; + path: string; + content: string; + isComplete: boolean; +}; + +type FilePart = { + type: "file"; + data: FileEdit; +}; + +type TextPart = { + type: "text"; + data: string; +}; + +export type MessagePart = TextPart | FilePart; + +/** + * Parses output from an AI coding agent into structured MessagePart objects. + * Properly handles streaming/incomplete input including partial operations. + * + * @param input The string output from the AI coding agent (may be incomplete) + * @returns Array of MessagePart objects (TextPart | FilePart) + */ +export function parseAIOutput(input: string): MessagePart[] { + const result: MessagePart[] = []; + + // Add debug logging for parsing issues + if (!input || typeof input !== "string") { + console.warn("[parseAIOutput] Invalid input:", input); + return []; + } + + // Use a more robust approach - find all file operations in the input + const fileOperationRegex = + /(EDIT FILE|CREATE FILE|REPLACE FILE)\s*\n\s*```(?:([^\n]*?)(?:\n|$)|([^\n]*?)(?:\n|$))/g; + const matches: Array<{ + operation: string; + path: string; + startIndex: number; + fullMatch: string; + }> = []; + let match; + + while ((match = fileOperationRegex.exec(input)) !== null) { + let path = match[2] || match[3] || ""; + + // If no path was captured, try to extract it from the content + if (!path) { + const contentStart = match.index + match[0].length; + const nextNewline = input.indexOf("\n", contentStart); + if (nextNewline !== -1) { + path = input.substring(contentStart, nextNewline).trim(); + } + } + + matches.push({ + operation: match[1], + path: path, + startIndex: match.index, + fullMatch: match[0], + }); + } + + if (matches.length === 0) { + // No file operations found, just text + return input.trim() ? [{ type: "text", data: input.trim() }] : []; + } + + // Process the input based on matches + let lastIndex = 0; + matches.forEach((match, index) => { + // Add text before the operation + if (match.startIndex > lastIndex) { + const textBefore = input.substring(lastIndex, match.startIndex).trim(); + if (textBefore) { + result.push({ type: "text", data: textBefore }); + } + } + + // Extract the operation content + const operationStart = match.startIndex + match.fullMatch.length; + const nextMatch = matches[index + 1]; + const operationEnd = nextMatch ? nextMatch.startIndex : input.length; + + let content = input.substring(operationStart, operationEnd); + + // If the path was extracted separately and is on the first line of content, skip it + if (match.path && content.startsWith(match.path + "\n")) { + content = content.substring(match.path.length + 1); + } + let isComplete = false; + + // Check if the operation is complete (ends with ```) + const closingBackticksIndex = content.lastIndexOf("```"); + if (closingBackticksIndex !== -1) { + isComplete = true; + content = content.substring(0, closingBackticksIndex); + + // Check if there's text after the closing backticks + if (closingBackticksIndex + 3 < content.length) { + const textAfter = content.substring(closingBackticksIndex + 3).trim(); + if (textAfter) { + // Push this as separate text part + result.push({ + type: "text", + data: textAfter, + }); + } + } + } else { + // Incomplete operation detected + } + + // Add the file operation + const opType = + match.operation === "EDIT FILE" + ? "edit" + : match.operation === "CREATE FILE" + ? "create" + : "replace"; + + result.push({ + type: "file", + data: { + type: opType, + path: match.path.trim(), + content: content, + isComplete: isComplete, + }, + }); + + lastIndex = operationEnd; + }); + + // Add any remaining text after the last operation + if (lastIndex < input.length) { + const remainingText = input.substring(lastIndex).trim(); + if (remainingText) { + result.push({ type: "text", data: remainingText }); + } + } + + // Filter out standalone operation markers that shouldn't be treated as text + return result.filter( + (p) => + !( + p.type === "text" && + (p.data === "CREATE FILE" || + p.data === "EDIT FILE" || + p.data === "REPLACE FILE" || + p.data.trim() === "") + ), + ); +} diff --git a/freebuff/web/lib/project-creation-handler.ts b/freebuff/web/lib/project-creation-handler.ts new file mode 100644 index 0000000000..f3108777ce --- /dev/null +++ b/freebuff/web/lib/project-creation-handler.ts @@ -0,0 +1,92 @@ +import { toast } from 'sonner' +import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime' + +/** + * Result type returned by the createProject mutation + */ +type ProjectCreationResult = + | { success: true; semanticIdentifier: string } + | { + success: false + error: { + kind: string + message?: string + retryAfter?: number + } + } + +/** + * Format milliseconds into a human-readable time string + */ +function formatRetryTime(ms: number): string { + const seconds = Math.ceil(ms / 1000) + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? 's' : ''}` + } + const minutes = Math.ceil(seconds / 60) + return `${minutes} minute${minutes !== 1 ? 's' : ''}` +} + +/** + * Reusable handler for project creation mutation results + * Handles all error cases (DEPLOYMENTS_PAUSED, RateLimited, etc.) and navigation + * + * @param result - The result from the createProject mutation + * @param router - Next.js router instance for navigation + * @param onSuccess - Optional callback to run on successful project creation (e.g., clear form, show toast) + * @param onProjectLimit - Optional callback to run when project limit is reached (e.g., show paywall) + * @returns true if successful, false if there was an error + */ +export function handleProjectCreationResult( + result: ProjectCreationResult, + router: AppRouterInstance, + onSuccess?: () => void, + onProjectLimit?: () => void, +): boolean { + // Handle error cases + if (!result.success) { + const { error } = result + + // Special handling for rate limit errors + if (error.kind === 'RateLimited') { + const timeString = formatRetryTime(error.retryAfter || 0) + toast.error( + `Rate limit exceeded. Please wait ${timeString} before creating a project.`, + { duration: 5000 }, + ) + return false + } + + // Special handling for paused deployments + if (error.kind === 'DEPLOYMENTS_PAUSED') { + toast.error( + error.message || + 'Your Convex deployments are paused. Please add more Convex credits to continue.', + { duration: 6000 }, + ) + return false + } + + // Special handling for project limit errors + if ( + error.kind === 'PROJECT_LIMIT' || + error.message?.toLowerCase().includes('project limit') || + error.message?.toLowerCase().includes('max projects') + ) { + void onProjectLimit + toast.error(error.message || 'Unable to create project right now.', { + duration: 6000, + }) + return false + } + + // Generic error handling + toast.error(error.message || 'Failed to create project. Please try again.') + return false + } + + // Success case - run optional callback and navigate + onSuccess?.() + router.push(`/web/project/${result.semanticIdentifier}`) + return true +} diff --git a/freebuff/web/lib/rateLimitHelpers.ts b/freebuff/web/lib/rateLimitHelpers.ts new file mode 100644 index 0000000000..dbb0f9a1c4 --- /dev/null +++ b/freebuff/web/lib/rateLimitHelpers.ts @@ -0,0 +1,61 @@ +import { toast } from "sonner"; + +/** + * Format milliseconds into a human-readable time string (e.g., "5m 30s" or "45s") + */ +export function formatRetryTime(milliseconds: number): string { + const remainingSeconds = Math.ceil(milliseconds / 1000); + const minutes = Math.floor(remainingSeconds / 60); + const seconds = remainingSeconds % 60; + return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; +} + +/** + * Check if currently rate limited and show a toast notification if so + * @returns true if NOT rate limited (can proceed), false if rate limited (should block) + */ +export function checkRateLimitAndNotify( + rateLimitRetryAfter: number | null | undefined, + action: string, +): boolean { + if (rateLimitRetryAfter && rateLimitRetryAfter > Date.now()) { + const remainingMs = rateLimitRetryAfter - Date.now(); + const timeString = formatRetryTime(remainingMs); + + toast.error( + `Rate limit exceeded. Please wait ${timeString} before ${action}.`, + { + duration: 5000, + }, + ); + return false; + } + return true; +} + +/** + * Handle rate limit error from API response + * @returns true if rate limited, false if not rate limited + */ +export function handleRateLimitError( + result: any, + setRateLimitRetryAfter: (time: number | null) => void, + action: string, +): boolean { + if (result && !result.success && result.error?.kind === "RateLimited") { + const retryAfterMs = result.error.retryAfter || 0; + const retryTime = Date.now() + retryAfterMs; + setRateLimitRetryAfter(retryTime); + + const timeString = formatRetryTime(retryAfterMs); + + toast.error( + `Rate limit exceeded. Please wait ${timeString} before ${action}.`, + { + duration: 5000, + }, + ); + return true; + } + return false; +} diff --git a/freebuff/web/lib/referral-cookies.ts b/freebuff/web/lib/referral-cookies.ts new file mode 100644 index 0000000000..2815c66c04 --- /dev/null +++ b/freebuff/web/lib/referral-cookies.ts @@ -0,0 +1,59 @@ +import { cookies } from "next/headers"; + +const REFERRAL_COOKIE_NAME = "vly_referral_code"; +// Configurable timeout - currently set to 24 hours +// Change this value to adjust the attribution window +export const REFERRAL_ATTRIBUTION_WINDOW_HOURS = 24; +const COOKIE_MAX_AGE = REFERRAL_ATTRIBUTION_WINDOW_HOURS * 60 * 60; // Convert hours to seconds + +export async function setReferralCode(code: string) { + const cookieStore = await cookies(); + cookieStore.set(REFERRAL_COOKIE_NAME, code, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: COOKIE_MAX_AGE, + path: "/", + }); +} + +export async function getReferralCode(): Promise<string | undefined> { + const cookieStore = await cookies(); + const cookie = cookieStore.get(REFERRAL_COOKIE_NAME); + return cookie?.value; +} + +export async function clearReferralCode() { + const cookieStore = await cookies(); + cookieStore.delete(REFERRAL_COOKIE_NAME); +} + +// Client-side cookie utilities +export const clientCookies = { + set( + name: string, + value: string, + hours: number = REFERRAL_ATTRIBUTION_WINDOW_HOURS, + ) { + const expires = new Date(); + expires.setTime(expires.getTime() + hours * 60 * 60 * 1000); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax${ + window.location.protocol === "https:" ? ";Secure" : "" + }`; + }, + + get(name: string): string | null { + const nameEQ = name + "="; + const ca = document.cookie.split(";"); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === " ") c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; + }, + + delete(name: string) { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`; + }, +}; diff --git a/freebuff/web/lib/sandbox-specs.ts b/freebuff/web/lib/sandbox-specs.ts new file mode 100644 index 0000000000..daba1763dd --- /dev/null +++ b/freebuff/web/lib/sandbox-specs.ts @@ -0,0 +1,67 @@ +// Sandbox resource specifications by size +// Sizes map to plan tiers for backward compatibility with UI + +export type SandboxSize = "small" | "medium" | "large"; + +// Specs by sandbox size (aligned with Daytona snapshots) +export const sandboxSpecsBySize = { + small: { + vcpu: 1, + ram_gb: 3, + disk_gb: 4, + }, + medium: { + vcpu: 1, + ram_gb: 4, + disk_gb: 8, + }, + large: { + vcpu: 2, + ram_gb: 8, + disk_gb: 10, + }, +} as const; + +// Specs by plan tier (includes both legacy and custom plan IDs) +export const sandboxSpecs = { + free_plan: sandboxSpecsBySize.small, + // Hobby tier (legacy + custom) + hobby_plan: sandboxSpecsBySize.medium, + hobby_custom_plan: sandboxSpecsBySize.medium, + // Pro tier (legacy + custom) + pro_plan: sandboxSpecsBySize.large, + pro_custom_plan: sandboxSpecsBySize.large, + // Team tier + team_plan: { + vcpu: 4, + ram_gb: 16, + disk_gb: 32, + }, + team_custom_plan: { + vcpu: 4, + ram_gb: 16, + disk_gb: 32, + }, + // Enterprise tier + enterprise_plan: { + vcpu: 8, + ram_gb: 32, + disk_gb: 64, + }, + enterprise_custom_plan: { + vcpu: 8, + ram_gb: 32, + disk_gb: 64, + }, +} as const; + +// Helper to get specs by size +export function getSpecsBySize(size: SandboxSize | undefined) { + return sandboxSpecsBySize[size ?? "small"]; +} + +// Helper to get size display name +export function getSizeDisplayName(size: SandboxSize | undefined): string { + const actualSize = size ?? "small"; + return actualSize.charAt(0).toUpperCase() + actualSize.slice(1); +} diff --git a/freebuff/web/lib/site-metadata.ts b/freebuff/web/lib/site-metadata.ts new file mode 100644 index 0000000000..55dbc75ab8 --- /dev/null +++ b/freebuff/web/lib/site-metadata.ts @@ -0,0 +1,111 @@ +import type { Metadata } from "next"; + +export const SITE_URL = "https://vly.ai"; +export const SITE_NAME = "vly.ai"; +export const SITE_DISPLAY_NAME = "VLY AI"; +export const DEFAULT_TITLE = + "vly.ai | AI App Builder for Production-Ready Web Apps"; +export const DEFAULT_DESCRIPTION = + "Build production-ready web apps with AI, a managed backend, realtime data, integrations, and coding agents."; +export const HOME_TITLE = "vly.ai | We Just Killed Bolt, Replit & Base44"; +export const HOME_DESCRIPTION = + "We just killed Bolt, Replit, and Base44. vly.ai is 7x cheaper and better for AI with a custom realtime architecture, backend visualizations, 1000+ integrations, and advanced coding agents."; +export const DEFAULT_OG_IMAGE_PATH = "/landing/landmarks.jpeg"; +export const BRAND_LOGO_URL = `${SITE_URL}/logos/falcon_clear_bg.png`; +export const BRAND_ICON_URL = `${SITE_URL}/logos/falcon_logo_favicon.png`; + +type PageMetadataOptions = { + title: string; + description?: string; + path?: string; + imagePath?: string; + noIndex?: boolean; +}; + +export function createPageMetadata({ + title, + description = DEFAULT_DESCRIPTION, + path = "/", + imagePath = DEFAULT_OG_IMAGE_PATH, + noIndex = false, +}: PageMetadataOptions): Metadata { + const canonicalUrl = new URL(path, SITE_URL).toString(); + const imageUrl = new URL(imagePath, SITE_URL).toString(); + + return { + title, + description, + alternates: { + canonical: canonicalUrl, + }, + openGraph: { + title, + description, + url: canonicalUrl, + siteName: SITE_NAME, + images: [ + { + url: imageUrl, + width: 1200, + height: 630, + alt: title, + }, + ], + locale: "en_US", + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [imageUrl], + creator: "@vlyai", + }, + robots: noIndex + ? { + index: false, + follow: true, + } + : { + index: true, + follow: true, + }, + }; +} + +export const brandStructuredData = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": `${SITE_URL}/#organization`, + name: SITE_NAME, + alternateName: SITE_DISPLAY_NAME, + url: SITE_URL, + email: "james@mail.freebuff.app", + logo: { + "@type": "ImageObject", + url: BRAND_LOGO_URL, + width: 512, + height: 512, + }, + image: { + "@type": "ImageObject", + url: BRAND_ICON_URL, + width: 512, + height: 512, + }, + }, + { + "@type": "WebSite", + "@id": `${SITE_URL}/#website`, + url: SITE_URL, + name: SITE_NAME, + alternateName: SITE_DISPLAY_NAME, + inLanguage: "en-US", + publisher: { + "@id": `${SITE_URL}/#organization`, + }, + }, + ], +} as const; diff --git a/freebuff/web/lib/theme-metadata.ts b/freebuff/web/lib/theme-metadata.ts new file mode 100644 index 0000000000..4d09623192 --- /dev/null +++ b/freebuff/web/lib/theme-metadata.ts @@ -0,0 +1,228 @@ +import type { ThemeName } from "@/vly/lib/theme-prompts"; + +export type ThemePreviewStyle = + | "minimal" + | "modern" + | "neobrutal" + | "papery" + | "notebook" + | "studio" + | "clay" + | "vintage" + | "glass" + | "terminal"; + +export interface ThemeMetadata { + name: ThemeName; + colors: { + primary: string; + secondary: string; + accent?: string; + background: string; + text: string; + }; + icon: string; + tags: string[]; + font: string; + vibe: string; + description: string; + previewStyle: ThemePreviewStyle; + borderStyle?: string; + shadowStyle?: string; +} + +const metadataByTheme = { + Minimalism: { + name: "Minimalism", + colors: { + primary: "#111111", + secondary: "#737373", + accent: "#E7E5E4", + background: "#FFFFFF", + text: "#111111", + }, + icon: "○", + tags: ["Quiet", "Precise", "Whitespace"], + font: "Inter", + vibe: "Clean essentials", + description: + "Quiet monochrome layouts with sharp spacing and restrained detail.", + previewStyle: "minimal", + borderStyle: "1px solid rgba(17, 17, 17, 0.08)", + shadowStyle: "0 18px 40px rgba(15, 23, 42, 0.06)", + }, + Modern: { + name: "Modern", + colors: { + primary: "#2D6BFF", + secondary: "#8AA3D4", + accent: "#5FD2FF", + background: "linear-gradient(135deg, #F8FBFF 0%, #EEF3FF 100%)", + text: "#10213A", + }, + icon: "◇", + tags: ["Product", "Polished", "Airy"], + font: "Inter", + vibe: "Crisp product polish", + description: + "Layered, contemporary UI with refined contrast and calm color.", + previewStyle: "modern", + borderStyle: "1px solid rgba(45, 107, 255, 0.12)", + shadowStyle: "0 20px 45px rgba(45, 107, 255, 0.12)", + }, + "Neobrutalism Minimalism": { + name: "Neobrutalism Minimalism", + colors: { + primary: "#FF6B2C", + secondary: "#FFE76A", + accent: "#111111", + background: "#F7F4EC", + text: "#111111", + }, + icon: "▣", + tags: ["Square", "Bold", "Consistent"], + font: "Space Grotesk", + vibe: "Hard edges, controlled palette", + description: + "Bold borders and flat blocks, but with disciplined minimal structure.", + previewStyle: "neobrutal", + borderStyle: "3px solid #111111", + shadowStyle: "6px 6px 0px #111111", + }, + Papery: { + name: "Papery", + colors: { + primary: "#2F241A", + secondary: "#8A7B67", + accent: "#B04A2B", + background: "#F0EEE6", + text: "#1F1A14", + }, + icon: "▤", + tags: ["Editorial", "Paper", "Newsprint"], + font: "Playfair Display", + vibe: "Newsroom minimalism", + description: + "Paper-toned editorial UI with thin rules, columns, and serif calm.", + previewStyle: "papery", + borderStyle: "1px solid rgba(47, 36, 26, 0.14)", + shadowStyle: "0 12px 24px rgba(47, 36, 26, 0.08)", + }, + Notebook: { + name: "Notebook", + colors: { + primary: "#1D4ED8", + secondary: "#E06C75", + accent: "#5B8A72", + background: "#F7F3E9", + text: "#2D2A24", + }, + icon: "☰", + tags: ["Textured", "Ruled", "Organized"], + font: "Roboto", + vibe: "Lined and hand-kept", + description: "Ruled paper, margin lines, and textured note-taking energy.", + previewStyle: "notebook", + borderStyle: "1px solid rgba(45, 42, 36, 0.12)", + shadowStyle: "0 14px 24px rgba(61, 56, 48, 0.08)", + }, + Studio: { + name: "Studio", + colors: { + primary: "#1F2937", + secondary: "#9AA4B2", + accent: "#D6BFA3", + background: "#FCFBF7", + text: "#111827", + }, + icon: "▢", + tags: ["Gallery", "Quiet", "Refined"], + font: "Inter", + vibe: "Soft modern editorial", + description: + "Warm-white, gallery-clean layouts with thin framing and airy balance.", + previewStyle: "studio", + borderStyle: "1px solid rgba(17, 24, 39, 0.08)", + shadowStyle: "0 18px 36px rgba(17, 24, 39, 0.06)", + }, + Claymorphism: { + name: "Claymorphism", + colors: { + primary: "#F28E6B", + secondary: "#F7C7B7", + accent: "#8C73FF", + background: "linear-gradient(135deg, #FBE7DE 0%, #F4EEFF 100%)", + text: "#5C4667", + }, + icon: "⬤", + tags: ["Tactile", "Rounded", "Matte"], + font: "Inter", + vibe: "Soft surfaces", + description: + "Inflated cards, plush shadows, and rounded matte pastel shapes.", + previewStyle: "clay", + shadowStyle: + "12px 12px 24px rgba(214, 156, 138, 0.25), -10px -10px 24px rgba(255, 255, 255, 0.7)", + }, + Vintage: { + name: "Vintage", + colors: { + primary: "#6C4C2C", + secondary: "#B99368", + accent: "#8F2D2D", + background: "#E6D7BD", + text: "#3A2816", + }, + icon: "◫", + tags: ["Sepia", "Printed", "Warm"], + font: "Playfair Display", + vibe: "Aged and elegant", + description: + "Archival warmth with sepia contrast, serif hierarchy, and quiet texture.", + previewStyle: "vintage", + borderStyle: "1px solid rgba(108, 76, 44, 0.22)", + shadowStyle: "0 14px 28px rgba(58, 40, 22, 0.12)", + }, + Glassmorphism: { + name: "Glassmorphism", + colors: { + primary: "#8FE7FF", + secondary: "#B39DFF", + accent: "#FF9ECD", + background: + "linear-gradient(135deg, #0F172A 0%, #1D4ED8 45%, #7C3AED 100%)", + text: "#F8FAFC", + }, + icon: "◌", + tags: ["Frosted", "Layered", "Luminous"], + font: "Inter", + vibe: "Refined translucent depth", + description: + "A cleaner glass UI with better hierarchy, subtle blur, and controlled glow.", + previewStyle: "glass", + borderStyle: "1px solid rgba(255, 255, 255, 0.22)", + shadowStyle: + "0 20px 48px rgba(15, 23, 42, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.25)", + }, + Terminal: { + name: "Terminal", + colors: { + primary: "#7CFF9B", + secondary: "#31D0AA", + accent: "#F9D66B", + background: "#08110D", + text: "#C9FFD6", + }, + icon: ">_", + tags: ["Monospace", "CLI", "Grid"], + font: "JetBrains Mono", + vibe: "Command-line precision", + description: + "Dark terminal framing with monospace alignment, status bars, and glow.", + previewStyle: "terminal", + borderStyle: "1px solid rgba(124, 255, 155, 0.18)", + shadowStyle: "0 0 24px rgba(124, 255, 155, 0.14)", + }, +} satisfies Record<ThemeName, ThemeMetadata>; + +export const themeMetadata: Record<string, ThemeMetadata> = metadataByTheme; diff --git a/freebuff/web/lib/theme-prompts.ts b/freebuff/web/lib/theme-prompts.ts new file mode 100644 index 0000000000..4befbf7451 --- /dev/null +++ b/freebuff/web/lib/theme-prompts.ts @@ -0,0 +1,47 @@ +export const featuredThemes = [ + "Minimalism", + "Modern", + "Neobrutalism Minimalism", +] as const; + +export const themes = [ + ...featuredThemes, + "Papery", + "Notebook", + "Studio", + "Claymorphism", + "Vintage", + "Glassmorphism", + "Terminal", +] as const; + +export type ThemeName = (typeof themes)[number]; + +const themeNameSet = new Set<string>(themes); + +export function isThemeName(theme: string): theme is ThemeName { + return themeNameSet.has(theme); +} + +export const themePrompts = { + Minimalism: + "Use a Minimalism theme when styling. Keep it clean, spacious, restrained, and highly legible with a near-monochrome palette, precise alignment, subtle dividers, and very intentional use of white space.", + Modern: + "Use a Modern theme when styling. Aim for a polished product-design look with crisp typography, quiet neutrals, refined accent color, soft layered cards, and balanced contemporary spacing.", + "Neobrutalism Minimalism": + "Use a Neobrutalism Minimalism theme when styling. Keep square corners, consistent minimalist structure, strong black borders, flat color blocking, and bold but controlled contrast without visual clutter.", + Papery: + "Use a Papery theme when styling. The background should feel like paper with a base of #F0EEE6, inspired by newspapers and editorial layouts with thin rules, serif-forward hierarchy, lots of breathing room, and quiet ink-like accents.", + Notebook: + "Use a Notebook theme when styling. Add a textured paper feel with subtle grain, ruled or margin-line details, organized list layouts, lightweight annotation accents, and a clean notebook-inspired composition.", + Studio: + "Use a Studio theme when styling. Keep it gallery-clean and refined with warm off-whites, thin framing, muted neutrals, careful spacing, and a soft editorial presentation.", + Claymorphism: + "Use a Claymorphism theme when styling. Make the interface feel tactile and soft with plush rounded surfaces, matte pastel colors, inflated shadows, and playful but polished depth.", + Vintage: + "Use a Vintage theme when styling. Use muted sepia and aged-paper tones, elegant serif hierarchy, light distressing or texture, and tasteful archival details rather than loud retro gimmicks.", + Glassmorphism: + "Use a Glassmorphism theme when styling. Make it better than a generic frosted card look by using refined hierarchy, layered translucent panels, controlled blur, subtle edge highlights, and restrained luminous color.", + Terminal: + "Use a Terminal theme when styling. Lean into monospace typography, command-line structure, aligned grid layouts, scanline or monitor hints, and green or amber accents on a dark interface.", +} as const satisfies Record<ThemeName, string>; diff --git a/freebuff/web/lib/utils.ts b/freebuff/web/lib/utils.ts new file mode 100644 index 0000000000..77529879a5 --- /dev/null +++ b/freebuff/web/lib/utils.ts @@ -0,0 +1,119 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { getDomain, getSubdomain as getTldtsSubdomain } from "tldts"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// Types for the result object with discriminated union +type Success<T> = [T, null]; + +type Failure<E> = [null, E]; + +export function Success<T>(data: T): Success<T> { + return [data, null]; +} + +export function Failure<E>(error: E): Failure<E> { + return [null, error]; +} + +export type Result<T, E = Error> = Success<T> | Failure<E>; + +// Main wrapper function +export async function tryCatch<T, E = Error>( + promise: Promise<T>, +): Promise<Result<T, E>> { + try { + const data = await promise; + return [data, null]; + } catch (error) { + return [null, error as E]; + } +} +/** + * Extracts the root domain from a given domain, properly handling multi-part TLDs using tldts + * Examples: + * - test.co.in -> test.co.in + * - sub.test.co.in -> test.co.in + * - example.com -> example.com + * - sub.example.com -> example.com + */ +export function getRootDomain(domain: string): string { + const rootDomain = getDomain(domain.toLowerCase()); + return rootDomain || domain; // Return original if parsing fails +} + +/** + * Extracts the subdomain from a given domain, properly handling multi-part TLDs using tldts + * Examples: + * - sub.test.co.in -> sub + * - www.sub.test.co.in -> www.sub + * - test.co.in -> (empty string) + */ +export function getSubdomain(domain: string): string { + const subdomain = getTldtsSubdomain(domain.toLowerCase()); + return subdomain || ""; +} + +export function filterTerminalOuptut(inputString: string) { + // Define the escape sequence to match + const escapeSequence = "\x1b[1A\x1b[0K"; + + // Split the input string into lines + const lines = inputString.split("\n"); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(escapeSequence)) { + lines[i - 1] = "\x1b<del>" + lines[i - 1]; + } + } + + // Filter out any lines containing the escape sequence + const filteredLines = lines.filter((line) => !line.startsWith("\x1b<del>")); + + // Filter out lines with loading spinner characters + const finalFilteredLines = filteredLines.filter( + (line) => !/[⠸⠼⠴⠦⠧⠇⠏⠋⠙⠹⠸⠰⠤⠠⠢⠖⠦⠔⠪⠫⠩⠨⠂⠆⠄⠈]/.test(line), + ); + + // Remove ANSI codes from each line first to make text matching work properly + const cleanedLines = finalFilteredLines.map((line) => + line + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") + .replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, "") + .replace(/\[\?25[hl]/g, "") + .replace(/\[1G\[0K/g, "") + .replace(/\[1A\[0K/g, "") + .replace(/^\[1G$/g, "") + .replace(/^\[0K$/g, "") + .replace(/^\[1A$/g, "") + .replace(/\x1b/g, ""), + ); + + // Remove empty lines + const nonEmptyLines = cleanedLines.filter((line) => line.trim() !== ""); + + // Remove lines with specific Convex deployment messages + const filteredConvexLines = nonEmptyLines.filter( + (line) => + !line.includes("Provisioned a dev deployment") && + !line.includes("Write your Convex") && + !line.includes("Give us feedback") && + !line.includes("View the Convex dashboard") && + !line.includes("Downloading current deployment") && + !line.includes("Diffing local code and deployment state") && + !line.includes("Analyzing and deploying source code") && + !line.includes("Collecting TypeScript errors") && + !line.includes("Preparing Convex functions") && + !line.includes("To ignore failing typecheck"), + ); + + // Join the remaining lines back together + const cleanedString = filteredConvexLines.join("\n"); + + return cleanedString; +} + +export type OAuthStrategy = "oauth_google" | "oauth_github"; diff --git a/freebuff/web/next.config.mjs b/freebuff/web/next.config.mjs index 5030be8c6f..7365bf5c73 100644 --- a/freebuff/web/next.config.mjs +++ b/freebuff/web/next.config.mjs @@ -1,45 +1,86 @@ import { resolve } from 'path' +import { blogSlugRedirects } from './blog-redirects.mjs' +import dotenv from 'dotenv' -const FREEBUFF_PORT = 3002 +const repoRoot = resolve(import.meta.dirname, '../..') + +dotenv.config({ + path: resolve(repoRoot, '.env.local'), + override: false, +}) +dotenv.config({ + path: resolve(repoRoot, '.env.development.local'), + override: true, +}) + +const FREEBUFF_PORT = + process.env.PORT || process.env.NEXT_PUBLIC_WEB_PORT || '3002' +const monorepoRoot = resolve(import.meta.dirname, '../../') /** @type {import('next').NextConfig} */ const nextConfig = { - outputFileTracingRoot: resolve(import.meta.dirname, '../../'), + // Emit a self-contained server bundle under `.next/standalone` so the Render + // deploy artifact can ship without the 3.9GB hoisted monorepo `node_modules` + // (electron/react-native/expo siblings the web server never loads). The + // assemble-standalone.mjs post-build step copies in static/public and the + // runtime assets Next's file tracer can't follow (see that script). + output: 'standalone', + outputFileTracingRoot: monorepoRoot, env: { - // In development, override the app URL to point to the Freebuff dev server port. - // In production, NEXT_PUBLIC_CODEBUFF_APP_URL is set via deployment env vars. + // In development, point Freebuff-specific URLs at the Freebuff dev server. + // In production, set these via deployment env vars. ...(process.env.NODE_ENV === 'development' ? { - NEXT_PUBLIC_CODEBUFF_APP_URL: `http://localhost:${FREEBUFF_PORT}`, - NEXTAUTH_URL: `http://localhost:${FREEBUFF_PORT}`, + NEXT_PUBLIC_FREEBUFF_APP_URL: `http://localhost:${FREEBUFF_PORT}`, + NEXTAUTH_FREEBUFF_URL: `http://localhost:${FREEBUFF_PORT}`, } : {}), }, - eslint: { - ignoreDuringBuilds: true, - }, typescript: { ignoreBuildErrors: true, }, - webpack: (config) => { - config.resolve.fallback = { fs: false, net: false, tls: false, path: false } - config.externals.push( - { 'thread-stream': 'commonjs thread-stream', pino: 'commonjs pino' }, - 'pino-pretty', - 'encoding', - 'perf_hooks', - 'async_hooks', - ) - config.externals.push( - '@codebuff/code-map', - '@codebuff/code-map/parse', - '@codebuff/code-map/languages', - /^@codebuff\/code-map/, - ) - config.infrastructureLogging = { - level: 'error', - } - return config + serverExternalPackages: [ + 'pino', + 'thread-stream', + 'pino-pretty', + // Reads its data files from node_modules at runtime; must stay unbundled. + 'geoip-country', + // Ships WASM modules (tree-sitter, quickjs) that bundlers can't resolve; + // loaded from its built dist at runtime (see prepare:workspace). + '@codebuff/sdk', + '@codebuff/code-map', + '@codebuff/code-map/parse', + '@codebuff/code-map/languages', + // Document extraction for chat uploads: unpdf bundles pdf.js (dynamic + // imports/workers), mammoth uses Node built-ins + dynamic requires, exceljs + // bundles a zip/stream stack, and turndown needs its own DOM — keep them + // unbundled so they load from node_modules at runtime. + 'unpdf', + 'mammoth', + 'exceljs', + 'turndown', + 'turndown-plugin-gfm', + // NOTE: lz4-wasm is intentionally NOT listed here. serverExternalPackages + // only works for packages Node can require() at runtime; lz4-wasm is an + // ESM-only wasm-pack bundler build. Turbopack bundles it natively and + // emits the .wasm file next to the chunk (inside the standalone output), + // so no server-side stub is needed — unlike the old webpack build, where + // the emitted wasm chunk path was unreadable at runtime (ENOENT in prod). + ], + experimental: { + optimizePackageImports: ['lucide-react', 'framer-motion'], + serverComponentsHmrCache: true, + // Persist Turbopack's build cache under .next/cache so the Render + // build-cache stash (scripts/render-next-cache.mjs) makes rebuilds warm. + turbopackFileSystemCacheForBuild: true, + }, + turbopack: { + // Keep Turbopack scoped to this Next app. Letting it infer the monorepo + // lockfile root makes local Vly dev crawl and cache the whole repo. + root: + process.env.NODE_ENV === 'production' + ? monorepoRoot + : import.meta.dirname, }, headers: () => { return [ @@ -69,16 +110,88 @@ const nextConfig = { }, ], }, + { + // WebContainer requires the page to be cross-origin isolated. Scoped to + // the project workspace routes only (not site-wide) because COOP + // same-origin can break window.opener-based flows (e.g. OAuth popups) + // on other pages. + source: '/web/project/:path*', + headers: [ + { + key: 'Cross-Origin-Embedder-Policy', + value: 'credentialless', + }, + { + key: 'Cross-Origin-Opener-Policy', + value: 'same-origin', + }, + ], + }, ] }, reactStrictMode: false, async redirects() { + const blogRedirects = blogSlugRedirects.map((r) => ({ + ...r, + permanent: true, + })) return [ + ...blogRedirects, { source: '/b/:hash', destination: 'https://go.trybeluga.ai/:hash', permanent: false, }, + { + source: '/project/:path*', + destination: '/web/project/:path*', + permanent: false, + }, + { + source: '/dashboard/:path*', + destination: '/web/dashboard/:path*', + permanent: false, + }, + { + source: '/community/:path*', + destination: '/web/community/:path*', + permanent: false, + }, + { + source: '/admin/:path*', + destination: '/web/admin/:path*', + permanent: false, + }, + { + source: '/pricing', + destination: '/web', + permanent: true, + }, + { + source: '/web/pricing', + destination: '/web', + permanent: true, + }, + { + source: '/contact', + destination: '/web/contact', + permanent: false, + }, + { + source: '/referrals', + destination: '/web/referrals', + permanent: false, + }, + { + source: '/devtools', + destination: '/web/devtools', + permanent: false, + }, + { + source: '/invite/:path*', + destination: '/web/invite/:path*', + permanent: false, + }, ] }, async rewrites() { diff --git a/freebuff/web/package.json b/freebuff/web/package.json index b22be8891a..0f1cfa0cd3 100644 --- a/freebuff/web/package.json +++ b/freebuff/web/package.json @@ -5,37 +5,162 @@ "type": "module", "scripts": { "dev": "next dev --port 3002", - "build": "next build", - "start": "next start", - "typecheck": "tsc --noEmit -p .", + "build": "bun run prepare:workspace && bun run build:next", + "build:next": "node scripts/render-next-cache.mjs restore && NODE_OPTIONS=--max-old-space-size=4096 next build --turbopack && node scripts/assemble-standalone.mjs && node scripts/render-next-cache.mjs save && bun run postbuild:prune-cache", + "postbuild:prune-cache": "node scripts/postbuild-prune-cache.mjs", + "deploy:render-preview": "bun run scripts/render-preview-deploy.ts", + "snapshot:deploy": "bun run scripts/deploy-golden-snapshots.ts", + "prepare:workspace": "cd ../../sdk && bun run build", + "start": "node scripts/start-standalone.mjs", + "typecheck": "bun run prepare:workspace && node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit -p .", "clean": "rm -rf .next" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "^3.0.66", + "@ai-sdk/anthropic": "^2.0.53", + "@ai-sdk/cerebras": "^1.0.32", + "@ai-sdk/deepinfra": "^0.2.17", + "@ai-sdk/deepseek": "^0.2.16", + "@ai-sdk/google": "^2.0.44", + "@ai-sdk/google-vertex": "^3.0.86", + "@ai-sdk/openai": "^2.0.77", + "@ai-sdk/openai-compatible": "^1.0.28", "@auth/drizzle-adapter": "^1.7.4", + "@axiomhq/js": "^1.3.1", + "@babel/standalone": "^7.28.5", + "@codebuff/auth": "workspace:*", "@codebuff/billing": "workspace:*", "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", + "@codebuff/logging": "workspace:*", + "@codebuff/sdk": "workspace:*", + "@codesandbox/sdk": "^2.1.0", + "@codingame/monaco-vscode-api": "^33.0.9", + "@codingame/monaco-vscode-css-default-extension": "^33.0.9", + "@codingame/monaco-vscode-files-service-override": "^33.0.9", + "@codingame/monaco-vscode-html-default-extension": "^33.0.9", + "@codingame/monaco-vscode-javascript-default-extension": "^33.0.9", + "@codingame/monaco-vscode-json-default-extension": "^33.0.9", + "@codingame/monaco-vscode-languages-service-override": "^33.0.9", + "@codingame/monaco-vscode-typescript-basics-default-extension": "^33.0.9", + "@codingame/monaco-vscode-typescript-language-features-default-extension": "^33.0.9", + "@convex-dev/aggregate": "^0.2.0", + "@convex-dev/crons": "^0.2.2", + "@convex-dev/migrations": "^0.2.9", + "@convex-dev/rate-limiter": "^0.2.14", + "@convex-dev/workflow": "^0.3.3", + "@convex-dev/workpool": "^0.3.0", + "@daytonaio/sdk": "^0.145.0", + "@google/genai": "^1.12.0", + "@gravity-ai/api": "^1.1.8", + "@gravity-ai/react": "^1.1.6", + "@mendable/firecrawl-js": "^1.25.1", + "@monaco-editor/react": "^4.7.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-three/fiber": "^9.5.0", + "@tabler/icons-react": "^3.36.1", + "@tanstack/react-query": "^5.90.11", + "@tsparticles/engine": "^3.9.1", + "@tsparticles/react": "^3.0.0", + "@tsparticles/slim": "^3.9.1", + "@webcontainer/api": "^1.6.4", + "atmn": "^0.0.28", + "autumn-js": "^0.1.70", + "axiom": "^0.32.0", + "axios": "^1.9.0", + "browser-image-compression": "^2.0.2", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "framer-motion": "^11.13.3", + "cmdk": "^1.1.1", + "convex": "^1.30.0", + "convex-helpers": "^0.1.106", + "culori": "^4.0.2", + "date-fns": "^3.6.0", + "diff": "^9.0.0", + "dotenv": "^16.4.7", + "dotted-map": "^2.2.3", + "embla-carousel-react": "^8.6.0", + "exceljs": "^4.4.0", + "framer-motion": "^12.23.25", + "freestyle-sandboxes": "^0.0.95", + "human-id": "^4.1.1", + "input-otp": "^1.4.2", + "isomorphic-git": "^1.38.6", "lucide-react": "^0.487.0", - "next": "15.5.16", + "lz4-wasm": "^0.9.2", + "mammoth": "^1.12.0", + "monaco-editor": "npm:@codingame/monaco-vscode-editor-api", + "motion": "^12.23.25", + "next": "16.2.10", "next-auth": "^4.24.11", "next-themes": "^0.4.6", + "octokit": "^4.1.4", "pino": "^9.6.0", "posthog-js": "^1.363.3", "react": "^19.0.0", + "react-colorful": "^5.6.1", + "react-day-picker": "^8.10.1", "react-dom": "^19.0.0", + "react-hook-form": "^7.68.0", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^2.1.9", + "react-scan": "^0.4.3", + "react-syntax-highlighter": "^15.6.1", + "recharts": "^2.15.3", + "remark-gfm": "^4", + "resend": "^4.5.1", + "slate": "^0.120.0", + "slate-history": "^0.113.1", + "slate-react": "^0.120.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.5.2", + "three": "^0.182.0", + "tldts": "^7.0.10", + "turndown": "^7.2.4", + "turndown-plugin-gfm": "^1.0.2", + "unpdf": "^1.6.2", + "use-stick-to-bottom": "^1.1.1", + "vaul": "^1.1.2", "zod": "^4.2.1" }, "devDependencies": { "@tailwindcss/typography": "^0.5.15", + "@types/babel__standalone": "^7.1.9", + "@types/canvas-confetti": "^1.9.0", + "@types/culori": "^4.0.1", "@types/node": "^22.14.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/three": "^0.184.1", + "@types/turndown": "^5.0.6", "autoprefixer": "^10.4.21", "postcss": "^8", "tailwindcss": "^3.4.11", diff --git a/freebuff/web/public/HackTech_Caltech.png b/freebuff/web/public/HackTech_Caltech.png new file mode 100644 index 0000000000..994e9e32ad Binary files /dev/null and b/freebuff/web/public/HackTech_Caltech.png differ diff --git a/freebuff/web/public/Maya_Alexander.jpeg b/freebuff/web/public/Maya_Alexander.jpeg new file mode 100644 index 0000000000..a8905476fb Binary files /dev/null and b/freebuff/web/public/Maya_Alexander.jpeg differ diff --git a/freebuff/web/public/PerfectlyNineties-Italic.woff2 b/freebuff/web/public/PerfectlyNineties-Italic.woff2 new file mode 100644 index 0000000000..5f1c0b097d Binary files /dev/null and b/freebuff/web/public/PerfectlyNineties-Italic.woff2 differ diff --git a/freebuff/web/public/PerfectlyNineties-Regular.woff2 b/freebuff/web/public/PerfectlyNineties-Regular.woff2 new file mode 100644 index 0000000000..bbbbab74d5 Binary files /dev/null and b/freebuff/web/public/PerfectlyNineties-Regular.woff2 differ diff --git a/freebuff/web/public/Pivot_Robotics.png b/freebuff/web/public/Pivot_Robotics.png new file mode 100644 index 0000000000..3346dab182 Binary files /dev/null and b/freebuff/web/public/Pivot_Robotics.png differ diff --git a/freebuff/web/public/Placeholder.svg b/freebuff/web/public/Placeholder.svg new file mode 100644 index 0000000000..e141dfbd8b --- /dev/null +++ b/freebuff/web/public/Placeholder.svg @@ -0,0 +1,8 @@ +<svg aria-hidden="true" role="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> + <svg> + <path fill="transparent" d="M0 0h128v128H0z"/> + <path d="M88.41 84.67a32 32 0 10-48.82 0 66.13 66.13 0 0148.82 0z" fill="#788fa5"/> + <path d="M88.41 84.67a32 32 0 01-48.82 0A66.79 66.79 0 000 128h128a66.79 66.79 0 00-39.59-43.33z" fill="#9db3c8"/> + <path d="M64 96a31.93 31.93 0 0024.41-11.33 66.13 66.13 0 00-48.82 0A31.93 31.93 0 0064 96z" fill="#56687a"/> + </svg> +</svg> \ No newline at end of file diff --git a/freebuff/web/public/Siddarth_Girdhar.jpeg b/freebuff/web/public/Siddarth_Girdhar.jpeg new file mode 100644 index 0000000000..c38d3ee888 Binary files /dev/null and b/freebuff/web/public/Siddarth_Girdhar.jpeg differ diff --git a/freebuff/web/public/YC_icon.png b/freebuff/web/public/YC_icon.png new file mode 100644 index 0000000000..a6c3a46941 Binary files /dev/null and b/freebuff/web/public/YC_icon.png differ diff --git a/freebuff/web/public/YCombinator.png b/freebuff/web/public/YCombinator.png new file mode 100644 index 0000000000..24dff52bf5 Binary files /dev/null and b/freebuff/web/public/YCombinator.png differ diff --git a/freebuff/web/public/businesslogic.svg b/freebuff/web/public/businesslogic.svg new file mode 100644 index 0000000000..1701c1bd28 --- /dev/null +++ b/freebuff/web/public/businesslogic.svg @@ -0,0 +1,3 @@ +<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.37932 2.61037C7.5436 1.86894 9.11932 1.42822 10.8336 1.42822C12.5479 1.42822 14.1236 1.86894 15.2879 2.61037C16.4465 3.34751 17.2622 4.43179 17.2622 5.71394C17.2622 6.99608 16.4465 8.08037 15.2879 8.81751C14.1236 9.55894 12.5479 9.99965 10.8336 9.99965C9.11932 9.99965 7.5436 9.55894 6.37932 8.81751C5.22074 8.08037 4.40503 6.99608 4.40503 5.71394C4.40503 4.43179 5.22074 3.34751 6.37932 2.61037ZM16.8393 8.45394C16.493 8.82473 16.1005 9.14952 15.6715 9.42037C14.3765 10.2447 12.6622 10.7139 10.8336 10.7139C9.00503 10.7139 7.29146 10.2447 5.99574 9.42037C5.56624 9.15017 5.17367 8.82531 4.82789 8.45394C4.55264 8.92268 4.40672 9.45607 4.40503 9.99965C4.40503 11.2825 5.22074 12.3668 6.37932 13.1039C7.5436 13.8447 9.11932 14.2854 10.8336 14.2854C12.5479 14.2854 14.1236 13.8447 15.2879 13.1039C16.4465 12.3668 17.2622 11.2825 17.2622 9.99965C17.2622 9.44322 17.1086 8.92394 16.8393 8.45394ZM16.8393 12.7397C16.493 13.1104 16.1005 13.4352 15.6715 13.7061C14.3765 14.5304 12.6622 14.9997 10.8336 14.9997C9.00503 14.9997 7.29146 14.5304 5.99574 13.7061C5.56671 13.4352 5.17421 13.1104 4.82789 12.7397C4.55264 13.2084 4.40672 13.7418 4.40503 14.2854C4.40503 15.5675 5.22074 16.6518 6.37932 17.3889C7.5436 18.1304 9.11932 18.5711 10.8336 18.5711C12.5479 18.5711 14.1236 18.1304 15.2879 17.3889C16.4465 16.6518 17.2622 15.5675 17.2622 14.2854C17.2622 13.7282 17.1086 13.2097 16.8393 12.7397Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/clouds.png b/freebuff/web/public/clouds.png new file mode 100644 index 0000000000..72ba150997 Binary files /dev/null and b/freebuff/web/public/clouds.png differ diff --git a/freebuff/web/public/codex-device-auth-security-guide.svg b/freebuff/web/public/codex-device-auth-security-guide.svg new file mode 100644 index 0000000000..22b7a5801b --- /dev/null +++ b/freebuff/web/public/codex-device-auth-security-guide.svg @@ -0,0 +1,22 @@ +<svg width="1320" height="680" viewBox="0 0 1320 680" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="1320" height="680" rx="18" fill="#0C0F16"/> + <rect x="34" y="34" width="300" height="612" rx="16" fill="#171A22"/> + <rect x="52" y="120" width="264" height="66" rx="14" fill="#30343E"/> + <text x="106" y="160" font-size="42" fill="#F7F8FA" font-family="Arial, sans-serif">Security</text> + <text x="88" y="74" font-size="36" fill="#EDEEF2" font-family="Arial, sans-serif">Settings</text> + <circle cx="72" cy="151" r="20" stroke="#F7F8FA" stroke-width="6"/> + <circle cx="66" cy="146" r="5" fill="#F7F8FA"/> + <circle cx="79" cy="145" r="5" fill="#F7F8FA"/> + <rect x="368" y="34" width="918" height="612" rx="16" fill="#151922"/> + <text x="404" y="98" font-size="56" fill="#F7F8FA" font-family="Arial, sans-serif">Secure sign in with ChatGPT</text> + <text x="404" y="146" font-size="36" fill="#A8AFBB" font-family="Arial, sans-serif">ChatGPT Security</text> + <rect x="404" y="206" width="848" height="402" rx="12" fill="#1A1F2A" stroke="#FF3A2E" stroke-width="14"/> + <text x="450" y="286" font-size="50" fill="#F4F6FA" font-family="Arial, sans-serif">Codex CLI</text> + <text x="450" y="336" font-size="36" fill="#C8CFDA" font-family="Arial, sans-serif">Allow Codex CLI to use models from the API.</text> + <text x="450" y="430" font-size="52" fill="#F7F8FA" font-family="Arial, sans-serif">Enable device code authorization for Codex</text> + <text x="450" y="488" font-size="36" fill="#A8AFBB" font-family="Arial, sans-serif">Turn this toggle on before starting device login.</text> + <rect x="1144" y="395" width="92" height="56" rx="28" fill="#1D9BFF"/> + <circle cx="1208" cy="423" r="24" fill="#F7F8FA"/> + <rect x="1060" y="232" width="176" height="66" rx="33" fill="#2A0D0D" stroke="#FF3A2E" stroke-width="4"/> + <text x="1086" y="276" font-size="38" fill="#FF5247" font-family="Arial, sans-serif">Disconnect</text> +</svg> diff --git a/freebuff/web/public/codicon_sparkle-filled.svg b/freebuff/web/public/codicon_sparkle-filled.svg new file mode 100644 index 0000000000..113a335a69 --- /dev/null +++ b/freebuff/web/public/codicon_sparkle-filled.svg @@ -0,0 +1,5 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M16.468 24.1151C16.7072 24.3195 16.9929 24.4288 17.2856 24.4279C17.5784 24.4271 17.8636 24.316 18.1019 24.1101C18.3435 23.8965 18.5267 23.6008 18.6271 23.2591L19.2337 21.0033C19.3898 20.4381 19.6517 19.9239 19.9991 19.5016C20.3474 19.0806 20.7715 18.7629 21.2381 18.5734L23.1258 17.8324C23.3317 17.7426 23.5178 17.5966 23.6696 17.4057C23.8214 17.2148 23.9348 16.9842 24.0011 16.7316C24.0577 16.516 24.0783 16.289 24.0618 16.0636C24.0453 15.8383 23.9921 15.6192 23.9051 15.4189C23.8181 15.2187 23.6992 15.0414 23.5551 14.8973C23.4111 14.7532 23.2449 14.6451 23.0661 14.5794L21.2001 13.845C20.7328 13.6567 20.3081 13.3395 19.9596 12.9183C19.611 12.4972 19.3481 11.9837 19.1917 11.4183L18.5783 9.13792C18.4982 8.86619 18.3628 8.62332 18.1845 8.43184C18.0063 8.24036 17.791 8.10647 17.5586 8.04256C17.3262 7.97866 17.0843 7.98682 16.8552 8.06629C16.6261 8.14576 16.4174 8.29397 16.2482 8.49717C16.0912 8.68453 15.9723 8.91342 15.9008 9.16585L15.2806 11.466C15.1265 12.017 14.8703 12.5181 14.5316 12.9307C14.1929 13.3434 13.7806 13.6567 13.3264 13.8466L11.4388 14.5827C11.2336 14.6737 11.0483 14.8202 10.8968 15.0109C10.7454 15.2016 10.6319 15.4316 10.5648 15.6834C10.4996 15.9361 10.4833 16.2036 10.5172 16.4651C10.5511 16.7267 10.6343 16.9753 10.7602 17.1917C10.9339 17.4874 11.1796 17.7092 11.4618 17.8258L13.3264 18.557C13.7961 18.7456 14.2226 19.0651 14.5717 19.4897C14.9207 19.9143 15.1826 20.4322 15.3362 21.0017L15.9496 23.287C16.0473 23.6222 16.2292 23.9113 16.468 24.1151ZM24.7963 30.7559C24.9796 30.9141 25.1991 30.9994 25.4242 31C25.6494 31.0006 25.8691 30.9164 26.053 30.7591C26.2432 30.5984 26.3859 30.3681 26.4601 30.102L26.7966 28.85C26.8678 28.5896 26.9883 28.3528 27.1485 28.1589C27.3088 27.9649 27.5043 27.819 27.7194 27.7328L28.7671 27.3188C28.9561 27.242 29.1236 27.1029 29.2509 26.9171C29.3782 26.7314 29.4602 26.5064 29.4877 26.2673C29.5267 25.957 29.4723 25.64 29.3342 25.3743C29.1962 25.1085 28.9838 24.9117 28.7359 24.8199L27.6991 24.4108C27.4847 24.3239 27.2899 24.1781 27.1298 23.9848C26.9697 23.7916 26.8487 23.5561 26.7763 23.2969L26.4343 22.0269C26.3601 21.7684 26.2211 21.5444 26.0367 21.3861C25.8281 21.2042 25.5727 21.1184 25.3165 21.144C25.0603 21.1696 24.8201 21.3049 24.6389 21.5258C24.5185 21.6708 24.4278 21.8481 24.3743 22.0433L24.0391 23.2952C23.9698 23.5528 23.8523 23.7875 23.6958 23.981C23.5392 24.1745 23.3479 24.3216 23.1366 24.4108L22.0876 24.8248C21.8718 24.9134 21.6845 25.0821 21.5522 25.3067C21.42 25.5314 21.3495 25.8008 21.3507 26.0767C21.352 26.3526 21.4248 26.621 21.559 26.8439C21.6932 27.0668 21.882 27.233 22.0985 27.3188L23.1339 27.7246C23.3497 27.8133 23.5465 27.9596 23.7066 28.1534C23.8681 28.3489 23.9875 28.5855 24.0567 28.8468L24.4001 30.1184C24.4744 30.3755 24.6129 30.5982 24.7963 30.7559Z" fill="#FEFEFE"/> +</svg> diff --git a/freebuff/web/public/competitors/base44.png b/freebuff/web/public/competitors/base44.png new file mode 100644 index 0000000000..3b0c7bee87 Binary files /dev/null and b/freebuff/web/public/competitors/base44.png differ diff --git a/freebuff/web/public/competitors/base_reviews.png b/freebuff/web/public/competitors/base_reviews.png new file mode 100644 index 0000000000..abdc0e7dd2 Binary files /dev/null and b/freebuff/web/public/competitors/base_reviews.png differ diff --git a/freebuff/web/public/competitors/bolt_review_main.png b/freebuff/web/public/competitors/bolt_review_main.png new file mode 100644 index 0000000000..9313f272d9 Binary files /dev/null and b/freebuff/web/public/competitors/bolt_review_main.png differ diff --git a/freebuff/web/public/competitors/bolt_reviews.png b/freebuff/web/public/competitors/bolt_reviews.png new file mode 100644 index 0000000000..d3949ae733 Binary files /dev/null and b/freebuff/web/public/competitors/bolt_reviews.png differ diff --git a/freebuff/web/public/competitors/boltnew.png b/freebuff/web/public/competitors/boltnew.png new file mode 100644 index 0000000000..413f6487fa Binary files /dev/null and b/freebuff/web/public/competitors/boltnew.png differ diff --git a/freebuff/web/public/competitors/emergent.png b/freebuff/web/public/competitors/emergent.png new file mode 100644 index 0000000000..3961c97c9b Binary files /dev/null and b/freebuff/web/public/competitors/emergent.png differ diff --git a/freebuff/web/public/competitors/emergent_reviews.png b/freebuff/web/public/competitors/emergent_reviews.png new file mode 100644 index 0000000000..66305fe10e Binary files /dev/null and b/freebuff/web/public/competitors/emergent_reviews.png differ diff --git a/freebuff/web/public/competitors/lovable_logo.svg b/freebuff/web/public/competitors/lovable_logo.svg new file mode 100644 index 0000000000..c4fe475a5f --- /dev/null +++ b/freebuff/web/public/competitors/lovable_logo.svg @@ -0,0 +1,53 @@ +<svg width="911" height="155" viewBox="0 0 911 155" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M857.05 154.384C845.795 154.384 836.016 152.238 827.715 147.947C819.414 143.585 812.942 137.043 808.299 128.32C803.727 119.597 801.44 108.798 801.44 95.9247C801.44 83.6841 803.832 73.1319 808.616 64.2681C813.4 55.3339 820.012 48.5453 828.454 43.9024C836.896 39.2594 846.498 36.9379 857.262 36.9379C867.603 36.9379 876.783 39.1891 884.803 43.6913C892.822 48.1936 899.048 54.7359 903.48 63.3184C907.982 71.9008 910.233 82.1716 910.233 94.1308C910.233 98.422 910.198 101.834 910.128 104.366H826.027V82.2068H889.762L877.733 86.3222C877.733 80.5536 876.889 75.7348 875.2 71.8657C873.582 67.9262 871.19 64.9716 868.025 63.0018C864.859 61.0321 861.025 60.0472 856.523 60.0472C851.81 60.0472 847.659 61.208 844.071 63.5294C840.554 65.7806 837.81 69.1573 835.841 73.6595C833.941 78.1618 832.991 83.5786 832.991 89.9099V100.779C832.991 107.251 833.976 112.738 835.946 117.24C837.916 121.742 840.73 125.154 844.388 127.476C848.046 129.727 852.372 130.852 857.367 130.852C862.854 130.852 867.392 129.445 870.979 126.632C874.567 123.747 876.818 119.702 877.733 114.496H909.706C908.721 122.657 905.942 129.727 901.37 135.706C896.867 141.686 890.853 146.294 883.325 149.53C875.798 152.766 867.04 154.384 857.05 154.384Z" fill="black"/> +<path d="M759.904 0.00286865H791.877V151.321H759.904V0.00286865Z" fill="black"/> +<path d="M705.496 154.381C699.446 154.381 694.029 153.326 689.246 151.216C684.532 149.105 680.523 145.94 677.216 141.719C673.91 137.428 671.448 132.151 669.83 125.89L673.206 126.84V151.321H641.55V39.9957H673.523V64.8988L669.935 65.532C671.553 59.5524 674.015 54.4522 677.322 50.2313C680.698 45.9401 684.814 42.6689 689.668 40.4177C694.522 38.0963 699.974 36.9355 706.024 36.9355C715.099 36.9355 722.977 39.2922 729.661 44.0055C736.344 48.7188 741.479 55.5074 745.067 64.3712C748.654 73.1647 750.448 83.6114 750.448 95.7112C750.448 107.741 748.619 118.187 744.961 127.051C741.303 135.845 736.062 142.598 729.238 147.311C722.485 152.025 714.571 154.381 705.496 154.381ZM695.577 130.006C700.361 130.006 704.37 128.599 707.606 125.785C710.913 122.971 713.375 118.996 714.993 113.861C716.681 108.726 717.526 102.676 717.526 95.7112C717.526 88.7468 716.681 82.6968 714.993 77.5614C713.375 72.426 710.913 68.4514 707.606 65.6375C704.37 62.7532 700.361 61.3111 695.577 61.3111C690.864 61.3111 686.819 62.7532 683.442 65.6375C680.136 68.4514 677.638 72.4612 675.95 77.667C674.262 82.8024 673.417 88.8171 673.417 95.7112C673.417 102.676 674.262 108.726 675.95 113.861C677.638 118.996 680.136 122.971 683.442 125.785C686.819 128.599 690.864 130.006 695.577 130.006ZM641.55 0.00286865H673.523V39.9957H641.55V0.00286865Z" fill="black"/> +<path d="M561.296 154.384C554.331 154.384 548.07 153.012 542.513 150.268C537.026 147.525 532.699 143.621 529.534 138.555C526.438 133.42 524.891 127.441 524.891 120.617C524.891 110.205 527.951 102.256 534.071 96.7688C540.191 91.2113 549.02 87.5884 560.557 85.9001L579.868 83.1565C583.737 82.5937 586.797 81.8902 589.048 81.0461C591.299 80.2019 592.952 79.0763 594.007 77.6694C595.063 76.1921 595.59 74.3278 595.59 72.0767C595.59 69.7552 594.957 67.6448 593.691 65.7454C592.495 63.7757 590.666 62.228 588.204 61.1024C585.812 59.9065 582.892 59.3086 579.445 59.3086C573.958 59.3086 569.562 60.7507 566.255 63.635C562.949 66.4489 561.155 70.318 560.874 75.2424H527.845C528.127 67.7855 530.378 61.1728 534.599 55.4043C538.89 49.5654 544.834 45.0279 552.432 41.7919C560.029 38.5559 568.823 36.9379 578.812 36.9379C589.294 36.9379 598.158 38.6615 605.404 42.1085C612.65 45.4852 618.102 50.3392 621.76 56.6705C625.488 63.0018 627.352 70.5642 627.352 79.3577V125.787C627.352 130.782 627.704 135.425 628.408 139.716C629.181 143.937 630.272 146.61 631.679 147.736V151.324H598.439C597.666 148.299 597.068 144.887 596.646 141.088C596.223 137.289 595.977 133.279 595.907 129.059L601.077 126.843C599.741 131.837 597.279 136.445 593.691 140.666C590.173 144.816 585.636 148.158 580.079 150.69C574.591 153.153 568.33 154.384 561.296 154.384ZM573.114 130.958C577.616 130.958 581.591 129.973 585.038 128.003C588.485 125.963 591.123 123.149 592.952 119.562C594.852 115.974 595.801 111.894 595.801 107.321V92.759L598.439 94.2363C596.751 96.4874 594.641 98.211 592.108 99.4069C589.646 100.603 586.304 101.623 582.083 102.467L573.958 104.05C568.542 105.105 564.461 106.723 561.718 108.904C559.045 111.085 557.708 114.145 557.708 118.084C557.708 122.024 559.15 125.154 562.034 127.476C564.919 129.797 568.612 130.958 573.114 130.958Z" fill="black"/> +<path d="M416.143 39.9976H450.121L479.35 135.495H469.115L497.183 39.9976H530.212L491.696 151.323H456.135L416.143 39.9976Z" fill="black"/> +<path d="M366.162 154.384C355.117 154.384 345.409 151.992 337.038 147.208C328.736 142.425 322.3 135.601 317.727 126.737C313.225 117.873 310.974 107.497 310.974 95.6081C310.974 83.7193 313.225 73.3781 317.727 64.5847C322.3 55.7208 328.736 48.8971 337.038 44.1134C345.409 39.3298 355.117 36.9379 366.162 36.9379C377.206 36.9379 386.879 39.3298 395.18 44.1134C403.481 48.8971 409.883 55.7208 414.385 64.5847C418.958 73.3781 421.244 83.7193 421.244 95.6081C421.244 107.497 418.958 117.873 414.385 126.737C409.883 135.601 403.481 142.425 395.18 147.208C386.879 151.992 377.206 154.384 366.162 154.384ZM366.162 129.903C370.805 129.903 374.779 128.636 378.086 126.104C381.392 123.501 383.924 119.667 385.683 114.602C387.442 109.467 388.321 103.135 388.321 95.6081C388.321 84.4228 386.387 75.9458 382.517 70.1773C378.648 64.3384 373.196 61.419 366.162 61.419C361.519 61.419 357.509 62.7204 354.132 65.3233C350.826 67.8558 348.293 71.6898 346.535 76.8252C344.776 81.8902 343.896 88.1512 343.896 95.6081C343.896 103.065 344.776 109.361 346.535 114.496C348.293 119.632 350.826 123.501 354.132 126.104C357.509 128.636 361.519 129.903 366.162 129.903Z" fill="black"/> +<path d="M217.513 0.00192261H250.647V127.741L244.737 120.882C244.737 120.882 261.148 120.882 289.379 120.882C317.609 120.882 313.749 151.32 313.749 151.32H217.513V0.00192261Z" fill="black"/> +<mask id="mask0_313_127" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="149" height="152"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M44.6502 0C69.3099 0 89.3004 20.0433 89.3004 44.7679V61.7825H104.16C128.82 61.7825 148.81 81.8258 148.81 106.55C148.81 131.275 128.82 151.318 104.16 151.318H0V44.7679C0 20.0433 19.9906 0 44.6502 0Z" fill="url(#paint0_linear_313_127)"/> +</mask> +<g mask="url(#mask0_313_127)"> +<g filter="url(#filter0_f_313_127)"> +<circle cx="65.2812" cy="80.7667" r="100.733" fill="#4B73FF"/> +</g> +<g filter="url(#filter1_f_313_127)"> +<ellipse cx="76.3487" cy="25.4884" rx="129.012" ry="100.733" fill="#FF66F4"/> +</g> +<g filter="url(#filter2_f_313_127)"> +<ellipse cx="97.3926" cy="6.53951" rx="100.733" ry="88.4654" fill="#FF0105"/> +</g> +<g filter="url(#filter3_f_313_127)"> +<circle cx="78.1432" cy="25.4679" r="60.5807" fill="#FE7B02"/> +</g> +</g> +<defs> +<filter id="filter0_f_313_127" x="-80.5968" y="-65.1113" width="291.756" height="291.756" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter1_f_313_127" x="-97.8078" y="-120.39" width="348.313" height="291.756" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter2_f_313_127" x="-48.4855" y="-127.071" width="291.756" height="267.22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter3_f_313_127" x="-27.5822" y="-80.2574" width="211.451" height="211.451" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<linearGradient id="paint0_linear_313_127" x1="50.0774" y1="26.5915" x2="95.4144" y2="151.261" gradientUnits="userSpaceOnUse"> +<stop offset="0.025" stop-color="#FF8E63"/> +<stop offset="0.56" stop-color="#FF7EB0"/> +<stop offset="0.95" stop-color="#4B73FF"/> +</linearGradient> +</defs> +</svg> diff --git a/freebuff/web/public/competitors/lovable_star_reviews.png b/freebuff/web/public/competitors/lovable_star_reviews.png new file mode 100644 index 0000000000..d8f227fdb2 Binary files /dev/null and b/freebuff/web/public/competitors/lovable_star_reviews.png differ diff --git a/freebuff/web/public/competitors/replit.png b/freebuff/web/public/competitors/replit.png new file mode 100644 index 0000000000..546a7412cc Binary files /dev/null and b/freebuff/web/public/competitors/replit.png differ diff --git a/freebuff/web/public/competitors/replit_reviews.png b/freebuff/web/public/competitors/replit_reviews.png new file mode 100644 index 0000000000..951c3ddbd1 Binary files /dev/null and b/freebuff/web/public/competitors/replit_reviews.png differ diff --git a/freebuff/web/public/competitors/sponsor_boltnew.png b/freebuff/web/public/competitors/sponsor_boltnew.png new file mode 100644 index 0000000000..b3caeb3381 Binary files /dev/null and b/freebuff/web/public/competitors/sponsor_boltnew.png differ diff --git a/freebuff/web/public/competitors/vly_reviews.png b/freebuff/web/public/competitors/vly_reviews.png new file mode 100644 index 0000000000..9e0a4fa014 Binary files /dev/null and b/freebuff/web/public/competitors/vly_reviews.png differ diff --git a/freebuff/web/public/competitors/vly_star_reviews.png b/freebuff/web/public/competitors/vly_star_reviews.png new file mode 100644 index 0000000000..15e059ffd9 Binary files /dev/null and b/freebuff/web/public/competitors/vly_star_reviews.png differ diff --git a/freebuff/web/public/convex-color.svg b/freebuff/web/public/convex-color.svg new file mode 100644 index 0000000000..8622c4c072 --- /dev/null +++ b/freebuff/web/public/convex-color.svg @@ -0,0 +1,5 @@ +<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#F3B01C"/> +<path d="M53.4012 90.1735C46.0375 107.191 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3097 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.831 62.318 70.4756 53.4012 90.1735Z" fill="#8D2676"/> +<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#EE342F"/> +</svg> diff --git a/freebuff/web/public/convex-logo.svg b/freebuff/web/public/convex-logo.svg new file mode 100644 index 0000000000..e1b924ffb0 --- /dev/null +++ b/freebuff/web/public/convex-logo.svg @@ -0,0 +1,5 @@ +<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.127 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="white"/> +<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="white"/> +<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/convex.svg b/freebuff/web/public/convex.svg new file mode 100644 index 0000000000..7d70c4de1a --- /dev/null +++ b/freebuff/web/public/convex.svg @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 367 370" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,-129.225,-127.948)"> + <g id="Layer-1" serif:id="Layer 1" transform="matrix(4.16667,0,0,4.16667,0,0)"> + <g transform="matrix(1,0,0,1,86.6099,107.074)"> + <path d="M0,-6.544C13.098,-7.973 25.449,-14.834 32.255,-26.287C29.037,2.033 -2.48,19.936 -28.196,8.94C-30.569,7.925 -32.605,6.254 -34.008,4.088C-39.789,-4.83 -41.69,-16.18 -38.963,-26.48C-31.158,-13.247 -15.3,-5.131 0,-6.544" style="fill:rgb(245,176,26);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,47.1708,74.7779)"> + <path d="M0,-2.489C-5.312,9.568 -5.545,23.695 0.971,35.316C-21.946,18.37 -21.692,-17.876 0.689,-34.65C2.754,-36.197 5.219,-37.124 7.797,-37.257C18.41,-37.805 29.19,-33.775 36.747,-26.264C21.384,-26.121 6.427,-16.446 0,-2.489" style="fill:rgb(141,37,118);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,91.325,66.4152)"> + <path d="M0,-14.199C-7.749,-24.821 -19.884,-32.044 -33.173,-32.264C-7.482,-43.726 24.112,-25.143 27.557,2.322C27.877,4.876 27.458,7.469 26.305,9.769C21.503,19.345 12.602,26.776 2.203,29.527C9.838,15.64 8.889,-1.328 0,-14.199" style="fill:rgb(238,52,47);fill-rule:nonzero;"/> + </g> + </g> + </g> +</svg> diff --git a/freebuff/web/public/device.png b/freebuff/web/public/device.png new file mode 100644 index 0000000000..226a48a594 Binary files /dev/null and b/freebuff/web/public/device.png differ diff --git a/freebuff/web/public/favicon.ico b/freebuff/web/public/favicon.ico new file mode 100644 index 0000000000..c0b9fae283 Binary files /dev/null and b/freebuff/web/public/favicon.ico differ diff --git a/freebuff/web/public/favicon.svg b/freebuff/web/public/favicon.svg new file mode 100644 index 0000000000..f15afc5020 --- /dev/null +++ b/freebuff/web/public/favicon.svg @@ -0,0 +1,6 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="150" height="150" rx="20" fill="#0a0a0b"/> + <path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#FFFFFF"/> + <path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#FFFFFF"/> + <path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="#7CFF3F"/> +</svg> diff --git a/freebuff/web/public/flowbite_thumbs-up-solid.svg b/freebuff/web/public/flowbite_thumbs-up-solid.svg new file mode 100644 index 0000000000..a1d8197707 --- /dev/null +++ b/freebuff/web/public/flowbite_thumbs-up-solid.svg @@ -0,0 +1,3 @@ +<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6967 9.68403H19.6617C19.9837 9.68403 20.3017 9.76403 20.5867 9.91603C20.8717 10.068 21.1187 10.29 21.3037 10.561C21.4884 10.8342 21.6065 11.1468 21.6485 11.4738C21.6905 11.8008 21.6553 12.1331 21.5457 12.444L19.1857 19.645C18.8977 20.459 18.7057 21 17.3017 21C15.2297 21 13.0257 20.323 11.1447 19.744C10.6727 19.599 10.2207 19.46 9.79675 19.34H9.68175V9.47802C11.3396 7.84219 12.7655 5.98694 13.9197 3.96403C14.1138 3.59053 14.4326 3.29684 14.8207 3.13403C15.2053 2.97366 15.6347 2.95663 16.0307 3.08603C16.4267 3.21603 16.7667 3.48303 16.9907 3.84303C17.2157 4.20303 17.3107 4.63103 17.2597 5.05403L15.6967 9.68403ZM4.84375 10H7.66675V18C7.66675 18.5305 7.45603 19.0392 7.08096 19.4142C6.70589 19.7893 6.19718 20 5.66675 20C5.13632 20 4.62761 19.7893 4.25253 19.4142C3.87746 19.0392 3.66675 18.5305 3.66675 18V11.177C3.66675 10.527 4.19475 10 4.84375 10Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/freebuff-icon.svg b/freebuff/web/public/freebuff-icon.svg new file mode 100644 index 0000000000..f15afc5020 --- /dev/null +++ b/freebuff/web/public/freebuff-icon.svg @@ -0,0 +1,6 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="150" height="150" rx="20" fill="#0a0a0b"/> + <path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#FFFFFF"/> + <path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#FFFFFF"/> + <path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="#7CFF3F"/> +</svg> diff --git a/freebuff/web/public/freebuff-logo.svg b/freebuff/web/public/freebuff-logo.svg new file mode 100644 index 0000000000..56ff0d2335 --- /dev/null +++ b/freebuff/web/public/freebuff-logo.svg @@ -0,0 +1,5 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#FFFFFF"/> + <path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#FFFFFF"/> + <path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="#7CFF3F"/> +</svg> diff --git a/freebuff/web/public/freebuff.svg b/freebuff/web/public/freebuff.svg new file mode 100644 index 0000000000..377fe7bc64 --- /dev/null +++ b/freebuff/web/public/freebuff.svg @@ -0,0 +1,142 @@ +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + width="100%" viewBox="0 0 512 512" enable-background="new 0 0 512 512" xml:space="preserve"> +<path fill="#FDFDFD" opacity="1.000000" stroke="none" + d=" +M513.000000,40.000000 + C513.000000,184.023331 513.000000,328.046661 512.664429,472.237976 + C511.685150,473.988953 510.989319,475.553009 510.405884,477.157928 + C504.572479,493.203979 493.967621,504.460083 477.646484,510.116211 + C476.263885,510.595367 475.207794,512.016479 474.000000,513.000000 + C329.976685,513.000000 185.953354,513.000000 41.762054,512.664429 + C40.011078,511.685181 38.447044,510.989319 36.842136,510.405884 + C20.796053,504.572479 9.539977,493.967590 3.883745,477.646484 + C3.404601,476.263916 1.983488,475.207794 0.999995,474.000000 + C1.000000,329.976685 1.000000,185.953354 1.335560,41.762051 + C2.314818,40.011070 3.010695,38.447041 3.594144,36.842133 + C9.427552,20.796053 20.032377,9.539956 36.353527,3.883760 + C37.736095,3.404621 38.792217,1.983503 40.000000,1.000000 + C184.023331,1.000000 328.046661,1.000000 472.237976,1.335571 + C473.988953,2.314838 475.552979,3.010711 477.157867,3.594153 + C493.203949,9.427554 504.460022,20.032358 510.116180,36.353542 + C510.595306,37.736107 512.016479,38.792225 513.000000,40.000000 +M477.163574,415.500061 + C477.163574,298.353241 477.165344,181.206406 477.155243,64.059578 + C477.154938,60.561733 477.137665,57.057926 476.943359,53.567196 + C476.405640,43.906849 470.086670,37.623329 460.361969,37.057804 + C457.203827,36.874146 454.033295,36.845142 450.868408,36.844898 + C321.723602,36.834953 192.578781,36.834183 63.433960,36.847691 + C59.770302,36.848076 56.091034,36.832458 52.446167,37.142830 + C44.096630,37.853817 37.941414,43.930523 37.172737,52.269863 + C36.821819,56.076962 36.849152,59.926746 36.848709,63.757191 + C36.833866,192.568726 36.834972,321.380280 36.844349,450.191803 + C36.844589,453.523071 36.873310,456.858917 37.043072,460.184753 + C37.551212,470.139771 43.866451,476.430084 53.887321,476.957214 + C57.213081,477.132172 60.549694,477.155457 63.881351,477.155701 + C192.526260,477.165009 321.171173,477.164551 449.816071,477.157532 + C452.814331,477.157379 455.813965,477.110565 458.810577,477.012299 + C470.390228,476.632599 476.752960,470.516296 476.963837,458.989349 + C477.222870,444.829773 477.113647,430.663513 477.163574,415.500061 +z"/> +<path fill="#000000" opacity="1.000000" stroke="none" + d=" +M39.531342,1.000000 + C38.792217,1.983503 37.736095,3.404621 36.353527,3.883760 + C20.032377,9.539956 9.427552,20.796053 3.594144,36.842133 + C3.010695,38.447041 2.314818,40.011070 1.335560,41.297035 + C1.000000,27.740946 1.000000,14.481891 1.000000,1.000000 + C13.686692,1.000000 26.374687,1.000000 39.531342,1.000000 +z"/> +<path fill="#000000" opacity="1.000000" stroke="none" + d=" +M513.000000,39.531342 + C512.016479,38.792225 510.595306,37.736107 510.116180,36.353542 + C504.460022,20.032358 493.203949,9.427554 477.157867,3.594153 + C475.552979,3.010711 473.988953,2.314838 472.703003,1.335571 + C486.259064,1.000000 499.518097,1.000000 513.000000,1.000000 + C513.000000,13.686692 513.000000,26.374687 513.000000,39.531342 +z"/> +<path fill="#000000" opacity="1.000000" stroke="none" + d=" +M474.468658,513.000000 + C475.207794,512.016479 476.263885,510.595367 477.646484,510.116211 + C493.967621,504.460083 504.572479,493.203979 510.405884,477.157928 + C510.989319,475.553009 511.685150,473.988953 512.664429,472.703003 + C513.000000,486.259064 513.000000,499.518097 513.000000,513.000000 + C500.313293,513.000000 487.625305,513.000000 474.468658,513.000000 +z"/> +<path fill="#000000" opacity="1.000000" stroke="none" + d=" +M0.999995,474.468658 + C1.983488,475.207794 3.404601,476.263916 3.883745,477.646484 + C9.539977,493.967590 20.796053,504.572479 36.842136,510.405884 + C38.447044,510.989319 40.011078,511.685181 41.297039,512.664429 + C27.740946,513.000000 14.481891,513.000000 1.000000,513.000000 + C1.000000,500.313293 1.000000,487.625305 0.999995,474.468658 +z"/> +<path fill="#000000" opacity="1.000000" stroke="none" + d=" +M477.163574,416.000092 + C477.113647,430.663513 477.222870,444.829773 476.963837,458.989349 + C476.752960,470.516296 470.390228,476.632599 458.810577,477.012299 + C455.813965,477.110565 452.814331,477.157379 449.816071,477.157532 + C321.171173,477.164551 192.526260,477.165009 63.881351,477.155701 + C60.549694,477.155457 57.213081,477.132172 53.887321,476.957214 + C43.866451,476.430084 37.551212,470.139771 37.043072,460.184753 + C36.873310,456.858917 36.844589,453.523071 36.844349,450.191803 + C36.834972,321.380280 36.833866,192.568726 36.848709,63.757191 + C36.849152,59.926746 36.821819,56.076962 37.172737,52.269863 + C37.941414,43.930523 44.096630,37.853817 52.446167,37.142830 + C56.091034,36.832458 59.770302,36.848076 63.433960,36.847691 + C192.578781,36.834183 321.723602,36.834953 450.868408,36.844898 + C454.033295,36.845142 457.203827,36.874146 460.361969,37.057804 + C470.086670,37.623329 476.405640,43.906849 476.943359,53.567196 + C477.137665,57.057926 477.154938,60.561733 477.155243,64.059578 + C477.165344,181.206406 477.163574,298.353241 477.163574,416.000092 +M147.116623,120.220955 + C145.746643,114.273163 144.376648,108.325371 142.983978,102.279053 + C141.718735,102.279053 140.567200,102.247253 139.419769,102.301697 + C139.136963,102.315117 138.867462,102.609344 138.271606,102.966370 + C133.640396,141.733734 111.451279,163.468536 72.973022,168.167770 + C71.157974,173.309280 74.397263,173.479630 77.472977,173.977966 + C101.533699,177.876251 120.911560,188.717499 131.010696,211.963150 + C134.669128,220.383911 136.576080,229.565598 139.440613,238.917191 + C139.417847,238.914902 140.216019,239.066391 141.011688,239.054230 + C141.788834,239.042358 142.563446,238.863403 143.521729,238.732941 + C148.200897,199.444351 170.516983,177.795044 209.463104,173.090469 + C209.250839,171.103088 209.122375,169.816788 208.963089,168.534348 + C208.946564,168.401413 208.746704,168.291245 208.575790,168.111465 + C179.152649,164.189697 156.956436,150.760345 147.116623,120.220955 +M229.500275,233.067947 + C222.893936,233.067947 216.287598,233.067947 209.620834,233.067947 + C209.620834,243.357285 209.620834,253.083405 209.620834,262.681458 + C253.366272,262.681458 296.781494,262.681458 340.079468,262.681458 + C340.079468,252.741257 340.079468,243.124084 340.079468,233.067947 + C303.402679,233.067947 266.951416,233.067947 229.500275,233.067947 +z"/> +<path fill="#FDFDFD" opacity="1.000000" stroke="none" + d=" +M147.238083,120.600220 + C156.956436,150.760345 179.152649,164.189697 208.575790,168.111465 + C208.746704,168.291245 208.946564,168.401413 208.963089,168.534348 + C209.122375,169.816788 209.250839,171.103088 209.463104,173.090469 + C170.516983,177.795044 148.200897,199.444351 143.521729,238.732941 + C142.563446,238.863403 141.788834,239.042358 141.011688,239.054230 + C140.216019,239.066391 139.417847,238.914902 139.440613,238.917191 + C136.576080,229.565598 134.669128,220.383911 131.010696,211.963150 + C120.911560,188.717499 101.533699,177.876251 77.472977,173.977966 + C74.397263,173.479630 71.157974,173.309280 72.973022,168.167770 + C111.451279,163.468536 133.640396,141.733734 138.271606,102.966370 + C138.867462,102.609344 139.136963,102.315117 139.419769,102.301697 + C140.567200,102.247253 141.718735,102.279053 142.983978,102.279053 + C144.376648,108.325371 145.746643,114.273163 147.238083,120.600220 +z"/> +<path fill="#FDFDFD" opacity="1.000000" stroke="none" + d=" +M230.000229,233.067947 + C266.951416,233.067947 303.402679,233.067947 340.079468,233.067947 + C340.079468,243.124084 340.079468,252.741257 340.079468,262.681458 + C296.781494,262.681458 253.366272,262.681458 209.620834,262.681458 + C209.620834,253.083405 209.620834,243.357285 209.620834,233.067947 + C216.287598,233.067947 222.893936,233.067947 230.000229,233.067947 +z"/> +</svg> \ No newline at end of file diff --git a/freebuff/web/public/hero.webp b/freebuff/web/public/hero.webp new file mode 100644 index 0000000000..e5472157e3 Binary files /dev/null and b/freebuff/web/public/hero.webp differ diff --git a/freebuff/web/public/landing/Glass-morphism-screenshot.png b/freebuff/web/public/landing/Glass-morphism-screenshot.png new file mode 100644 index 0000000000..ecd83d589b Binary files /dev/null and b/freebuff/web/public/landing/Glass-morphism-screenshot.png differ diff --git a/freebuff/web/public/landing/Minecraft-screenshot.png b/freebuff/web/public/landing/Minecraft-screenshot.png new file mode 100644 index 0000000000..d4ff218247 Binary files /dev/null and b/freebuff/web/public/landing/Minecraft-screenshot.png differ diff --git a/freebuff/web/public/landing/Nocode-screenshot.png b/freebuff/web/public/landing/Nocode-screenshot.png new file mode 100644 index 0000000000..0f40018e18 Binary files /dev/null and b/freebuff/web/public/landing/Nocode-screenshot.png differ diff --git a/freebuff/web/public/landing/Slack-screenshot.png b/freebuff/web/public/landing/Slack-screenshot.png new file mode 100644 index 0000000000..dfb1441589 Binary files /dev/null and b/freebuff/web/public/landing/Slack-screenshot.png differ diff --git a/freebuff/web/public/landing/below_clouds.jpeg b/freebuff/web/public/landing/below_clouds.jpeg new file mode 100644 index 0000000000..b2891da2ef Binary files /dev/null and b/freebuff/web/public/landing/below_clouds.jpeg differ diff --git a/freebuff/web/public/landing/bg-clouds.webp b/freebuff/web/public/landing/bg-clouds.webp new file mode 100644 index 0000000000..ffb16a825b Binary files /dev/null and b/freebuff/web/public/landing/bg-clouds.webp differ diff --git a/freebuff/web/public/landing/bg-footer.webp b/freebuff/web/public/landing/bg-footer.webp new file mode 100644 index 0000000000..a7be1fc56c Binary files /dev/null and b/freebuff/web/public/landing/bg-footer.webp differ diff --git a/freebuff/web/public/landing/bushes-fg.webp b/freebuff/web/public/landing/bushes-fg.webp new file mode 100644 index 0000000000..f9b05ad289 Binary files /dev/null and b/freebuff/web/public/landing/bushes-fg.webp differ diff --git a/freebuff/web/public/landing/codex.svg b/freebuff/web/public/landing/codex.svg new file mode 100644 index 0000000000..023194d6eb --- /dev/null +++ b/freebuff/web/public/landing/codex.svg @@ -0,0 +1,23 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none"> + <defs> + <linearGradient id="codexBlob" x1="24" y1="22" x2="78" y2="80" gradientUnits="userSpaceOnUse"> + <stop offset="0" stop-color="#5C8BFF"/> + <stop offset="0.55" stop-color="#7B6BF2"/> + <stop offset="1" stop-color="#9B57F0"/> + </linearGradient> + </defs> + <rect width="100" height="100" rx="26" fill="#ffffff"/> + <g fill="url(#codexBlob)"> + <circle cx="50" cy="50" r="19"/> + <circle cx="66" cy="50" r="14"/> + <circle cx="61.3" cy="61.3" r="14"/> + <circle cx="50" cy="66" r="14"/> + <circle cx="38.7" cy="61.3" r="14"/> + <circle cx="34" cy="50" r="14"/> + <circle cx="38.7" cy="38.7" r="14"/> + <circle cx="50" cy="34" r="14"/> + <circle cx="61.3" cy="38.7" r="14"/> + </g> + <polyline points="43,41 52,50 43,59" fill="none" stroke="#ffffff" stroke-width="5.4" stroke-linecap="round" stroke-linejoin="round"/> + <line x1="55" y1="58" x2="65" y2="58" stroke="#ffffff" stroke-width="5.4" stroke-linecap="round"/> +</svg> diff --git a/freebuff/web/public/landing/daytona_logo.png b/freebuff/web/public/landing/daytona_logo.png new file mode 100644 index 0000000000..d47c8a38cc Binary files /dev/null and b/freebuff/web/public/landing/daytona_logo.png differ diff --git a/freebuff/web/public/landing/demo-chat.png b/freebuff/web/public/landing/demo-chat.png new file mode 100644 index 0000000000..c4482b707b Binary files /dev/null and b/freebuff/web/public/landing/demo-chat.png differ diff --git a/freebuff/web/public/landing/demo-web.png b/freebuff/web/public/landing/demo-web.png new file mode 100644 index 0000000000..fbbf897e9c Binary files /dev/null and b/freebuff/web/public/landing/demo-web.png differ diff --git a/freebuff/web/public/landing/hero-bg.webp b/freebuff/web/public/landing/hero-bg.webp new file mode 100644 index 0000000000..c1da6067b2 Binary files /dev/null and b/freebuff/web/public/landing/hero-bg.webp differ diff --git a/freebuff/web/public/landing/hero-sun.webp b/freebuff/web/public/landing/hero-sun.webp new file mode 100644 index 0000000000..e5472157e3 Binary files /dev/null and b/freebuff/web/public/landing/hero-sun.webp differ diff --git a/freebuff/web/public/landing/hills-bg.webp b/freebuff/web/public/landing/hills-bg.webp new file mode 100644 index 0000000000..93f3a49504 Binary files /dev/null and b/freebuff/web/public/landing/hills-bg.webp differ diff --git a/freebuff/web/public/landing/landmarks.jpeg b/freebuff/web/public/landing/landmarks.jpeg new file mode 100644 index 0000000000..aa5b2bc3de Binary files /dev/null and b/freebuff/web/public/landing/landmarks.jpeg differ diff --git a/freebuff/web/public/landing/lovable.svg b/freebuff/web/public/landing/lovable.svg new file mode 100644 index 0000000000..b7bdcaf8f1 --- /dev/null +++ b/freebuff/web/public/landing/lovable.svg @@ -0,0 +1,46 @@ +<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg"> +<mask id="mask0_3001_194" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="0" width="178" height="180"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M54.6052 0C83.9389 0 107.719 23.8424 107.719 53.2535V73.4931H125.395C154.729 73.4931 178.508 97.3355 178.508 126.747C178.508 156.158 154.729 180 125.395 180H1.4917V53.2535C1.4917 23.8424 25.2714 0 54.6052 0Z" fill="url(#paint0_linear_3001_194)"/> +</mask> +<g mask="url(#mask0_3001_194)"> +<g filter="url(#filter0_f_3001_194)"> +<circle cx="79.1388" cy="96.0857" r="119.827" fill="#4B73FF"/> +</g> +<g filter="url(#filter1_f_3001_194)"> +<ellipse cx="92.3162" cy="30.3281" rx="153.465" ry="119.827" fill="#FF66F4"/> +</g> +<g filter="url(#filter2_f_3001_194)"> +<ellipse cx="117.345" cy="7.77496" rx="119.827" ry="105.234" fill="#FF0105"/> +</g> +<g filter="url(#filter3_f_3001_194)"> +<circle cx="94.4282" cy="30.2969" r="72.0635" fill="#FE7B02"/> +</g> +</g> +<defs> +<filter id="filter0_f_3001_194" x="-94.3897" y="-77.4428" width="347.057" height="347.057" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="26.8508" result="effect1_foregroundBlur_3001_194"/> +</filter> +<filter id="filter1_f_3001_194" x="-114.851" y="-143.2" width="414.334" height="347.057" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="26.8508" result="effect1_foregroundBlur_3001_194"/> +</filter> +<filter id="filter2_f_3001_194" x="-56.1831" y="-151.16" width="347.057" height="317.87" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="26.8508" result="effect1_foregroundBlur_3001_194"/> +</filter> +<filter id="filter3_f_3001_194" x="-31.3369" y="-95.4683" width="251.53" height="251.53" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="26.8508" result="effect1_foregroundBlur_3001_194"/> +</filter> +<linearGradient id="paint0_linear_3001_194" x1="61.061" y1="31.6318" x2="114.991" y2="179.932" gradientUnits="userSpaceOnUse"> +<stop offset="0.025" stop-color="#FF8E63"/> +<stop offset="0.56" stop-color="#FF7EB0"/> +<stop offset="0.95" stop-color="#4B73FF"/> +</linearGradient> +</defs> +</svg> diff --git a/freebuff/web/public/landing/mid_sf_bg.jpeg b/freebuff/web/public/landing/mid_sf_bg.jpeg new file mode 100644 index 0000000000..3f055928a6 Binary files /dev/null and b/freebuff/web/public/landing/mid_sf_bg.jpeg differ diff --git a/freebuff/web/public/landing/minecraft-project.webp b/freebuff/web/public/landing/minecraft-project.webp new file mode 100644 index 0000000000..468a110b2d Binary files /dev/null and b/freebuff/web/public/landing/minecraft-project.webp differ diff --git a/freebuff/web/public/landing/new_skyline.jpeg b/freebuff/web/public/landing/new_skyline.jpeg new file mode 100644 index 0000000000..94911a2b9a Binary files /dev/null and b/freebuff/web/public/landing/new_skyline.jpeg differ diff --git a/freebuff/web/public/landing/shadcn_logo.png b/freebuff/web/public/landing/shadcn_logo.png new file mode 100644 index 0000000000..9ac00278b9 Binary files /dev/null and b/freebuff/web/public/landing/shadcn_logo.png differ diff --git a/freebuff/web/public/landing/sky-bg.webp b/freebuff/web/public/landing/sky-bg.webp new file mode 100644 index 0000000000..bd3b02d8f6 Binary files /dev/null and b/freebuff/web/public/landing/sky-bg.webp differ diff --git a/freebuff/web/public/landing/skyline.jpeg b/freebuff/web/public/landing/skyline.jpeg new file mode 100644 index 0000000000..1a4e73119c Binary files /dev/null and b/freebuff/web/public/landing/skyline.jpeg differ diff --git a/freebuff/web/public/light-blue-check-vly.png b/freebuff/web/public/light-blue-check-vly.png new file mode 100644 index 0000000000..c3cb37aee8 Binary files /dev/null and b/freebuff/web/public/light-blue-check-vly.png differ diff --git a/freebuff/web/public/line-md_arrow-up.png b/freebuff/web/public/line-md_arrow-up.png new file mode 100644 index 0000000000..4d13ca2b37 Binary files /dev/null and b/freebuff/web/public/line-md_arrow-up.png differ diff --git a/freebuff/web/public/line-md_arrow-up.svg b/freebuff/web/public/line-md_arrow-up.svg new file mode 100644 index 0000000000..924125748c --- /dev/null +++ b/freebuff/web/public/line-md_arrow-up.svg @@ -0,0 +1,6 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M21.2578 27.6665V12.4349" stroke="#FEFEFE" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M27.3505 18.0925L21.2579 11.9999L15.1653 18.0925" stroke="#FEFEFE" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/freebuff/web/public/logo.svg b/freebuff/web/public/logo.svg new file mode 100644 index 0000000000..56ff0d2335 --- /dev/null +++ b/freebuff/web/public/logo.svg @@ -0,0 +1,5 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#FFFFFF"/> + <path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#FFFFFF"/> + <path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="#7CFF3F"/> +</svg> diff --git a/freebuff/web/public/logos/faclon_logo_rounded_white.png b/freebuff/web/public/logos/faclon_logo_rounded_white.png new file mode 100644 index 0000000000..cbddaa56a3 Binary files /dev/null and b/freebuff/web/public/logos/faclon_logo_rounded_white.png differ diff --git a/freebuff/web/public/logos/falcon_clear_bg.png b/freebuff/web/public/logos/falcon_clear_bg.png new file mode 100644 index 0000000000..a6f7990b2d Binary files /dev/null and b/freebuff/web/public/logos/falcon_clear_bg.png differ diff --git a/freebuff/web/public/logos/falcon_logo_dark.png b/freebuff/web/public/logos/falcon_logo_dark.png new file mode 100644 index 0000000000..a932f09660 Binary files /dev/null and b/freebuff/web/public/logos/falcon_logo_dark.png differ diff --git a/freebuff/web/public/logos/falcon_logo_favicon.png b/freebuff/web/public/logos/falcon_logo_favicon.png new file mode 100644 index 0000000000..ef05499d13 Binary files /dev/null and b/freebuff/web/public/logos/falcon_logo_favicon.png differ diff --git a/freebuff/web/public/logos/favicon_old.ico b/freebuff/web/public/logos/favicon_old.ico new file mode 100644 index 0000000000..52e4c6105e Binary files /dev/null and b/freebuff/web/public/logos/favicon_old.ico differ diff --git a/freebuff/web/public/logos/logo_old.svg b/freebuff/web/public/logos/logo_old.svg new file mode 100644 index 0000000000..c0e872f1ff --- /dev/null +++ b/freebuff/web/public/logos/logo_old.svg @@ -0,0 +1,4 @@ +<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M43.3425 71.9746C40.83 69.3049 22.3495 41.8363 14 28C42.2387 28 74.0844 31.2927 90.072 54.5697C94.2246 60.6157 97.0525 66.9165 99.8804 73.6386C102.708 66.9165 105.536 60.6157 109.689 54.5697C125.676 31.2927 157.522 28 185.761 28L99.8804 171.134L74.1163 129.228C94.1551 110.335 108.468 114.596 115.625 118.89C118.488 111.733 120.125 87.5292 90.5767 86.685C65.5283 85.9693 47.3406 76.2228 43.3425 71.9746Z" fill="#A37FBC"/> +<ellipse cx="99.255" cy="103.68" rx="3.57633" ry="2.9995" fill="#A37FBC"/> +</svg> diff --git a/freebuff/web/public/manifest/android-chrome-192x192.png b/freebuff/web/public/manifest/android-chrome-192x192.png new file mode 100644 index 0000000000..9616a5e493 Binary files /dev/null and b/freebuff/web/public/manifest/android-chrome-192x192.png differ diff --git a/freebuff/web/public/manifest/android-chrome-512x512.png b/freebuff/web/public/manifest/android-chrome-512x512.png new file mode 100644 index 0000000000..ef05499d13 Binary files /dev/null and b/freebuff/web/public/manifest/android-chrome-512x512.png differ diff --git a/freebuff/web/public/manifest/apple-touch-icon.png b/freebuff/web/public/manifest/apple-touch-icon.png new file mode 100644 index 0000000000..0a10d41475 Binary files /dev/null and b/freebuff/web/public/manifest/apple-touch-icon.png differ diff --git a/freebuff/web/public/manifest/favicon-16x16.png b/freebuff/web/public/manifest/favicon-16x16.png new file mode 100644 index 0000000000..b6b21c94d0 Binary files /dev/null and b/freebuff/web/public/manifest/favicon-16x16.png differ diff --git a/freebuff/web/public/manifest/favicon-32x32.png b/freebuff/web/public/manifest/favicon-32x32.png new file mode 100644 index 0000000000..5f6e0e1ba4 Binary files /dev/null and b/freebuff/web/public/manifest/favicon-32x32.png differ diff --git a/freebuff/web/public/material-symbols_photo-rounded.svg b/freebuff/web/public/material-symbols_photo-rounded.svg new file mode 100644 index 0000000000..f6ab39aec7 --- /dev/null +++ b/freebuff/web/public/material-symbols_photo-rounded.svg @@ -0,0 +1,5 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M12.7222 30C12.1111 30 11.5881 29.7826 11.1533 29.3478C10.7185 28.913 10.5007 28.3896 10.5 27.7778V12.2222C10.5 11.6111 10.7178 11.0881 11.1533 10.6533C11.5889 10.2185 12.1119 10.0007 12.7222 10H28.2778C28.8889 10 29.4122 10.2178 29.8478 10.6533C30.2833 11.0889 30.5007 11.6119 30.5 12.2222V27.7778C30.5 28.3889 30.2826 28.9122 29.8478 29.3478C29.413 29.7833 28.8896 30.0007 28.2778 30H12.7222ZM14.9444 25.5556H26.0556C26.2778 25.5556 26.4444 25.4537 26.5556 25.25C26.6667 25.0463 26.6481 24.8519 26.5 24.6667L23.4444 20.5833C23.3333 20.4352 23.1852 20.3611 23 20.3611C22.8148 20.3611 22.6667 20.4352 22.5556 20.5833L19.6667 24.4444L17.6111 21.6944C17.5 21.5463 17.3519 21.4722 17.1667 21.4722C16.9815 21.4722 16.8333 21.5463 16.7222 21.6944L14.5 24.6667C14.3519 24.8519 14.3333 25.0463 14.4444 25.25C14.5556 25.4537 14.7222 25.5556 14.9444 25.5556Z" fill="#FEFEFE"/> +</svg> diff --git a/freebuff/web/public/opengraph-image.png b/freebuff/web/public/opengraph-image.png new file mode 100644 index 0000000000..c2c00527b1 Binary files /dev/null and b/freebuff/web/public/opengraph-image.png differ diff --git a/freebuff/web/public/ph_flower-fill-1.svg b/freebuff/web/public/ph_flower-fill-1.svg new file mode 100644 index 0000000000..3653cf3d9e --- /dev/null +++ b/freebuff/web/public/ph_flower-fill-1.svg @@ -0,0 +1,27 @@ +<svg width="237" height="236" viewBox="0 0 237 236" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5048)"> +<rect width="231.8" height="231.8" transform="translate(2.5 2)" fill="#F1F1F1"/> +<path d="M192.965 119.131C192.232 118.706 144.586 118.298 143.753 117.9C144.586 117.502 192.232 117.094 192.965 116.668C197.118 114.308 200.762 111.149 203.689 107.374C206.615 103.599 208.766 99.2822 210.018 94.6726C211.269 90.0629 211.596 85.2512 210.981 80.5145C210.365 75.7778 208.819 71.2097 206.43 67.0731C204.042 62.9366 200.859 59.3133 197.065 56.4118C193.27 53.5104 188.939 51.3882 184.322 50.1673C179.704 48.9464 174.89 48.651 170.158 49.298C165.425 49.9451 160.867 51.5219 156.747 53.9376C156.013 54.3632 135.227 97.7605 134.475 98.2947C134.548 97.3892 154.619 53.5664 154.619 52.6609C154.619 43.0551 150.803 33.8427 144.011 27.0504C137.218 20.258 128.006 16.4421 118.4 16.4421C108.794 16.4421 99.5818 20.258 92.7895 27.0504C85.9971 33.8427 82.1813 43.0551 82.1813 52.6609C82.1813 53.512 105.597 94.989 105.714 95.9488C104.963 95.4326 80.7868 54.3632 80.0534 53.9376C75.9328 51.5219 71.375 49.9451 66.6425 49.298C61.91 48.651 57.0963 48.9464 52.4784 50.1673C47.8605 51.3882 43.5298 53.5104 39.7354 56.4118C35.9411 59.3133 32.7582 62.9366 30.3698 67.0731C27.9815 71.2097 26.435 75.7778 25.8193 80.5145C25.2037 85.2512 25.531 90.0629 26.7825 94.6726C28.034 99.2822 30.1849 103.599 33.1115 107.374C36.038 111.149 39.6823 114.308 43.8347 116.668C44.5681 117.094 92.2438 118.733 93.0768 119.131C92.2438 119.53 44.5681 118.706 43.8347 119.131C39.6823 121.492 36.038 124.651 33.1115 128.426C30.1849 132.201 28.034 136.518 26.7825 141.127C25.531 145.737 25.2037 150.549 25.8193 155.285C26.435 160.022 27.9815 164.59 30.3698 168.727C32.7582 172.863 35.9411 176.487 39.7354 179.388C43.5298 182.289 47.8605 184.412 52.4784 185.633C57.0963 186.853 61.91 187.149 66.6425 186.502C71.375 185.855 75.9328 184.278 80.0534 181.862C80.7868 181.437 108.999 141.662 109.751 141.127C109.678 142.033 82.1813 182.233 82.1813 183.094C82.1813 192.699 85.9971 201.912 92.7895 208.704C99.5818 215.497 108.794 219.312 118.4 219.312C128.006 219.312 137.218 215.497 144.011 208.704C150.803 201.912 154.619 192.699 154.619 183.094C154.619 182.243 128.635 142.042 128.562 141.127C129.314 141.643 156.013 181.437 156.747 181.862C162.233 185.04 168.461 186.714 174.802 186.716C177.988 186.712 181.16 186.292 184.237 185.466C191.156 183.611 197.37 179.747 202.093 174.36C206.816 168.974 209.835 162.308 210.77 155.206C211.704 148.103 210.511 140.884 207.342 134.459C204.173 128.035 199.17 122.694 192.965 119.113V119.131ZM118.4 143.253C113.386 143.253 108.484 141.766 104.315 138.98C100.145 136.194 96.8957 132.235 94.9768 127.602C93.0579 122.969 92.5558 117.872 93.534 112.954C94.5123 108.036 96.9269 103.518 100.473 99.9725C104.018 96.4268 108.536 94.0122 113.454 93.0339C118.372 92.0557 123.47 92.5578 128.102 94.4767C132.735 96.3956 136.695 99.6452 139.48 103.814C142.266 107.984 143.753 112.886 143.753 117.9C143.753 124.624 141.082 131.073 136.327 135.827C131.573 140.582 125.124 143.253 118.4 143.253Z" fill="url(#paint0_radial_1287_5048)"/> +<circle cx="49" cy="50.5" r="2.5" fill="white"/> +<circle cx="166" cy="111.5" r="2.5" fill="white"/> +<circle cx="173" cy="121.5" r="4.5" fill="white"/> +<circle cx="141.465" cy="36.0203" r="2.88308" fill="white"/> +<circle cx="151.844" cy="77.5367" r="1.72985" fill="white"/> +<circle cx="154.15" cy="85.6095" r="1.72985" fill="white"/> +<circle cx="141.465" cy="36.0203" r="2.88308" fill="white"/> +<circle cx="55" cy="62.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5048" x="0.5" y="0" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1287_5048"/> +</filter> +<radialGradient id="paint0_radial_1287_5048" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.4 117.877) rotate(90) scale(101.435 92.8828)"> +<stop offset="0.115385" stop-color="#F8EAD1"/> +<stop offset="0.254808" stop-color="#FBE5E6"/> +<stop offset="0.692308" stop-color="#B4CDE8"/> +<stop offset="1" stop-color="#81C7D6"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/ph_flower-fill-2.svg b/freebuff/web/public/ph_flower-fill-2.svg new file mode 100644 index 0000000000..0bfc92344c --- /dev/null +++ b/freebuff/web/public/ph_flower-fill-2.svg @@ -0,0 +1,63 @@ +<svg width="237" height="236" viewBox="0 0 237 236" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5062)"> +<rect width="231.8" height="231.8" transform="translate(2.30005 2)" fill="#F1F1F1"/> +<g filter="url(#filter1_f_1287_5062)"> +<ellipse cx="118.5" cy="118" rx="71" ry="96" fill="url(#paint0_radial_1287_5062)"/> +</g> +<circle cx="118" cy="158.5" r="6.5" fill="url(#paint1_radial_1287_5062)"/> +<circle cx="118" cy="133.5" r="9.5" fill="url(#paint2_radial_1287_5062)"/> +<g filter="url(#filter2_f_1287_5062)"> +<circle cx="118.5" cy="74" r="50" fill="url(#paint3_radial_1287_5062)"/> +</g> +<circle cx="118" cy="113.5" r="10.5" fill="url(#paint4_radial_1287_5062)"/> +<circle cx="83.0002" cy="66.5" r="2.5" fill="white"/> +<circle cx="79.5002" cy="61" r="1" fill="white"/> +<circle cx="142" cy="41.5" r="1.5" fill="white"/> +<circle cx="113" cy="102.5" r="1.5" fill="white"/> +<circle cx="137" cy="46.5" r="1.5" fill="white"/> +<circle cx="129" cy="128.5" r="1.5" fill="white"/> +<circle cx="110" cy="112.5" r="1.5" fill="white"/> +<circle cx="150" cy="48.5" r="3.5" fill="white"/> +<circle cx="122" cy="133.5" r="3.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5062" x="0.300049" y="0" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<filter id="filter1_f_1287_5062" x="44.5002" y="19" width="148" height="198" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<filter id="filter2_f_1287_5062" x="51.5002" y="7" width="134" height="134" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<radialGradient id="paint0_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.5 88.5) rotate(90) scale(101.5 75.0677)"> +<stop offset="0.100962" stop-color="#F6F8FF"/> +<stop offset="0.322115" stop-color="#C6D6F3"/> +<stop offset="0.9375" stop-color="#81C7D6"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 158.5) rotate(90) scale(6.5)"> +<stop stop-color="#F9F4C0"/> +<stop offset="1" stop-color="#F9F4C0" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint2_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 133.5) rotate(90) scale(9.5)"> +<stop stop-color="#FCF3BA"/> +<stop offset="1" stop-color="#FCF3BA" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint3_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.5 61.5) rotate(90) scale(62.5)"> +<stop offset="0.158654" stop-color="#F6F4F6"/> +<stop offset="0.274038" stop-color="#FFDAF3"/> +<stop offset="0.533654" stop-color="#F8F3B8"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint4_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 113.5) rotate(90) scale(10.5)"> +<stop offset="0.206731" stop-color="#FDF5B8"/> +<stop offset="1" stop-color="#FDF5B8" stop-opacity="0"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/ph_flower-fill-3.svg b/freebuff/web/public/ph_flower-fill-3.svg new file mode 100644 index 0000000000..26fd0c9b79 --- /dev/null +++ b/freebuff/web/public/ph_flower-fill-3.svg @@ -0,0 +1,49 @@ +<svg width="234" height="234" viewBox="0 0 234 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5082)"> +<rect width="231.8" height="231.8" transform="translate(1.09998 1)" fill="#F1F1F1"/> +<circle cx="77.4999" cy="64" r="1" fill="white"/> +<g filter="url(#filter1_f_1287_5082)"> +<circle cx="87.9999" cy="116.5" r="55.5" fill="url(#paint0_radial_1287_5082)"/> +</g> +<g filter="url(#filter2_f_1287_5082)"> +<path d="M201.5 116.5C201.5 131.688 189.817 144 175.405 144C160.993 144 152.5 131.688 152.5 116.5C152.5 101.312 160.993 89 175.405 89C189.817 89 201.5 101.312 201.5 116.5Z" fill="url(#paint1_radial_1287_5082)"/> +</g> +<circle cx="129" cy="86.5" r="2.5" fill="white"/> +<circle cx="190" cy="103.5" r="2.5" fill="white"/> +<circle cx="43.9999" cy="141.5" r="2.5" fill="white"/> +<circle cx="120" cy="92.5" r="3.5" fill="white"/> +<circle cx="130" cy="97.5" r="1.5" fill="white"/> +<circle cx="123" cy="154.5" r="1.5" fill="white"/> +<circle cx="187" cy="110.5" r="1.5" fill="white"/> +<circle cx="44.9999" cy="129.5" r="1.5" fill="white"/> +<circle cx="51.9999" cy="132.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5082" x="0.0999756" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<filter id="filter1_f_1287_5082" x="29.4999" y="58" width="117" height="117" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<filter id="filter2_f_1287_5082" x="145.5" y="82" width="63" height="69" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="3.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<radialGradient id="paint0_radial_1287_5082" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(87.9999 116.5) rotate(89.3489) scale(44.0028)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5082" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(175.405 116.5) rotate(90) scale(27.5 26.0952)"> +<stop stop-color="#FBF5BB"/> +<stop offset="1" stop-color="#C9E1D5"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/ph_flower-fill-4.svg b/freebuff/web/public/ph_flower-fill-4.svg new file mode 100644 index 0000000000..133a6d91fb --- /dev/null +++ b/freebuff/web/public/ph_flower-fill-4.svg @@ -0,0 +1,69 @@ +<svg width="235" height="234" viewBox="0 0 235 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1287_5100)" filter="url(#filter0_f_1287_5100)"> +<rect width="231.8" height="231.8" transform="translate(1.90002 1)" fill="#F1F1F1"/> +<circle cx="63.5001" cy="64" r="1" fill="white"/> +<g filter="url(#filter1_df_1287_5100)"> +<circle cx="64.5001" cy="117" r="121" fill="url(#paint0_radial_1287_5100)"/> +</g> +<g filter="url(#filter2_df_1287_5100)"> +<ellipse cx="36.0001" cy="117" rx="62.5" ry="64" fill="url(#paint1_radial_1287_5100)"/> +</g> +<circle cx="151" cy="21.5" r="2.5" fill="white"/> +<circle cx="87.0001" cy="205.5" r="2.5" fill="white"/> +<circle cx="17.0001" cy="119.5" r="2.5" fill="white"/> +<circle cx="138" cy="27.5" r="3.5" fill="white"/> +<circle cx="155" cy="32.5" r="1.5" fill="white"/> +<circle cx="19.0001" cy="186.5" r="1.5" fill="white"/> +<circle cx="76.0001" cy="209.5" r="1.5" fill="white"/> +<circle cx="41.0001" cy="96.5" r="1.5" fill="white"/> +<circle cx="47.0001" cy="103.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5100" x="0.900024" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5100"/> +</filter> +<filter id="filter1_df_1287_5100" x="-156.5" y="-104" width="442" height="442" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="50"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.545098 0 0 0 0 0.796078 0 0 0 0 0.854902 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1287_5100"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1287_5100" result="shape"/> +<feGaussianBlur stdDeviation="5" result="effect2_foregroundBlur_1287_5100"/> +</filter> +<filter id="filter2_df_1287_5100" x="-126.5" y="-47" width="325" height="328" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="50"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.545098 0 0 0 0 0.796078 0 0 0 0 0.854902 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1287_5100"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1287_5100" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect2_foregroundBlur_1287_5100"/> +</filter> +<radialGradient id="paint0_radial_1287_5100" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(37.0001 117) scale(121)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.177885" stop-color="#F8E1F0"/> +<stop offset="0.259615" stop-color="#CED7F6"/> +<stop offset="0.413462" stop-color="#AFD3EC"/> +<stop offset="0.764423" stop-color="#8ACAD9"/> +<stop offset="1" stop-color="#F8EDD0"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5100" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(36.0001 117) rotate(89.3642) scale(50.7419 49.5529)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.177885" stop-color="#F8E1F0"/> +<stop offset="0.259615" stop-color="#CED7F6"/> +<stop offset="0.413462" stop-color="#AFD3EC"/> +<stop offset="0.764423" stop-color="#8ACAD9"/> +<stop offset="1" stop-color="#F8EDD0"/> +</radialGradient> +<clipPath id="clip0_1287_5100"> +<rect width="231.8" height="231.8" fill="white" transform="translate(1.90002 1)"/> +</clipPath> +</defs> +</svg> diff --git a/freebuff/web/public/ph_flower-fill-5.svg b/freebuff/web/public/ph_flower-fill-5.svg new file mode 100644 index 0000000000..89947097a2 --- /dev/null +++ b/freebuff/web/public/ph_flower-fill-5.svg @@ -0,0 +1,67 @@ +<svg width="235" height="234" viewBox="0 0 235 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5118)"> +<rect width="231.8" height="231.8" transform="translate(1.7002 1)" fill="#F1F1F1"/> +<circle cx="66.7002" cy="43" r="1" fill="white"/> +<g filter="url(#filter1_f_1287_5118)"> +<ellipse cx="117" cy="159.5" rx="55.5" ry="32.5" fill="url(#paint0_radial_1287_5118)"/> +</g> +<g filter="url(#filter2_f_1287_5118)"> +<ellipse cx="117" cy="118.5" rx="55.5" ry="32.5" fill="url(#paint1_radial_1287_5118)"/> +</g> +<g filter="url(#filter3_f_1287_5118)"> +<ellipse cx="117" cy="74.5" rx="55.5" ry="32.5" fill="url(#paint2_radial_1287_5118)"/> +</g> +<circle cx="118.2" cy="65.5" r="2.5" fill="white"/> +<circle cx="170" cy="157.5" r="2.5" fill="white"/> +<circle cx="74" cy="137.5" r="2.5" fill="white"/> +<circle cx="109.2" cy="71.5" r="3.5" fill="white"/> +<circle cx="119.2" cy="76.5" r="1.5" fill="white"/> +<circle cx="170" cy="168.5" r="1.5" fill="white"/> +<circle cx="162" cy="169.5" r="1.5" fill="white"/> +<circle cx="78" cy="132.5" r="1.5" fill="white"/> +<circle cx="69" cy="125.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5118" x="0.700195" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter1_f_1287_5118" x="58.5" y="124" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter2_f_1287_5118" x="58.5" y="83" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter3_f_1287_5118" x="58.5" y="39" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<radialGradient id="paint0_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 159.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 118.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint2_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 74.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/pointandclick.svg b/freebuff/web/public/pointandclick.svg new file mode 100644 index 0000000000..6b0b514ecf --- /dev/null +++ b/freebuff/web/public/pointandclick.svg @@ -0,0 +1,66 @@ +<svg width="237" height="237" viewBox="0 0 237 237" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1042_326)" filter="url(#filter0_f_1042_326)"> +<rect width="231.8" height="231.8" transform="translate(2.7998 2.2998)" fill="#F1F1F1"/> +<g filter="url(#filter1_f_1042_326)"> +<path d="M244 199.499C203.548 238.482 56.9735 237.078 10.3746 188.724C-36.2243 140.369 -41.2073 69.5681 -0.755226 30.5847C39.6968 -8.39867 218.5 24.4988 150 58.9995C196.599 107.354 284.452 160.515 244 199.499Z" fill="url(#paint0_radial_1042_326)"/> +</g> +<g filter="url(#filter2_f_1042_326)"> +<ellipse cx="83.9301" cy="99.9933" rx="53.5" ry="61" transform="rotate(44.6538 83.9301 99.9933)" fill="url(#paint1_radial_1042_326)"/> +</g> +<g filter="url(#filter3_f_1042_326)"> +<path d="M139.71 135.636L214.007 167.163L171.102 209.99L139.71 135.636Z" fill="url(#paint2_linear_1042_326)"/> +</g> +<circle cx="186.5" cy="163.5" r="2.5" fill="white"/> +<circle cx="201.5" cy="161.5" r="4.5" fill="white"/> +<circle cx="135.73" cy="142.73" r="1.72985" fill="white"/> +<circle cx="195.73" cy="172.73" r="1.72985" fill="white"/> +<circle cx="133.883" cy="130.883" r="2.88308" fill="white"/> +<circle cx="127.73" cy="135.73" r="1.72985" fill="white"/> +<circle cx="88.5" cy="29.5" r="2.5" fill="white"/> +<circle cx="101.5" cy="33.5" r="1.5" fill="white"/> +<circle cx="80.5" cy="33.5" r="1.5" fill="white"/> +<circle cx="55.5" cy="62.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1042_326" x="0.799805" y="0.299805" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter1_f_1042_326" x="-45.1138" y="-4.06104" width="316.476" height="248.083" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter2_f_1042_326" x="9.60205" y="25.5747" width="148.656" height="148.837" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter3_f_1042_326" x="135.71" y="131.636" width="82.2964" height="82.354" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="2" result="effect1_foregroundBlur_1042_326"/> +</filter> +<radialGradient id="paint0_radial_1042_326" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(101.931 100.491) rotate(136.059) scale(127.151 151.99)"> +<stop offset="0.158654" stop-color="#F6F4F6"/> +<stop offset="0.201923" stop-color="#81C7D6"/> +<stop offset="0.533654" stop-color="#A6CCE3"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint1_radial_1042_326" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(83.9301 84.7433) rotate(90) scale(76.25 66.875)"> +<stop offset="0.0865385" stop-color="#F5E4F2"/> +<stop offset="0.298077" stop-color="#C9DAF3"/> +<stop offset="0.533654" stop-color="#F8F3B8"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<linearGradient id="paint2_linear_1042_326" x1="116.243" y1="104.702" x2="207.504" y2="208.565" gradientUnits="userSpaceOnUse"> +<stop offset="0.197115" stop-color="#F3F2BF"/> +<stop offset="0.538462" stop-color="#DCE9CB"/> +<stop offset="1" stop-color="#97CADE" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_1042_326"> +<rect width="231.8" height="231.8" fill="white" transform="translate(2.7998 2.2998)"/> +</clipPath> +</defs> +</svg> diff --git a/freebuff/web/public/productionready.svg b/freebuff/web/public/productionready.svg new file mode 100644 index 0000000000..46da1a7ac3 --- /dev/null +++ b/freebuff/web/public/productionready.svg @@ -0,0 +1,3 @@ +<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2.3335 2.37467C2.3335 2.16471 2.4169 1.96335 2.56537 1.81488C2.71384 1.66642 2.9152 1.58301 3.12516 1.58301H17.3752C17.5851 1.58301 17.7865 1.66642 17.935 1.81488C18.0834 1.96335 18.1668 2.16471 18.1668 2.37467C18.1668 2.58464 18.0834 2.786 17.935 2.93447C17.7865 3.08293 17.5851 3.16634 17.3752 3.16634V11.8747C17.3752 12.0846 17.2918 12.286 17.1433 12.4345C16.9948 12.5829 16.7935 12.6663 16.5835 12.6663H12.9315L14.1681 16.3745C14.2345 16.5738 14.2189 16.7912 14.125 16.979C14.031 17.1668 13.8663 17.3096 13.667 17.376C13.4677 17.4423 13.2503 17.4268 13.0625 17.3328C12.8747 17.2388 12.7319 17.0741 12.6655 16.8748L11.2635 12.6663H9.23841L7.83558 16.8748C7.76923 17.0741 7.62645 17.2388 7.43864 17.3328C7.25083 17.4268 7.03338 17.4423 6.83412 17.376C6.63487 17.3096 6.47013 17.1668 6.37615 16.979C6.28217 16.7912 6.26665 16.5738 6.333 16.3745L7.56879 12.6663H3.91683C3.70687 12.6663 3.5055 12.5829 3.35704 12.4345C3.20857 12.286 3.12516 12.0846 3.12516 11.8747V3.16634C2.9152 3.16634 2.71384 3.08293 2.56537 2.93447C2.4169 2.786 2.3335 2.58464 2.3335 2.37467ZM9.89787 4.88267C9.77865 4.80313 9.64006 4.75744 9.4969 4.75049C9.35375 4.74353 9.21139 4.77557 9.08501 4.84319C8.95864 4.91081 8.85299 5.01146 8.77935 5.13442C8.7057 5.25737 8.66681 5.39802 8.66683 5.54134V8.70801C8.66681 8.85133 8.7057 8.99198 8.77935 9.11493C8.85299 9.23789 8.95864 9.33854 9.08501 9.40616C9.21139 9.47377 9.35375 9.50581 9.4969 9.49886C9.64006 9.49191 9.77865 9.44622 9.89787 9.36667L12.2729 7.78334C12.3813 7.71104 12.4702 7.6131 12.5317 7.4982C12.5932 7.3833 12.6253 7.25499 12.6253 7.12467C12.6253 6.99436 12.5932 6.86605 12.5317 6.75115C12.4702 6.63625 12.3813 6.53831 12.2729 6.46601L9.89787 4.88267ZM10.4061 7.12467L10.2502 7.22838V7.02176L10.4061 7.12467Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/reviews/base44_review.png b/freebuff/web/public/reviews/base44_review.png new file mode 100644 index 0000000000..cfb1bbb18b Binary files /dev/null and b/freebuff/web/public/reviews/base44_review.png differ diff --git a/freebuff/web/public/reviews/bolt_review.png b/freebuff/web/public/reviews/bolt_review.png new file mode 100644 index 0000000000..631858d048 Binary files /dev/null and b/freebuff/web/public/reviews/bolt_review.png differ diff --git a/freebuff/web/public/reviews/lovable_review.png b/freebuff/web/public/reviews/lovable_review.png new file mode 100644 index 0000000000..beb2afe418 Binary files /dev/null and b/freebuff/web/public/reviews/lovable_review.png differ diff --git a/freebuff/web/public/reviews/replit_review.png b/freebuff/web/public/reviews/replit_review.png new file mode 100644 index 0000000000..456d347333 Binary files /dev/null and b/freebuff/web/public/reviews/replit_review.png differ diff --git a/freebuff/web/public/reviews/vly_review_1.png b/freebuff/web/public/reviews/vly_review_1.png new file mode 100644 index 0000000000..156f385076 Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_1.png differ diff --git a/freebuff/web/public/reviews/vly_review_2.png b/freebuff/web/public/reviews/vly_review_2.png new file mode 100644 index 0000000000..59fe0b1620 Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_2.png differ diff --git a/freebuff/web/public/reviews/vly_review_3.png b/freebuff/web/public/reviews/vly_review_3.png new file mode 100644 index 0000000000..0e5b76ff98 Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_3.png differ diff --git a/freebuff/web/public/reviews/vly_review_4.png b/freebuff/web/public/reviews/vly_review_4.png new file mode 100644 index 0000000000..bbf9b1366f Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_4.png differ diff --git a/freebuff/web/public/reviews/vly_review_5.png b/freebuff/web/public/reviews/vly_review_5.png new file mode 100644 index 0000000000..942cada81a Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_5.png differ diff --git a/freebuff/web/public/reviews/vly_review_6.png b/freebuff/web/public/reviews/vly_review_6.png new file mode 100644 index 0000000000..07c6b19219 Binary files /dev/null and b/freebuff/web/public/reviews/vly_review_6.png differ diff --git a/freebuff/web/public/reviews/vly_tesitmonial_13.png b/freebuff/web/public/reviews/vly_tesitmonial_13.png new file mode 100644 index 0000000000..97bbf26e95 Binary files /dev/null and b/freebuff/web/public/reviews/vly_tesitmonial_13.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial.png b/freebuff/web/public/reviews/vly_testimonial.png new file mode 100644 index 0000000000..5a2435d54d Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_10.png b/freebuff/web/public/reviews/vly_testimonial_10.png new file mode 100644 index 0000000000..fc53f8cd1f Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_10.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_11.png b/freebuff/web/public/reviews/vly_testimonial_11.png new file mode 100644 index 0000000000..f5a5ba977a Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_11.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_12.png b/freebuff/web/public/reviews/vly_testimonial_12.png new file mode 100644 index 0000000000..8c3a838070 Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_12.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_14.png b/freebuff/web/public/reviews/vly_testimonial_14.png new file mode 100644 index 0000000000..21d6a232a2 Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_14.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_2.png b/freebuff/web/public/reviews/vly_testimonial_2.png new file mode 100644 index 0000000000..966ae3662d Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_2.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_3.png b/freebuff/web/public/reviews/vly_testimonial_3.png new file mode 100644 index 0000000000..775ffbf523 Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_3.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_4.png b/freebuff/web/public/reviews/vly_testimonial_4.png new file mode 100644 index 0000000000..ea4cadbb4b Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_4.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_5.png b/freebuff/web/public/reviews/vly_testimonial_5.png new file mode 100644 index 0000000000..fd05111dce Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_5.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_6.png b/freebuff/web/public/reviews/vly_testimonial_6.png new file mode 100644 index 0000000000..c1a74668bd Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_6.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_7.png b/freebuff/web/public/reviews/vly_testimonial_7.png new file mode 100644 index 0000000000..63e04c8ccf Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_7.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_8.png b/freebuff/web/public/reviews/vly_testimonial_8.png new file mode 100644 index 0000000000..6cffcd3905 Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_8.png differ diff --git a/freebuff/web/public/reviews/vly_testimonial_9.png b/freebuff/web/public/reviews/vly_testimonial_9.png new file mode 100644 index 0000000000..aff74a2dd4 Binary files /dev/null and b/freebuff/web/public/reviews/vly_testimonial_9.png differ diff --git a/freebuff/web/public/site.webmanifest b/freebuff/web/public/site.webmanifest new file mode 100644 index 0000000000..c859750c70 --- /dev/null +++ b/freebuff/web/public/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "VLY AI", + "short_name": "vly.ai", + "description": "Build production-ready web apps with AI.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/manifest/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/manifest/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/freebuff/web/public/trustpilot_emergent.png b/freebuff/web/public/trustpilot_emergent.png new file mode 100644 index 0000000000..56d3d1c60c Binary files /dev/null and b/freebuff/web/public/trustpilot_emergent.png differ diff --git a/freebuff/web/public/trustpilot_vly.png b/freebuff/web/public/trustpilot_vly.png new file mode 100644 index 0000000000..12f5b16a3e Binary files /dev/null and b/freebuff/web/public/trustpilot_vly.png differ diff --git a/freebuff/web/public/vc_logo/0ad.png b/freebuff/web/public/vc_logo/0ad.png new file mode 100644 index 0000000000..6d39cefa04 Binary files /dev/null and b/freebuff/web/public/vc_logo/0ad.png differ diff --git a/freebuff/web/public/vc_logo/468.png b/freebuff/web/public/vc_logo/468.png new file mode 100644 index 0000000000..eef0b77e7b Binary files /dev/null and b/freebuff/web/public/vc_logo/468.png differ diff --git a/freebuff/web/public/vc_logo/bluebrown.png b/freebuff/web/public/vc_logo/bluebrown.png new file mode 100644 index 0000000000..1836f00b0e Binary files /dev/null and b/freebuff/web/public/vc_logo/bluebrown.png differ diff --git a/freebuff/web/public/vc_logo/goodwater.png b/freebuff/web/public/vc_logo/goodwater.png new file mode 100644 index 0000000000..79b09daf90 Binary files /dev/null and b/freebuff/web/public/vc_logo/goodwater.png differ diff --git a/freebuff/web/public/vc_logo/olive.png b/freebuff/web/public/vc_logo/olive.png new file mode 100644 index 0000000000..582944053e Binary files /dev/null and b/freebuff/web/public/vc_logo/olive.png differ diff --git a/freebuff/web/public/vc_logo/treeo.png b/freebuff/web/public/vc_logo/treeo.png new file mode 100644 index 0000000000..8a7f2e9110 Binary files /dev/null and b/freebuff/web/public/vc_logo/treeo.png differ diff --git a/freebuff/web/public/vly/HackTech_Caltech.png b/freebuff/web/public/vly/HackTech_Caltech.png new file mode 100644 index 0000000000..994e9e32ad Binary files /dev/null and b/freebuff/web/public/vly/HackTech_Caltech.png differ diff --git a/freebuff/web/public/vly/Maya_Alexander.jpeg b/freebuff/web/public/vly/Maya_Alexander.jpeg new file mode 100644 index 0000000000..a8905476fb Binary files /dev/null and b/freebuff/web/public/vly/Maya_Alexander.jpeg differ diff --git a/freebuff/web/public/vly/PerfectlyNineties-Italic.woff2 b/freebuff/web/public/vly/PerfectlyNineties-Italic.woff2 new file mode 100644 index 0000000000..5f1c0b097d Binary files /dev/null and b/freebuff/web/public/vly/PerfectlyNineties-Italic.woff2 differ diff --git a/freebuff/web/public/vly/PerfectlyNineties-Regular.woff2 b/freebuff/web/public/vly/PerfectlyNineties-Regular.woff2 new file mode 100644 index 0000000000..bbbbab74d5 Binary files /dev/null and b/freebuff/web/public/vly/PerfectlyNineties-Regular.woff2 differ diff --git a/freebuff/web/public/vly/Pivot_Robotics.png b/freebuff/web/public/vly/Pivot_Robotics.png new file mode 100644 index 0000000000..3346dab182 Binary files /dev/null and b/freebuff/web/public/vly/Pivot_Robotics.png differ diff --git a/freebuff/web/public/vly/Placeholder.svg b/freebuff/web/public/vly/Placeholder.svg new file mode 100644 index 0000000000..e141dfbd8b --- /dev/null +++ b/freebuff/web/public/vly/Placeholder.svg @@ -0,0 +1,8 @@ +<svg aria-hidden="true" role="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"> + <svg> + <path fill="transparent" d="M0 0h128v128H0z"/> + <path d="M88.41 84.67a32 32 0 10-48.82 0 66.13 66.13 0 0148.82 0z" fill="#788fa5"/> + <path d="M88.41 84.67a32 32 0 01-48.82 0A66.79 66.79 0 000 128h128a66.79 66.79 0 00-39.59-43.33z" fill="#9db3c8"/> + <path d="M64 96a31.93 31.93 0 0024.41-11.33 66.13 66.13 0 00-48.82 0A31.93 31.93 0 0064 96z" fill="#56687a"/> + </svg> +</svg> \ No newline at end of file diff --git a/freebuff/web/public/vly/Siddarth_Girdhar.jpeg b/freebuff/web/public/vly/Siddarth_Girdhar.jpeg new file mode 100644 index 0000000000..c38d3ee888 Binary files /dev/null and b/freebuff/web/public/vly/Siddarth_Girdhar.jpeg differ diff --git a/freebuff/web/public/vly/YC_icon.png b/freebuff/web/public/vly/YC_icon.png new file mode 100644 index 0000000000..a6c3a46941 Binary files /dev/null and b/freebuff/web/public/vly/YC_icon.png differ diff --git a/freebuff/web/public/vly/YCombinator.png b/freebuff/web/public/vly/YCombinator.png new file mode 100644 index 0000000000..24dff52bf5 Binary files /dev/null and b/freebuff/web/public/vly/YCombinator.png differ diff --git a/freebuff/web/public/vly/businesslogic.svg b/freebuff/web/public/vly/businesslogic.svg new file mode 100644 index 0000000000..1701c1bd28 --- /dev/null +++ b/freebuff/web/public/vly/businesslogic.svg @@ -0,0 +1,3 @@ +<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M6.37932 2.61037C7.5436 1.86894 9.11932 1.42822 10.8336 1.42822C12.5479 1.42822 14.1236 1.86894 15.2879 2.61037C16.4465 3.34751 17.2622 4.43179 17.2622 5.71394C17.2622 6.99608 16.4465 8.08037 15.2879 8.81751C14.1236 9.55894 12.5479 9.99965 10.8336 9.99965C9.11932 9.99965 7.5436 9.55894 6.37932 8.81751C5.22074 8.08037 4.40503 6.99608 4.40503 5.71394C4.40503 4.43179 5.22074 3.34751 6.37932 2.61037ZM16.8393 8.45394C16.493 8.82473 16.1005 9.14952 15.6715 9.42037C14.3765 10.2447 12.6622 10.7139 10.8336 10.7139C9.00503 10.7139 7.29146 10.2447 5.99574 9.42037C5.56624 9.15017 5.17367 8.82531 4.82789 8.45394C4.55264 8.92268 4.40672 9.45607 4.40503 9.99965C4.40503 11.2825 5.22074 12.3668 6.37932 13.1039C7.5436 13.8447 9.11932 14.2854 10.8336 14.2854C12.5479 14.2854 14.1236 13.8447 15.2879 13.1039C16.4465 12.3668 17.2622 11.2825 17.2622 9.99965C17.2622 9.44322 17.1086 8.92394 16.8393 8.45394ZM16.8393 12.7397C16.493 13.1104 16.1005 13.4352 15.6715 13.7061C14.3765 14.5304 12.6622 14.9997 10.8336 14.9997C9.00503 14.9997 7.29146 14.5304 5.99574 13.7061C5.56671 13.4352 5.17421 13.1104 4.82789 12.7397C4.55264 13.2084 4.40672 13.7418 4.40503 14.2854C4.40503 15.5675 5.22074 16.6518 6.37932 17.3889C7.5436 18.1304 9.11932 18.5711 10.8336 18.5711C12.5479 18.5711 14.1236 18.1304 15.2879 17.3889C16.4465 16.6518 17.2622 15.5675 17.2622 14.2854C17.2622 13.7282 17.1086 13.2097 16.8393 12.7397Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/vly/clouds.png b/freebuff/web/public/vly/clouds.png new file mode 100644 index 0000000000..72ba150997 Binary files /dev/null and b/freebuff/web/public/vly/clouds.png differ diff --git a/freebuff/web/public/vly/codex-device-auth-security-guide.svg b/freebuff/web/public/vly/codex-device-auth-security-guide.svg new file mode 100644 index 0000000000..22b7a5801b --- /dev/null +++ b/freebuff/web/public/vly/codex-device-auth-security-guide.svg @@ -0,0 +1,22 @@ +<svg width="1320" height="680" viewBox="0 0 1320 680" fill="none" xmlns="http://www.w3.org/2000/svg"> + <rect width="1320" height="680" rx="18" fill="#0C0F16"/> + <rect x="34" y="34" width="300" height="612" rx="16" fill="#171A22"/> + <rect x="52" y="120" width="264" height="66" rx="14" fill="#30343E"/> + <text x="106" y="160" font-size="42" fill="#F7F8FA" font-family="Arial, sans-serif">Security</text> + <text x="88" y="74" font-size="36" fill="#EDEEF2" font-family="Arial, sans-serif">Settings</text> + <circle cx="72" cy="151" r="20" stroke="#F7F8FA" stroke-width="6"/> + <circle cx="66" cy="146" r="5" fill="#F7F8FA"/> + <circle cx="79" cy="145" r="5" fill="#F7F8FA"/> + <rect x="368" y="34" width="918" height="612" rx="16" fill="#151922"/> + <text x="404" y="98" font-size="56" fill="#F7F8FA" font-family="Arial, sans-serif">Secure sign in with ChatGPT</text> + <text x="404" y="146" font-size="36" fill="#A8AFBB" font-family="Arial, sans-serif">ChatGPT Security</text> + <rect x="404" y="206" width="848" height="402" rx="12" fill="#1A1F2A" stroke="#FF3A2E" stroke-width="14"/> + <text x="450" y="286" font-size="50" fill="#F4F6FA" font-family="Arial, sans-serif">Codex CLI</text> + <text x="450" y="336" font-size="36" fill="#C8CFDA" font-family="Arial, sans-serif">Allow Codex CLI to use models from the API.</text> + <text x="450" y="430" font-size="52" fill="#F7F8FA" font-family="Arial, sans-serif">Enable device code authorization for Codex</text> + <text x="450" y="488" font-size="36" fill="#A8AFBB" font-family="Arial, sans-serif">Turn this toggle on before starting device login.</text> + <rect x="1144" y="395" width="92" height="56" rx="28" fill="#1D9BFF"/> + <circle cx="1208" cy="423" r="24" fill="#F7F8FA"/> + <rect x="1060" y="232" width="176" height="66" rx="33" fill="#2A0D0D" stroke="#FF3A2E" stroke-width="4"/> + <text x="1086" y="276" font-size="38" fill="#FF5247" font-family="Arial, sans-serif">Disconnect</text> +</svg> diff --git a/freebuff/web/public/vly/codicon_sparkle-filled.svg b/freebuff/web/public/vly/codicon_sparkle-filled.svg new file mode 100644 index 0000000000..113a335a69 --- /dev/null +++ b/freebuff/web/public/vly/codicon_sparkle-filled.svg @@ -0,0 +1,5 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M16.468 24.1151C16.7072 24.3195 16.9929 24.4288 17.2856 24.4279C17.5784 24.4271 17.8636 24.316 18.1019 24.1101C18.3435 23.8965 18.5267 23.6008 18.6271 23.2591L19.2337 21.0033C19.3898 20.4381 19.6517 19.9239 19.9991 19.5016C20.3474 19.0806 20.7715 18.7629 21.2381 18.5734L23.1258 17.8324C23.3317 17.7426 23.5178 17.5966 23.6696 17.4057C23.8214 17.2148 23.9348 16.9842 24.0011 16.7316C24.0577 16.516 24.0783 16.289 24.0618 16.0636C24.0453 15.8383 23.9921 15.6192 23.9051 15.4189C23.8181 15.2187 23.6992 15.0414 23.5551 14.8973C23.4111 14.7532 23.2449 14.6451 23.0661 14.5794L21.2001 13.845C20.7328 13.6567 20.3081 13.3395 19.9596 12.9183C19.611 12.4972 19.3481 11.9837 19.1917 11.4183L18.5783 9.13792C18.4982 8.86619 18.3628 8.62332 18.1845 8.43184C18.0063 8.24036 17.791 8.10647 17.5586 8.04256C17.3262 7.97866 17.0843 7.98682 16.8552 8.06629C16.6261 8.14576 16.4174 8.29397 16.2482 8.49717C16.0912 8.68453 15.9723 8.91342 15.9008 9.16585L15.2806 11.466C15.1265 12.017 14.8703 12.5181 14.5316 12.9307C14.1929 13.3434 13.7806 13.6567 13.3264 13.8466L11.4388 14.5827C11.2336 14.6737 11.0483 14.8202 10.8968 15.0109C10.7454 15.2016 10.6319 15.4316 10.5648 15.6834C10.4996 15.9361 10.4833 16.2036 10.5172 16.4651C10.5511 16.7267 10.6343 16.9753 10.7602 17.1917C10.9339 17.4874 11.1796 17.7092 11.4618 17.8258L13.3264 18.557C13.7961 18.7456 14.2226 19.0651 14.5717 19.4897C14.9207 19.9143 15.1826 20.4322 15.3362 21.0017L15.9496 23.287C16.0473 23.6222 16.2292 23.9113 16.468 24.1151ZM24.7963 30.7559C24.9796 30.9141 25.1991 30.9994 25.4242 31C25.6494 31.0006 25.8691 30.9164 26.053 30.7591C26.2432 30.5984 26.3859 30.3681 26.4601 30.102L26.7966 28.85C26.8678 28.5896 26.9883 28.3528 27.1485 28.1589C27.3088 27.9649 27.5043 27.819 27.7194 27.7328L28.7671 27.3188C28.9561 27.242 29.1236 27.1029 29.2509 26.9171C29.3782 26.7314 29.4602 26.5064 29.4877 26.2673C29.5267 25.957 29.4723 25.64 29.3342 25.3743C29.1962 25.1085 28.9838 24.9117 28.7359 24.8199L27.6991 24.4108C27.4847 24.3239 27.2899 24.1781 27.1298 23.9848C26.9697 23.7916 26.8487 23.5561 26.7763 23.2969L26.4343 22.0269C26.3601 21.7684 26.2211 21.5444 26.0367 21.3861C25.8281 21.2042 25.5727 21.1184 25.3165 21.144C25.0603 21.1696 24.8201 21.3049 24.6389 21.5258C24.5185 21.6708 24.4278 21.8481 24.3743 22.0433L24.0391 23.2952C23.9698 23.5528 23.8523 23.7875 23.6958 23.981C23.5392 24.1745 23.3479 24.3216 23.1366 24.4108L22.0876 24.8248C21.8718 24.9134 21.6845 25.0821 21.5522 25.3067C21.42 25.5314 21.3495 25.8008 21.3507 26.0767C21.352 26.3526 21.4248 26.621 21.559 26.8439C21.6932 27.0668 21.882 27.233 22.0985 27.3188L23.1339 27.7246C23.3497 27.8133 23.5465 27.9596 23.7066 28.1534C23.8681 28.3489 23.9875 28.5855 24.0567 28.8468L24.4001 30.1184C24.4744 30.3755 24.6129 30.5982 24.7963 30.7559Z" fill="#FEFEFE"/> +</svg> diff --git a/freebuff/web/public/vly/competitors/base44.png b/freebuff/web/public/vly/competitors/base44.png new file mode 100644 index 0000000000..3b0c7bee87 Binary files /dev/null and b/freebuff/web/public/vly/competitors/base44.png differ diff --git a/freebuff/web/public/vly/competitors/base_reviews.png b/freebuff/web/public/vly/competitors/base_reviews.png new file mode 100644 index 0000000000..abdc0e7dd2 Binary files /dev/null and b/freebuff/web/public/vly/competitors/base_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/bolt_review_main.png b/freebuff/web/public/vly/competitors/bolt_review_main.png new file mode 100644 index 0000000000..9313f272d9 Binary files /dev/null and b/freebuff/web/public/vly/competitors/bolt_review_main.png differ diff --git a/freebuff/web/public/vly/competitors/bolt_reviews.png b/freebuff/web/public/vly/competitors/bolt_reviews.png new file mode 100644 index 0000000000..d3949ae733 Binary files /dev/null and b/freebuff/web/public/vly/competitors/bolt_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/boltnew.png b/freebuff/web/public/vly/competitors/boltnew.png new file mode 100644 index 0000000000..413f6487fa Binary files /dev/null and b/freebuff/web/public/vly/competitors/boltnew.png differ diff --git a/freebuff/web/public/vly/competitors/emergent.png b/freebuff/web/public/vly/competitors/emergent.png new file mode 100644 index 0000000000..3961c97c9b Binary files /dev/null and b/freebuff/web/public/vly/competitors/emergent.png differ diff --git a/freebuff/web/public/vly/competitors/emergent_reviews.png b/freebuff/web/public/vly/competitors/emergent_reviews.png new file mode 100644 index 0000000000..66305fe10e Binary files /dev/null and b/freebuff/web/public/vly/competitors/emergent_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/lovable_logo.svg b/freebuff/web/public/vly/competitors/lovable_logo.svg new file mode 100644 index 0000000000..c4fe475a5f --- /dev/null +++ b/freebuff/web/public/vly/competitors/lovable_logo.svg @@ -0,0 +1,53 @@ +<svg width="911" height="155" viewBox="0 0 911 155" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M857.05 154.384C845.795 154.384 836.016 152.238 827.715 147.947C819.414 143.585 812.942 137.043 808.299 128.32C803.727 119.597 801.44 108.798 801.44 95.9247C801.44 83.6841 803.832 73.1319 808.616 64.2681C813.4 55.3339 820.012 48.5453 828.454 43.9024C836.896 39.2594 846.498 36.9379 857.262 36.9379C867.603 36.9379 876.783 39.1891 884.803 43.6913C892.822 48.1936 899.048 54.7359 903.48 63.3184C907.982 71.9008 910.233 82.1716 910.233 94.1308C910.233 98.422 910.198 101.834 910.128 104.366H826.027V82.2068H889.762L877.733 86.3222C877.733 80.5536 876.889 75.7348 875.2 71.8657C873.582 67.9262 871.19 64.9716 868.025 63.0018C864.859 61.0321 861.025 60.0472 856.523 60.0472C851.81 60.0472 847.659 61.208 844.071 63.5294C840.554 65.7806 837.81 69.1573 835.841 73.6595C833.941 78.1618 832.991 83.5786 832.991 89.9099V100.779C832.991 107.251 833.976 112.738 835.946 117.24C837.916 121.742 840.73 125.154 844.388 127.476C848.046 129.727 852.372 130.852 857.367 130.852C862.854 130.852 867.392 129.445 870.979 126.632C874.567 123.747 876.818 119.702 877.733 114.496H909.706C908.721 122.657 905.942 129.727 901.37 135.706C896.867 141.686 890.853 146.294 883.325 149.53C875.798 152.766 867.04 154.384 857.05 154.384Z" fill="black"/> +<path d="M759.904 0.00286865H791.877V151.321H759.904V0.00286865Z" fill="black"/> +<path d="M705.496 154.381C699.446 154.381 694.029 153.326 689.246 151.216C684.532 149.105 680.523 145.94 677.216 141.719C673.91 137.428 671.448 132.151 669.83 125.89L673.206 126.84V151.321H641.55V39.9957H673.523V64.8988L669.935 65.532C671.553 59.5524 674.015 54.4522 677.322 50.2313C680.698 45.9401 684.814 42.6689 689.668 40.4177C694.522 38.0963 699.974 36.9355 706.024 36.9355C715.099 36.9355 722.977 39.2922 729.661 44.0055C736.344 48.7188 741.479 55.5074 745.067 64.3712C748.654 73.1647 750.448 83.6114 750.448 95.7112C750.448 107.741 748.619 118.187 744.961 127.051C741.303 135.845 736.062 142.598 729.238 147.311C722.485 152.025 714.571 154.381 705.496 154.381ZM695.577 130.006C700.361 130.006 704.37 128.599 707.606 125.785C710.913 122.971 713.375 118.996 714.993 113.861C716.681 108.726 717.526 102.676 717.526 95.7112C717.526 88.7468 716.681 82.6968 714.993 77.5614C713.375 72.426 710.913 68.4514 707.606 65.6375C704.37 62.7532 700.361 61.3111 695.577 61.3111C690.864 61.3111 686.819 62.7532 683.442 65.6375C680.136 68.4514 677.638 72.4612 675.95 77.667C674.262 82.8024 673.417 88.8171 673.417 95.7112C673.417 102.676 674.262 108.726 675.95 113.861C677.638 118.996 680.136 122.971 683.442 125.785C686.819 128.599 690.864 130.006 695.577 130.006ZM641.55 0.00286865H673.523V39.9957H641.55V0.00286865Z" fill="black"/> +<path d="M561.296 154.384C554.331 154.384 548.07 153.012 542.513 150.268C537.026 147.525 532.699 143.621 529.534 138.555C526.438 133.42 524.891 127.441 524.891 120.617C524.891 110.205 527.951 102.256 534.071 96.7688C540.191 91.2113 549.02 87.5884 560.557 85.9001L579.868 83.1565C583.737 82.5937 586.797 81.8902 589.048 81.0461C591.299 80.2019 592.952 79.0763 594.007 77.6694C595.063 76.1921 595.59 74.3278 595.59 72.0767C595.59 69.7552 594.957 67.6448 593.691 65.7454C592.495 63.7757 590.666 62.228 588.204 61.1024C585.812 59.9065 582.892 59.3086 579.445 59.3086C573.958 59.3086 569.562 60.7507 566.255 63.635C562.949 66.4489 561.155 70.318 560.874 75.2424H527.845C528.127 67.7855 530.378 61.1728 534.599 55.4043C538.89 49.5654 544.834 45.0279 552.432 41.7919C560.029 38.5559 568.823 36.9379 578.812 36.9379C589.294 36.9379 598.158 38.6615 605.404 42.1085C612.65 45.4852 618.102 50.3392 621.76 56.6705C625.488 63.0018 627.352 70.5642 627.352 79.3577V125.787C627.352 130.782 627.704 135.425 628.408 139.716C629.181 143.937 630.272 146.61 631.679 147.736V151.324H598.439C597.666 148.299 597.068 144.887 596.646 141.088C596.223 137.289 595.977 133.279 595.907 129.059L601.077 126.843C599.741 131.837 597.279 136.445 593.691 140.666C590.173 144.816 585.636 148.158 580.079 150.69C574.591 153.153 568.33 154.384 561.296 154.384ZM573.114 130.958C577.616 130.958 581.591 129.973 585.038 128.003C588.485 125.963 591.123 123.149 592.952 119.562C594.852 115.974 595.801 111.894 595.801 107.321V92.759L598.439 94.2363C596.751 96.4874 594.641 98.211 592.108 99.4069C589.646 100.603 586.304 101.623 582.083 102.467L573.958 104.05C568.542 105.105 564.461 106.723 561.718 108.904C559.045 111.085 557.708 114.145 557.708 118.084C557.708 122.024 559.15 125.154 562.034 127.476C564.919 129.797 568.612 130.958 573.114 130.958Z" fill="black"/> +<path d="M416.143 39.9976H450.121L479.35 135.495H469.115L497.183 39.9976H530.212L491.696 151.323H456.135L416.143 39.9976Z" fill="black"/> +<path d="M366.162 154.384C355.117 154.384 345.409 151.992 337.038 147.208C328.736 142.425 322.3 135.601 317.727 126.737C313.225 117.873 310.974 107.497 310.974 95.6081C310.974 83.7193 313.225 73.3781 317.727 64.5847C322.3 55.7208 328.736 48.8971 337.038 44.1134C345.409 39.3298 355.117 36.9379 366.162 36.9379C377.206 36.9379 386.879 39.3298 395.18 44.1134C403.481 48.8971 409.883 55.7208 414.385 64.5847C418.958 73.3781 421.244 83.7193 421.244 95.6081C421.244 107.497 418.958 117.873 414.385 126.737C409.883 135.601 403.481 142.425 395.18 147.208C386.879 151.992 377.206 154.384 366.162 154.384ZM366.162 129.903C370.805 129.903 374.779 128.636 378.086 126.104C381.392 123.501 383.924 119.667 385.683 114.602C387.442 109.467 388.321 103.135 388.321 95.6081C388.321 84.4228 386.387 75.9458 382.517 70.1773C378.648 64.3384 373.196 61.419 366.162 61.419C361.519 61.419 357.509 62.7204 354.132 65.3233C350.826 67.8558 348.293 71.6898 346.535 76.8252C344.776 81.8902 343.896 88.1512 343.896 95.6081C343.896 103.065 344.776 109.361 346.535 114.496C348.293 119.632 350.826 123.501 354.132 126.104C357.509 128.636 361.519 129.903 366.162 129.903Z" fill="black"/> +<path d="M217.513 0.00192261H250.647V127.741L244.737 120.882C244.737 120.882 261.148 120.882 289.379 120.882C317.609 120.882 313.749 151.32 313.749 151.32H217.513V0.00192261Z" fill="black"/> +<mask id="mask0_313_127" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="149" height="152"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M44.6502 0C69.3099 0 89.3004 20.0433 89.3004 44.7679V61.7825H104.16C128.82 61.7825 148.81 81.8258 148.81 106.55C148.81 131.275 128.82 151.318 104.16 151.318H0V44.7679C0 20.0433 19.9906 0 44.6502 0Z" fill="url(#paint0_linear_313_127)"/> +</mask> +<g mask="url(#mask0_313_127)"> +<g filter="url(#filter0_f_313_127)"> +<circle cx="65.2812" cy="80.7667" r="100.733" fill="#4B73FF"/> +</g> +<g filter="url(#filter1_f_313_127)"> +<ellipse cx="76.3487" cy="25.4884" rx="129.012" ry="100.733" fill="#FF66F4"/> +</g> +<g filter="url(#filter2_f_313_127)"> +<ellipse cx="97.3926" cy="6.53951" rx="100.733" ry="88.4654" fill="#FF0105"/> +</g> +<g filter="url(#filter3_f_313_127)"> +<circle cx="78.1432" cy="25.4679" r="60.5807" fill="#FE7B02"/> +</g> +</g> +<defs> +<filter id="filter0_f_313_127" x="-80.5968" y="-65.1113" width="291.756" height="291.756" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter1_f_313_127" x="-97.8078" y="-120.39" width="348.313" height="291.756" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter2_f_313_127" x="-48.4855" y="-127.071" width="291.756" height="267.22" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<filter id="filter3_f_313_127" x="-27.5822" y="-80.2574" width="211.451" height="211.451" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="22.5723" result="effect1_foregroundBlur_313_127"/> +</filter> +<linearGradient id="paint0_linear_313_127" x1="50.0774" y1="26.5915" x2="95.4144" y2="151.261" gradientUnits="userSpaceOnUse"> +<stop offset="0.025" stop-color="#FF8E63"/> +<stop offset="0.56" stop-color="#FF7EB0"/> +<stop offset="0.95" stop-color="#4B73FF"/> +</linearGradient> +</defs> +</svg> diff --git a/freebuff/web/public/vly/competitors/lovable_star_reviews.png b/freebuff/web/public/vly/competitors/lovable_star_reviews.png new file mode 100644 index 0000000000..d8f227fdb2 Binary files /dev/null and b/freebuff/web/public/vly/competitors/lovable_star_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/replit.png b/freebuff/web/public/vly/competitors/replit.png new file mode 100644 index 0000000000..546a7412cc Binary files /dev/null and b/freebuff/web/public/vly/competitors/replit.png differ diff --git a/freebuff/web/public/vly/competitors/replit_reviews.png b/freebuff/web/public/vly/competitors/replit_reviews.png new file mode 100644 index 0000000000..951c3ddbd1 Binary files /dev/null and b/freebuff/web/public/vly/competitors/replit_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/sponsor_boltnew.png b/freebuff/web/public/vly/competitors/sponsor_boltnew.png new file mode 100644 index 0000000000..b3caeb3381 Binary files /dev/null and b/freebuff/web/public/vly/competitors/sponsor_boltnew.png differ diff --git a/freebuff/web/public/vly/competitors/vly_reviews.png b/freebuff/web/public/vly/competitors/vly_reviews.png new file mode 100644 index 0000000000..9e0a4fa014 Binary files /dev/null and b/freebuff/web/public/vly/competitors/vly_reviews.png differ diff --git a/freebuff/web/public/vly/competitors/vly_star_reviews.png b/freebuff/web/public/vly/competitors/vly_star_reviews.png new file mode 100644 index 0000000000..15e059ffd9 Binary files /dev/null and b/freebuff/web/public/vly/competitors/vly_star_reviews.png differ diff --git a/freebuff/web/public/vly/convex-color.svg b/freebuff/web/public/vly/convex-color.svg new file mode 100644 index 0000000000..8622c4c072 --- /dev/null +++ b/freebuff/web/public/vly/convex-color.svg @@ -0,0 +1,5 @@ +<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.128 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="#F3B01C"/> +<path d="M53.4012 90.1735C46.0375 107.191 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3097 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.831 62.318 70.4756 53.4012 90.1735Z" fill="#8D2676"/> +<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="#EE342F"/> +</svg> diff --git a/freebuff/web/public/vly/convex-logo.svg b/freebuff/web/public/vly/convex-logo.svg new file mode 100644 index 0000000000..e1b924ffb0 --- /dev/null +++ b/freebuff/web/public/vly/convex-logo.svg @@ -0,0 +1,5 @@ +<svg width="184" height="188" viewBox="0 0 184 188" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M108.092 130.021C126.258 128.003 143.385 118.323 152.815 102.167C148.349 142.127 104.653 167.385 68.9858 151.878C65.6992 150.453 62.8702 148.082 60.9288 145.034C52.9134 132.448 50.2786 116.433 54.0644 101.899C64.881 120.567 86.8748 132.01 108.092 130.021Z" fill="white"/> +<path d="M53.4012 90.1735C46.0375 107.19 45.7186 127.114 54.7463 143.51C22.9759 119.608 23.3226 68.4578 54.358 44.7949C57.2286 42.6078 60.64 41.3096 64.2178 41.1121C78.9312 40.336 93.8804 46.0225 104.364 56.6193C83.0637 56.8309 62.318 70.4756 53.4012 90.1735Z" fill="white"/> +<path d="M114.637 61.8552C103.89 46.8701 87.0686 36.6684 68.6387 36.358C104.264 20.1876 148.085 46.4045 152.856 85.1654C153.3 88.7635 152.717 92.4322 151.122 95.6775C144.466 109.195 132.124 119.679 117.702 123.559C128.269 103.96 126.965 80.0151 114.637 61.8552Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/vly/convex.svg b/freebuff/web/public/vly/convex.svg new file mode 100644 index 0000000000..7d70c4de1a --- /dev/null +++ b/freebuff/web/public/vly/convex.svg @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 367 370" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <g transform="matrix(1,0,0,1,-129.225,-127.948)"> + <g id="Layer-1" serif:id="Layer 1" transform="matrix(4.16667,0,0,4.16667,0,0)"> + <g transform="matrix(1,0,0,1,86.6099,107.074)"> + <path d="M0,-6.544C13.098,-7.973 25.449,-14.834 32.255,-26.287C29.037,2.033 -2.48,19.936 -28.196,8.94C-30.569,7.925 -32.605,6.254 -34.008,4.088C-39.789,-4.83 -41.69,-16.18 -38.963,-26.48C-31.158,-13.247 -15.3,-5.131 0,-6.544" style="fill:rgb(245,176,26);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,47.1708,74.7779)"> + <path d="M0,-2.489C-5.312,9.568 -5.545,23.695 0.971,35.316C-21.946,18.37 -21.692,-17.876 0.689,-34.65C2.754,-36.197 5.219,-37.124 7.797,-37.257C18.41,-37.805 29.19,-33.775 36.747,-26.264C21.384,-26.121 6.427,-16.446 0,-2.489" style="fill:rgb(141,37,118);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,91.325,66.4152)"> + <path d="M0,-14.199C-7.749,-24.821 -19.884,-32.044 -33.173,-32.264C-7.482,-43.726 24.112,-25.143 27.557,2.322C27.877,4.876 27.458,7.469 26.305,9.769C21.503,19.345 12.602,26.776 2.203,29.527C9.838,15.64 8.889,-1.328 0,-14.199" style="fill:rgb(238,52,47);fill-rule:nonzero;"/> + </g> + </g> + </g> +</svg> diff --git a/freebuff/web/public/vly/device.png b/freebuff/web/public/vly/device.png new file mode 100644 index 0000000000..226a48a594 Binary files /dev/null and b/freebuff/web/public/vly/device.png differ diff --git a/freebuff/web/public/vly/favicon.ico b/freebuff/web/public/vly/favicon.ico new file mode 100644 index 0000000000..c0b9fae283 Binary files /dev/null and b/freebuff/web/public/vly/favicon.ico differ diff --git a/freebuff/web/public/vly/favicon.svg b/freebuff/web/public/vly/favicon.svg new file mode 100644 index 0000000000..4927bfec04 --- /dev/null +++ b/freebuff/web/public/vly/favicon.svg @@ -0,0 +1,6 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="150" height="150" rx="20" fill="#EEEEEE"/> +<path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#303030"/> +<path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#303030"/> +<path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="#EEEEEE"/> +</svg> diff --git a/freebuff/web/public/vly/flowbite_thumbs-up-solid.svg b/freebuff/web/public/vly/flowbite_thumbs-up-solid.svg new file mode 100644 index 0000000000..a1d8197707 --- /dev/null +++ b/freebuff/web/public/vly/flowbite_thumbs-up-solid.svg @@ -0,0 +1,3 @@ +<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6967 9.68403H19.6617C19.9837 9.68403 20.3017 9.76403 20.5867 9.91603C20.8717 10.068 21.1187 10.29 21.3037 10.561C21.4884 10.8342 21.6065 11.1468 21.6485 11.4738C21.6905 11.8008 21.6553 12.1331 21.5457 12.444L19.1857 19.645C18.8977 20.459 18.7057 21 17.3017 21C15.2297 21 13.0257 20.323 11.1447 19.744C10.6727 19.599 10.2207 19.46 9.79675 19.34H9.68175V9.47802C11.3396 7.84219 12.7655 5.98694 13.9197 3.96403C14.1138 3.59053 14.4326 3.29684 14.8207 3.13403C15.2053 2.97366 15.6347 2.95663 16.0307 3.08603C16.4267 3.21603 16.7667 3.48303 16.9907 3.84303C17.2157 4.20303 17.3107 4.63103 17.2597 5.05403L15.6967 9.68403ZM4.84375 10H7.66675V18C7.66675 18.5305 7.45603 19.0392 7.08096 19.4142C6.70589 19.7893 6.19718 20 5.66675 20C5.13632 20 4.62761 19.7893 4.25253 19.4142C3.87746 19.0392 3.66675 18.5305 3.66675 18V11.177C3.66675 10.527 4.19475 10 4.84375 10Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/vly/hero.webp b/freebuff/web/public/vly/hero.webp new file mode 100644 index 0000000000..e5472157e3 Binary files /dev/null and b/freebuff/web/public/vly/hero.webp differ diff --git a/freebuff/web/public/vly/landing/Glass-morphism-screenshot.png b/freebuff/web/public/vly/landing/Glass-morphism-screenshot.png new file mode 100644 index 0000000000..ecd83d589b Binary files /dev/null and b/freebuff/web/public/vly/landing/Glass-morphism-screenshot.png differ diff --git a/freebuff/web/public/vly/landing/Minecraft-screenshot.png b/freebuff/web/public/vly/landing/Minecraft-screenshot.png new file mode 100644 index 0000000000..d4ff218247 Binary files /dev/null and b/freebuff/web/public/vly/landing/Minecraft-screenshot.png differ diff --git a/freebuff/web/public/vly/landing/Nocode-screenshot.png b/freebuff/web/public/vly/landing/Nocode-screenshot.png new file mode 100644 index 0000000000..0f40018e18 Binary files /dev/null and b/freebuff/web/public/vly/landing/Nocode-screenshot.png differ diff --git a/freebuff/web/public/vly/landing/Slack-screenshot.png b/freebuff/web/public/vly/landing/Slack-screenshot.png new file mode 100644 index 0000000000..dfb1441589 Binary files /dev/null and b/freebuff/web/public/vly/landing/Slack-screenshot.png differ diff --git a/freebuff/web/public/vly/landing/below_clouds.jpeg b/freebuff/web/public/vly/landing/below_clouds.jpeg new file mode 100644 index 0000000000..b2891da2ef Binary files /dev/null and b/freebuff/web/public/vly/landing/below_clouds.jpeg differ diff --git a/freebuff/web/public/vly/landing/bg-clouds.webp b/freebuff/web/public/vly/landing/bg-clouds.webp new file mode 100644 index 0000000000..ffb16a825b Binary files /dev/null and b/freebuff/web/public/vly/landing/bg-clouds.webp differ diff --git a/freebuff/web/public/vly/landing/bg-footer.webp b/freebuff/web/public/vly/landing/bg-footer.webp new file mode 100644 index 0000000000..a7be1fc56c Binary files /dev/null and b/freebuff/web/public/vly/landing/bg-footer.webp differ diff --git a/freebuff/web/public/vly/landing/daytona_logo.png b/freebuff/web/public/vly/landing/daytona_logo.png new file mode 100644 index 0000000000..d47c8a38cc Binary files /dev/null and b/freebuff/web/public/vly/landing/daytona_logo.png differ diff --git a/freebuff/web/public/vly/landing/hero-bg.webp b/freebuff/web/public/vly/landing/hero-bg.webp new file mode 100644 index 0000000000..c1da6067b2 Binary files /dev/null and b/freebuff/web/public/vly/landing/hero-bg.webp differ diff --git a/freebuff/web/public/vly/landing/hero-sun.webp b/freebuff/web/public/vly/landing/hero-sun.webp new file mode 100644 index 0000000000..e5472157e3 Binary files /dev/null and b/freebuff/web/public/vly/landing/hero-sun.webp differ diff --git a/freebuff/web/public/vly/landing/landmarks.jpeg b/freebuff/web/public/vly/landing/landmarks.jpeg new file mode 100644 index 0000000000..aa5b2bc3de Binary files /dev/null and b/freebuff/web/public/vly/landing/landmarks.jpeg differ diff --git a/freebuff/web/public/vly/landing/mid_sf_bg.jpeg b/freebuff/web/public/vly/landing/mid_sf_bg.jpeg new file mode 100644 index 0000000000..3f055928a6 Binary files /dev/null and b/freebuff/web/public/vly/landing/mid_sf_bg.jpeg differ diff --git a/freebuff/web/public/vly/landing/minecraft-project.webp b/freebuff/web/public/vly/landing/minecraft-project.webp new file mode 100644 index 0000000000..468a110b2d Binary files /dev/null and b/freebuff/web/public/vly/landing/minecraft-project.webp differ diff --git a/freebuff/web/public/vly/landing/new_skyline.jpeg b/freebuff/web/public/vly/landing/new_skyline.jpeg new file mode 100644 index 0000000000..94911a2b9a Binary files /dev/null and b/freebuff/web/public/vly/landing/new_skyline.jpeg differ diff --git a/freebuff/web/public/vly/landing/shadcn_logo.png b/freebuff/web/public/vly/landing/shadcn_logo.png new file mode 100644 index 0000000000..9ac00278b9 Binary files /dev/null and b/freebuff/web/public/vly/landing/shadcn_logo.png differ diff --git a/freebuff/web/public/vly/landing/skyline.jpeg b/freebuff/web/public/vly/landing/skyline.jpeg new file mode 100644 index 0000000000..1a4e73119c Binary files /dev/null and b/freebuff/web/public/vly/landing/skyline.jpeg differ diff --git a/freebuff/web/public/vly/light-blue-check-vly.png b/freebuff/web/public/vly/light-blue-check-vly.png new file mode 100644 index 0000000000..c3cb37aee8 Binary files /dev/null and b/freebuff/web/public/vly/light-blue-check-vly.png differ diff --git a/freebuff/web/public/vly/line-md_arrow-up.png b/freebuff/web/public/vly/line-md_arrow-up.png new file mode 100644 index 0000000000..4d13ca2b37 Binary files /dev/null and b/freebuff/web/public/vly/line-md_arrow-up.png differ diff --git a/freebuff/web/public/vly/line-md_arrow-up.svg b/freebuff/web/public/vly/line-md_arrow-up.svg new file mode 100644 index 0000000000..924125748c --- /dev/null +++ b/freebuff/web/public/vly/line-md_arrow-up.svg @@ -0,0 +1,6 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M21.2578 27.6665V12.4349" stroke="#FEFEFE" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M27.3505 18.0925L21.2579 11.9999L15.1653 18.0925" stroke="#FEFEFE" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/freebuff/web/public/vly/logo.svg b/freebuff/web/public/vly/logo.svg new file mode 100644 index 0000000000..e53adf0e00 --- /dev/null +++ b/freebuff/web/public/vly/logo.svg @@ -0,0 +1,5 @@ +<svg width="150" height="150" viewBox="0 0 150 150" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M84 71.3367L110 91.5L90.5 66L60 27L84 71.3367Z" fill="#303030"/> +<path d="M25 34.5C25 34.5 73.0241 66.7839 100.5 86C104.406 88.732 106.691 90.634 110.5 93.5C114.309 96.366 117.834 99.1793 120.5 104.5C121.801 107.095 122.154 108.618 122.5 111.5C122.686 113.051 125 122 125 122C125 122 118.037 111.646 113 108.5C107.396 105 103.072 101.185 96.5 100.5H85.5L50 86L81 90L25 34.5Z" fill="#303030"/> +<path d="M111.5 99.5C113.889 100.593 114.945 100.883 116.5 103C117.792 104.76 119.5 111 119.5 111C119.5 111 114.744 104.577 113 103C110.233 100.498 104 96.5 104 96.5C104 96.5 110.036 98.8299 111.5 99.5Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/vly/logos/faclon_logo_rounded_white.png b/freebuff/web/public/vly/logos/faclon_logo_rounded_white.png new file mode 100644 index 0000000000..cbddaa56a3 Binary files /dev/null and b/freebuff/web/public/vly/logos/faclon_logo_rounded_white.png differ diff --git a/freebuff/web/public/vly/logos/falcon_clear_bg.png b/freebuff/web/public/vly/logos/falcon_clear_bg.png new file mode 100644 index 0000000000..a6f7990b2d Binary files /dev/null and b/freebuff/web/public/vly/logos/falcon_clear_bg.png differ diff --git a/freebuff/web/public/vly/logos/falcon_logo_dark.png b/freebuff/web/public/vly/logos/falcon_logo_dark.png new file mode 100644 index 0000000000..a932f09660 Binary files /dev/null and b/freebuff/web/public/vly/logos/falcon_logo_dark.png differ diff --git a/freebuff/web/public/vly/logos/falcon_logo_favicon.png b/freebuff/web/public/vly/logos/falcon_logo_favicon.png new file mode 100644 index 0000000000..ef05499d13 Binary files /dev/null and b/freebuff/web/public/vly/logos/falcon_logo_favicon.png differ diff --git a/freebuff/web/public/vly/logos/favicon_old.ico b/freebuff/web/public/vly/logos/favicon_old.ico new file mode 100644 index 0000000000..52e4c6105e Binary files /dev/null and b/freebuff/web/public/vly/logos/favicon_old.ico differ diff --git a/freebuff/web/public/vly/logos/logo_old.svg b/freebuff/web/public/vly/logos/logo_old.svg new file mode 100644 index 0000000000..c0e872f1ff --- /dev/null +++ b/freebuff/web/public/vly/logos/logo_old.svg @@ -0,0 +1,4 @@ +<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M43.3425 71.9746C40.83 69.3049 22.3495 41.8363 14 28C42.2387 28 74.0844 31.2927 90.072 54.5697C94.2246 60.6157 97.0525 66.9165 99.8804 73.6386C102.708 66.9165 105.536 60.6157 109.689 54.5697C125.676 31.2927 157.522 28 185.761 28L99.8804 171.134L74.1163 129.228C94.1551 110.335 108.468 114.596 115.625 118.89C118.488 111.733 120.125 87.5292 90.5767 86.685C65.5283 85.9693 47.3406 76.2228 43.3425 71.9746Z" fill="#A37FBC"/> +<ellipse cx="99.255" cy="103.68" rx="3.57633" ry="2.9995" fill="#A37FBC"/> +</svg> diff --git a/freebuff/web/public/vly/manifest/android-chrome-192x192.png b/freebuff/web/public/vly/manifest/android-chrome-192x192.png new file mode 100644 index 0000000000..9616a5e493 Binary files /dev/null and b/freebuff/web/public/vly/manifest/android-chrome-192x192.png differ diff --git a/freebuff/web/public/vly/manifest/android-chrome-512x512.png b/freebuff/web/public/vly/manifest/android-chrome-512x512.png new file mode 100644 index 0000000000..ef05499d13 Binary files /dev/null and b/freebuff/web/public/vly/manifest/android-chrome-512x512.png differ diff --git a/freebuff/web/public/vly/manifest/apple-touch-icon.png b/freebuff/web/public/vly/manifest/apple-touch-icon.png new file mode 100644 index 0000000000..0a10d41475 Binary files /dev/null and b/freebuff/web/public/vly/manifest/apple-touch-icon.png differ diff --git a/freebuff/web/public/vly/manifest/favicon-16x16.png b/freebuff/web/public/vly/manifest/favicon-16x16.png new file mode 100644 index 0000000000..b6b21c94d0 Binary files /dev/null and b/freebuff/web/public/vly/manifest/favicon-16x16.png differ diff --git a/freebuff/web/public/vly/manifest/favicon-32x32.png b/freebuff/web/public/vly/manifest/favicon-32x32.png new file mode 100644 index 0000000000..5f6e0e1ba4 Binary files /dev/null and b/freebuff/web/public/vly/manifest/favicon-32x32.png differ diff --git a/freebuff/web/public/vly/material-symbols_photo-rounded.svg b/freebuff/web/public/vly/material-symbols_photo-rounded.svg new file mode 100644 index 0000000000..f6ab39aec7 --- /dev/null +++ b/freebuff/web/public/vly/material-symbols_photo-rounded.svg @@ -0,0 +1,5 @@ +<svg width="41" height="40" viewBox="0 0 41 40" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" fill="#CCB8DA"/> +<rect x="1" y="0.5" width="39" height="39" rx="19.5" stroke="#CCB8DA"/> +<path d="M12.7222 30C12.1111 30 11.5881 29.7826 11.1533 29.3478C10.7185 28.913 10.5007 28.3896 10.5 27.7778V12.2222C10.5 11.6111 10.7178 11.0881 11.1533 10.6533C11.5889 10.2185 12.1119 10.0007 12.7222 10H28.2778C28.8889 10 29.4122 10.2178 29.8478 10.6533C30.2833 11.0889 30.5007 11.6119 30.5 12.2222V27.7778C30.5 28.3889 30.2826 28.9122 29.8478 29.3478C29.413 29.7833 28.8896 30.0007 28.2778 30H12.7222ZM14.9444 25.5556H26.0556C26.2778 25.5556 26.4444 25.4537 26.5556 25.25C26.6667 25.0463 26.6481 24.8519 26.5 24.6667L23.4444 20.5833C23.3333 20.4352 23.1852 20.3611 23 20.3611C22.8148 20.3611 22.6667 20.4352 22.5556 20.5833L19.6667 24.4444L17.6111 21.6944C17.5 21.5463 17.3519 21.4722 17.1667 21.4722C16.9815 21.4722 16.8333 21.5463 16.7222 21.6944L14.5 24.6667C14.3519 24.8519 14.3333 25.0463 14.4444 25.25C14.5556 25.4537 14.7222 25.5556 14.9444 25.5556Z" fill="#FEFEFE"/> +</svg> diff --git a/freebuff/web/public/vly/ph_flower-fill-1.svg b/freebuff/web/public/vly/ph_flower-fill-1.svg new file mode 100644 index 0000000000..3653cf3d9e --- /dev/null +++ b/freebuff/web/public/vly/ph_flower-fill-1.svg @@ -0,0 +1,27 @@ +<svg width="237" height="236" viewBox="0 0 237 236" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5048)"> +<rect width="231.8" height="231.8" transform="translate(2.5 2)" fill="#F1F1F1"/> +<path d="M192.965 119.131C192.232 118.706 144.586 118.298 143.753 117.9C144.586 117.502 192.232 117.094 192.965 116.668C197.118 114.308 200.762 111.149 203.689 107.374C206.615 103.599 208.766 99.2822 210.018 94.6726C211.269 90.0629 211.596 85.2512 210.981 80.5145C210.365 75.7778 208.819 71.2097 206.43 67.0731C204.042 62.9366 200.859 59.3133 197.065 56.4118C193.27 53.5104 188.939 51.3882 184.322 50.1673C179.704 48.9464 174.89 48.651 170.158 49.298C165.425 49.9451 160.867 51.5219 156.747 53.9376C156.013 54.3632 135.227 97.7605 134.475 98.2947C134.548 97.3892 154.619 53.5664 154.619 52.6609C154.619 43.0551 150.803 33.8427 144.011 27.0504C137.218 20.258 128.006 16.4421 118.4 16.4421C108.794 16.4421 99.5818 20.258 92.7895 27.0504C85.9971 33.8427 82.1813 43.0551 82.1813 52.6609C82.1813 53.512 105.597 94.989 105.714 95.9488C104.963 95.4326 80.7868 54.3632 80.0534 53.9376C75.9328 51.5219 71.375 49.9451 66.6425 49.298C61.91 48.651 57.0963 48.9464 52.4784 50.1673C47.8605 51.3882 43.5298 53.5104 39.7354 56.4118C35.9411 59.3133 32.7582 62.9366 30.3698 67.0731C27.9815 71.2097 26.435 75.7778 25.8193 80.5145C25.2037 85.2512 25.531 90.0629 26.7825 94.6726C28.034 99.2822 30.1849 103.599 33.1115 107.374C36.038 111.149 39.6823 114.308 43.8347 116.668C44.5681 117.094 92.2438 118.733 93.0768 119.131C92.2438 119.53 44.5681 118.706 43.8347 119.131C39.6823 121.492 36.038 124.651 33.1115 128.426C30.1849 132.201 28.034 136.518 26.7825 141.127C25.531 145.737 25.2037 150.549 25.8193 155.285C26.435 160.022 27.9815 164.59 30.3698 168.727C32.7582 172.863 35.9411 176.487 39.7354 179.388C43.5298 182.289 47.8605 184.412 52.4784 185.633C57.0963 186.853 61.91 187.149 66.6425 186.502C71.375 185.855 75.9328 184.278 80.0534 181.862C80.7868 181.437 108.999 141.662 109.751 141.127C109.678 142.033 82.1813 182.233 82.1813 183.094C82.1813 192.699 85.9971 201.912 92.7895 208.704C99.5818 215.497 108.794 219.312 118.4 219.312C128.006 219.312 137.218 215.497 144.011 208.704C150.803 201.912 154.619 192.699 154.619 183.094C154.619 182.243 128.635 142.042 128.562 141.127C129.314 141.643 156.013 181.437 156.747 181.862C162.233 185.04 168.461 186.714 174.802 186.716C177.988 186.712 181.16 186.292 184.237 185.466C191.156 183.611 197.37 179.747 202.093 174.36C206.816 168.974 209.835 162.308 210.77 155.206C211.704 148.103 210.511 140.884 207.342 134.459C204.173 128.035 199.17 122.694 192.965 119.113V119.131ZM118.4 143.253C113.386 143.253 108.484 141.766 104.315 138.98C100.145 136.194 96.8957 132.235 94.9768 127.602C93.0579 122.969 92.5558 117.872 93.534 112.954C94.5123 108.036 96.9269 103.518 100.473 99.9725C104.018 96.4268 108.536 94.0122 113.454 93.0339C118.372 92.0557 123.47 92.5578 128.102 94.4767C132.735 96.3956 136.695 99.6452 139.48 103.814C142.266 107.984 143.753 112.886 143.753 117.9C143.753 124.624 141.082 131.073 136.327 135.827C131.573 140.582 125.124 143.253 118.4 143.253Z" fill="url(#paint0_radial_1287_5048)"/> +<circle cx="49" cy="50.5" r="2.5" fill="white"/> +<circle cx="166" cy="111.5" r="2.5" fill="white"/> +<circle cx="173" cy="121.5" r="4.5" fill="white"/> +<circle cx="141.465" cy="36.0203" r="2.88308" fill="white"/> +<circle cx="151.844" cy="77.5367" r="1.72985" fill="white"/> +<circle cx="154.15" cy="85.6095" r="1.72985" fill="white"/> +<circle cx="141.465" cy="36.0203" r="2.88308" fill="white"/> +<circle cx="55" cy="62.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5048" x="0.5" y="0" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1287_5048"/> +</filter> +<radialGradient id="paint0_radial_1287_5048" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.4 117.877) rotate(90) scale(101.435 92.8828)"> +<stop offset="0.115385" stop-color="#F8EAD1"/> +<stop offset="0.254808" stop-color="#FBE5E6"/> +<stop offset="0.692308" stop-color="#B4CDE8"/> +<stop offset="1" stop-color="#81C7D6"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/vly/ph_flower-fill-2.svg b/freebuff/web/public/vly/ph_flower-fill-2.svg new file mode 100644 index 0000000000..0bfc92344c --- /dev/null +++ b/freebuff/web/public/vly/ph_flower-fill-2.svg @@ -0,0 +1,63 @@ +<svg width="237" height="236" viewBox="0 0 237 236" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5062)"> +<rect width="231.8" height="231.8" transform="translate(2.30005 2)" fill="#F1F1F1"/> +<g filter="url(#filter1_f_1287_5062)"> +<ellipse cx="118.5" cy="118" rx="71" ry="96" fill="url(#paint0_radial_1287_5062)"/> +</g> +<circle cx="118" cy="158.5" r="6.5" fill="url(#paint1_radial_1287_5062)"/> +<circle cx="118" cy="133.5" r="9.5" fill="url(#paint2_radial_1287_5062)"/> +<g filter="url(#filter2_f_1287_5062)"> +<circle cx="118.5" cy="74" r="50" fill="url(#paint3_radial_1287_5062)"/> +</g> +<circle cx="118" cy="113.5" r="10.5" fill="url(#paint4_radial_1287_5062)"/> +<circle cx="83.0002" cy="66.5" r="2.5" fill="white"/> +<circle cx="79.5002" cy="61" r="1" fill="white"/> +<circle cx="142" cy="41.5" r="1.5" fill="white"/> +<circle cx="113" cy="102.5" r="1.5" fill="white"/> +<circle cx="137" cy="46.5" r="1.5" fill="white"/> +<circle cx="129" cy="128.5" r="1.5" fill="white"/> +<circle cx="110" cy="112.5" r="1.5" fill="white"/> +<circle cx="150" cy="48.5" r="3.5" fill="white"/> +<circle cx="122" cy="133.5" r="3.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5062" x="0.300049" y="0" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<filter id="filter1_f_1287_5062" x="44.5002" y="19" width="148" height="198" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<filter id="filter2_f_1287_5062" x="51.5002" y="7" width="134" height="134" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1287_5062"/> +</filter> +<radialGradient id="paint0_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.5 88.5) rotate(90) scale(101.5 75.0677)"> +<stop offset="0.100962" stop-color="#F6F8FF"/> +<stop offset="0.322115" stop-color="#C6D6F3"/> +<stop offset="0.9375" stop-color="#81C7D6"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 158.5) rotate(90) scale(6.5)"> +<stop stop-color="#F9F4C0"/> +<stop offset="1" stop-color="#F9F4C0" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint2_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 133.5) rotate(90) scale(9.5)"> +<stop stop-color="#FCF3BA"/> +<stop offset="1" stop-color="#FCF3BA" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint3_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118.5 61.5) rotate(90) scale(62.5)"> +<stop offset="0.158654" stop-color="#F6F4F6"/> +<stop offset="0.274038" stop-color="#FFDAF3"/> +<stop offset="0.533654" stop-color="#F8F3B8"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint4_radial_1287_5062" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(118 113.5) rotate(90) scale(10.5)"> +<stop offset="0.206731" stop-color="#FDF5B8"/> +<stop offset="1" stop-color="#FDF5B8" stop-opacity="0"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/vly/ph_flower-fill-3.svg b/freebuff/web/public/vly/ph_flower-fill-3.svg new file mode 100644 index 0000000000..26fd0c9b79 --- /dev/null +++ b/freebuff/web/public/vly/ph_flower-fill-3.svg @@ -0,0 +1,49 @@ +<svg width="234" height="234" viewBox="0 0 234 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5082)"> +<rect width="231.8" height="231.8" transform="translate(1.09998 1)" fill="#F1F1F1"/> +<circle cx="77.4999" cy="64" r="1" fill="white"/> +<g filter="url(#filter1_f_1287_5082)"> +<circle cx="87.9999" cy="116.5" r="55.5" fill="url(#paint0_radial_1287_5082)"/> +</g> +<g filter="url(#filter2_f_1287_5082)"> +<path d="M201.5 116.5C201.5 131.688 189.817 144 175.405 144C160.993 144 152.5 131.688 152.5 116.5C152.5 101.312 160.993 89 175.405 89C189.817 89 201.5 101.312 201.5 116.5Z" fill="url(#paint1_radial_1287_5082)"/> +</g> +<circle cx="129" cy="86.5" r="2.5" fill="white"/> +<circle cx="190" cy="103.5" r="2.5" fill="white"/> +<circle cx="43.9999" cy="141.5" r="2.5" fill="white"/> +<circle cx="120" cy="92.5" r="3.5" fill="white"/> +<circle cx="130" cy="97.5" r="1.5" fill="white"/> +<circle cx="123" cy="154.5" r="1.5" fill="white"/> +<circle cx="187" cy="110.5" r="1.5" fill="white"/> +<circle cx="44.9999" cy="129.5" r="1.5" fill="white"/> +<circle cx="51.9999" cy="132.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5082" x="0.0999756" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<filter id="filter1_f_1287_5082" x="29.4999" y="58" width="117" height="117" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<filter id="filter2_f_1287_5082" x="145.5" y="82" width="63" height="69" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="3.5" result="effect1_foregroundBlur_1287_5082"/> +</filter> +<radialGradient id="paint0_radial_1287_5082" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(87.9999 116.5) rotate(89.3489) scale(44.0028)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5082" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(175.405 116.5) rotate(90) scale(27.5 26.0952)"> +<stop stop-color="#FBF5BB"/> +<stop offset="1" stop-color="#C9E1D5"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/vly/ph_flower-fill-4.svg b/freebuff/web/public/vly/ph_flower-fill-4.svg new file mode 100644 index 0000000000..133a6d91fb --- /dev/null +++ b/freebuff/web/public/vly/ph_flower-fill-4.svg @@ -0,0 +1,69 @@ +<svg width="235" height="234" viewBox="0 0 235 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1287_5100)" filter="url(#filter0_f_1287_5100)"> +<rect width="231.8" height="231.8" transform="translate(1.90002 1)" fill="#F1F1F1"/> +<circle cx="63.5001" cy="64" r="1" fill="white"/> +<g filter="url(#filter1_df_1287_5100)"> +<circle cx="64.5001" cy="117" r="121" fill="url(#paint0_radial_1287_5100)"/> +</g> +<g filter="url(#filter2_df_1287_5100)"> +<ellipse cx="36.0001" cy="117" rx="62.5" ry="64" fill="url(#paint1_radial_1287_5100)"/> +</g> +<circle cx="151" cy="21.5" r="2.5" fill="white"/> +<circle cx="87.0001" cy="205.5" r="2.5" fill="white"/> +<circle cx="17.0001" cy="119.5" r="2.5" fill="white"/> +<circle cx="138" cy="27.5" r="3.5" fill="white"/> +<circle cx="155" cy="32.5" r="1.5" fill="white"/> +<circle cx="19.0001" cy="186.5" r="1.5" fill="white"/> +<circle cx="76.0001" cy="209.5" r="1.5" fill="white"/> +<circle cx="41.0001" cy="96.5" r="1.5" fill="white"/> +<circle cx="47.0001" cy="103.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5100" x="0.900024" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5100"/> +</filter> +<filter id="filter1_df_1287_5100" x="-156.5" y="-104" width="442" height="442" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="50"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.545098 0 0 0 0 0.796078 0 0 0 0 0.854902 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1287_5100"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1287_5100" result="shape"/> +<feGaussianBlur stdDeviation="5" result="effect2_foregroundBlur_1287_5100"/> +</filter> +<filter id="filter2_df_1287_5100" x="-126.5" y="-47" width="325" height="328" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="50"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.545098 0 0 0 0 0.796078 0 0 0 0 0.854902 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1287_5100"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1287_5100" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect2_foregroundBlur_1287_5100"/> +</filter> +<radialGradient id="paint0_radial_1287_5100" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(37.0001 117) scale(121)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.177885" stop-color="#F8E1F0"/> +<stop offset="0.259615" stop-color="#CED7F6"/> +<stop offset="0.413462" stop-color="#AFD3EC"/> +<stop offset="0.764423" stop-color="#8ACAD9"/> +<stop offset="1" stop-color="#F8EDD0"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5100" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(36.0001 117) rotate(89.3642) scale(50.7419 49.5529)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.177885" stop-color="#F8E1F0"/> +<stop offset="0.259615" stop-color="#CED7F6"/> +<stop offset="0.413462" stop-color="#AFD3EC"/> +<stop offset="0.764423" stop-color="#8ACAD9"/> +<stop offset="1" stop-color="#F8EDD0"/> +</radialGradient> +<clipPath id="clip0_1287_5100"> +<rect width="231.8" height="231.8" fill="white" transform="translate(1.90002 1)"/> +</clipPath> +</defs> +</svg> diff --git a/freebuff/web/public/vly/ph_flower-fill-5.svg b/freebuff/web/public/vly/ph_flower-fill-5.svg new file mode 100644 index 0000000000..89947097a2 --- /dev/null +++ b/freebuff/web/public/vly/ph_flower-fill-5.svg @@ -0,0 +1,67 @@ +<svg width="235" height="234" viewBox="0 0 235 234" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_f_1287_5118)"> +<rect width="231.8" height="231.8" transform="translate(1.7002 1)" fill="#F1F1F1"/> +<circle cx="66.7002" cy="43" r="1" fill="white"/> +<g filter="url(#filter1_f_1287_5118)"> +<ellipse cx="117" cy="159.5" rx="55.5" ry="32.5" fill="url(#paint0_radial_1287_5118)"/> +</g> +<g filter="url(#filter2_f_1287_5118)"> +<ellipse cx="117" cy="118.5" rx="55.5" ry="32.5" fill="url(#paint1_radial_1287_5118)"/> +</g> +<g filter="url(#filter3_f_1287_5118)"> +<ellipse cx="117" cy="74.5" rx="55.5" ry="32.5" fill="url(#paint2_radial_1287_5118)"/> +</g> +<circle cx="118.2" cy="65.5" r="2.5" fill="white"/> +<circle cx="170" cy="157.5" r="2.5" fill="white"/> +<circle cx="74" cy="137.5" r="2.5" fill="white"/> +<circle cx="109.2" cy="71.5" r="3.5" fill="white"/> +<circle cx="119.2" cy="76.5" r="1.5" fill="white"/> +<circle cx="170" cy="168.5" r="1.5" fill="white"/> +<circle cx="162" cy="169.5" r="1.5" fill="white"/> +<circle cx="78" cy="132.5" r="1.5" fill="white"/> +<circle cx="69" cy="125.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1287_5118" x="0.700195" y="0" width="233.8" height="233.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="0.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter1_f_1287_5118" x="58.5" y="124" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter2_f_1287_5118" x="58.5" y="83" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<filter id="filter3_f_1287_5118" x="58.5" y="39" width="117" height="71" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_1287_5118"/> +</filter> +<radialGradient id="paint0_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 159.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint1_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 118.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +<radialGradient id="paint2_radial_1287_5118" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117 74.5) rotate(88.8883) scale(25.7706 43.9974)"> +<stop offset="0.0432692" stop-color="#FDFFFE"/> +<stop offset="0.274038" stop-color="#F8E1F0"/> +<stop offset="0.490385" stop-color="#CED7F6"/> +<stop offset="0.831731" stop-color="#AFD3EC"/> +<stop offset="0.990385" stop-color="#8ACAD9"/> +</radialGradient> +</defs> +</svg> diff --git a/freebuff/web/public/vly/pointandclick.svg b/freebuff/web/public/vly/pointandclick.svg new file mode 100644 index 0000000000..6b0b514ecf --- /dev/null +++ b/freebuff/web/public/vly/pointandclick.svg @@ -0,0 +1,66 @@ +<svg width="237" height="237" viewBox="0 0 237 237" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_1042_326)" filter="url(#filter0_f_1042_326)"> +<rect width="231.8" height="231.8" transform="translate(2.7998 2.2998)" fill="#F1F1F1"/> +<g filter="url(#filter1_f_1042_326)"> +<path d="M244 199.499C203.548 238.482 56.9735 237.078 10.3746 188.724C-36.2243 140.369 -41.2073 69.5681 -0.755226 30.5847C39.6968 -8.39867 218.5 24.4988 150 58.9995C196.599 107.354 284.452 160.515 244 199.499Z" fill="url(#paint0_radial_1042_326)"/> +</g> +<g filter="url(#filter2_f_1042_326)"> +<ellipse cx="83.9301" cy="99.9933" rx="53.5" ry="61" transform="rotate(44.6538 83.9301 99.9933)" fill="url(#paint1_radial_1042_326)"/> +</g> +<g filter="url(#filter3_f_1042_326)"> +<path d="M139.71 135.636L214.007 167.163L171.102 209.99L139.71 135.636Z" fill="url(#paint2_linear_1042_326)"/> +</g> +<circle cx="186.5" cy="163.5" r="2.5" fill="white"/> +<circle cx="201.5" cy="161.5" r="4.5" fill="white"/> +<circle cx="135.73" cy="142.73" r="1.72985" fill="white"/> +<circle cx="195.73" cy="172.73" r="1.72985" fill="white"/> +<circle cx="133.883" cy="130.883" r="2.88308" fill="white"/> +<circle cx="127.73" cy="135.73" r="1.72985" fill="white"/> +<circle cx="88.5" cy="29.5" r="2.5" fill="white"/> +<circle cx="101.5" cy="33.5" r="1.5" fill="white"/> +<circle cx="80.5" cy="33.5" r="1.5" fill="white"/> +<circle cx="55.5" cy="62.5" r="1.5" fill="white"/> +</g> +<defs> +<filter id="filter0_f_1042_326" x="0.799805" y="0.299805" width="235.8" height="235.8" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="1" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter1_f_1042_326" x="-45.1138" y="-4.06104" width="316.476" height="248.083" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter2_f_1042_326" x="9.60205" y="25.5747" width="148.656" height="148.837" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="8.5" result="effect1_foregroundBlur_1042_326"/> +</filter> +<filter id="filter3_f_1042_326" x="135.71" y="131.636" width="82.2964" height="82.354" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/> +<feGaussianBlur stdDeviation="2" result="effect1_foregroundBlur_1042_326"/> +</filter> +<radialGradient id="paint0_radial_1042_326" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(101.931 100.491) rotate(136.059) scale(127.151 151.99)"> +<stop offset="0.158654" stop-color="#F6F4F6"/> +<stop offset="0.201923" stop-color="#81C7D6"/> +<stop offset="0.533654" stop-color="#A6CCE3"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<radialGradient id="paint1_radial_1042_326" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(83.9301 84.7433) rotate(90) scale(76.25 66.875)"> +<stop offset="0.0865385" stop-color="#F5E4F2"/> +<stop offset="0.298077" stop-color="#C9DAF3"/> +<stop offset="0.533654" stop-color="#F8F3B8"/> +<stop offset="0.735577" stop-color="#F8F3B8" stop-opacity="0"/> +</radialGradient> +<linearGradient id="paint2_linear_1042_326" x1="116.243" y1="104.702" x2="207.504" y2="208.565" gradientUnits="userSpaceOnUse"> +<stop offset="0.197115" stop-color="#F3F2BF"/> +<stop offset="0.538462" stop-color="#DCE9CB"/> +<stop offset="1" stop-color="#97CADE" stop-opacity="0"/> +</linearGradient> +<clipPath id="clip0_1042_326"> +<rect width="231.8" height="231.8" fill="white" transform="translate(2.7998 2.2998)"/> +</clipPath> +</defs> +</svg> diff --git a/freebuff/web/public/vly/productionready.svg b/freebuff/web/public/vly/productionready.svg new file mode 100644 index 0000000000..46da1a7ac3 --- /dev/null +++ b/freebuff/web/public/vly/productionready.svg @@ -0,0 +1,3 @@ +<svg width="20" height="19" viewBox="0 0 20 19" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M2.3335 2.37467C2.3335 2.16471 2.4169 1.96335 2.56537 1.81488C2.71384 1.66642 2.9152 1.58301 3.12516 1.58301H17.3752C17.5851 1.58301 17.7865 1.66642 17.935 1.81488C18.0834 1.96335 18.1668 2.16471 18.1668 2.37467C18.1668 2.58464 18.0834 2.786 17.935 2.93447C17.7865 3.08293 17.5851 3.16634 17.3752 3.16634V11.8747C17.3752 12.0846 17.2918 12.286 17.1433 12.4345C16.9948 12.5829 16.7935 12.6663 16.5835 12.6663H12.9315L14.1681 16.3745C14.2345 16.5738 14.2189 16.7912 14.125 16.979C14.031 17.1668 13.8663 17.3096 13.667 17.376C13.4677 17.4423 13.2503 17.4268 13.0625 17.3328C12.8747 17.2388 12.7319 17.0741 12.6655 16.8748L11.2635 12.6663H9.23841L7.83558 16.8748C7.76923 17.0741 7.62645 17.2388 7.43864 17.3328C7.25083 17.4268 7.03338 17.4423 6.83412 17.376C6.63487 17.3096 6.47013 17.1668 6.37615 16.979C6.28217 16.7912 6.26665 16.5738 6.333 16.3745L7.56879 12.6663H3.91683C3.70687 12.6663 3.5055 12.5829 3.35704 12.4345C3.20857 12.286 3.12516 12.0846 3.12516 11.8747V3.16634C2.9152 3.16634 2.71384 3.08293 2.56537 2.93447C2.4169 2.786 2.3335 2.58464 2.3335 2.37467ZM9.89787 4.88267C9.77865 4.80313 9.64006 4.75744 9.4969 4.75049C9.35375 4.74353 9.21139 4.77557 9.08501 4.84319C8.95864 4.91081 8.85299 5.01146 8.77935 5.13442C8.7057 5.25737 8.66681 5.39802 8.66683 5.54134V8.70801C8.66681 8.85133 8.7057 8.99198 8.77935 9.11493C8.85299 9.23789 8.95864 9.33854 9.08501 9.40616C9.21139 9.47377 9.35375 9.50581 9.4969 9.49886C9.64006 9.49191 9.77865 9.44622 9.89787 9.36667L12.2729 7.78334C12.3813 7.71104 12.4702 7.6131 12.5317 7.4982C12.5932 7.3833 12.6253 7.25499 12.6253 7.12467C12.6253 6.99436 12.5932 6.86605 12.5317 6.75115C12.4702 6.63625 12.3813 6.53831 12.2729 6.46601L9.89787 4.88267ZM10.4061 7.12467L10.2502 7.22838V7.02176L10.4061 7.12467Z" fill="white"/> +</svg> diff --git a/freebuff/web/public/vly/reviews/base44_review.png b/freebuff/web/public/vly/reviews/base44_review.png new file mode 100644 index 0000000000..cfb1bbb18b Binary files /dev/null and b/freebuff/web/public/vly/reviews/base44_review.png differ diff --git a/freebuff/web/public/vly/reviews/bolt_review.png b/freebuff/web/public/vly/reviews/bolt_review.png new file mode 100644 index 0000000000..631858d048 Binary files /dev/null and b/freebuff/web/public/vly/reviews/bolt_review.png differ diff --git a/freebuff/web/public/vly/reviews/lovable_review.png b/freebuff/web/public/vly/reviews/lovable_review.png new file mode 100644 index 0000000000..beb2afe418 Binary files /dev/null and b/freebuff/web/public/vly/reviews/lovable_review.png differ diff --git a/freebuff/web/public/vly/reviews/replit_review.png b/freebuff/web/public/vly/reviews/replit_review.png new file mode 100644 index 0000000000..456d347333 Binary files /dev/null and b/freebuff/web/public/vly/reviews/replit_review.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_1.png b/freebuff/web/public/vly/reviews/vly_review_1.png new file mode 100644 index 0000000000..156f385076 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_1.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_2.png b/freebuff/web/public/vly/reviews/vly_review_2.png new file mode 100644 index 0000000000..59fe0b1620 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_2.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_3.png b/freebuff/web/public/vly/reviews/vly_review_3.png new file mode 100644 index 0000000000..0e5b76ff98 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_3.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_4.png b/freebuff/web/public/vly/reviews/vly_review_4.png new file mode 100644 index 0000000000..bbf9b1366f Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_4.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_5.png b/freebuff/web/public/vly/reviews/vly_review_5.png new file mode 100644 index 0000000000..942cada81a Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_5.png differ diff --git a/freebuff/web/public/vly/reviews/vly_review_6.png b/freebuff/web/public/vly/reviews/vly_review_6.png new file mode 100644 index 0000000000..07c6b19219 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_review_6.png differ diff --git a/freebuff/web/public/vly/reviews/vly_tesitmonial_13.png b/freebuff/web/public/vly/reviews/vly_tesitmonial_13.png new file mode 100644 index 0000000000..97bbf26e95 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_tesitmonial_13.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial.png b/freebuff/web/public/vly/reviews/vly_testimonial.png new file mode 100644 index 0000000000..5a2435d54d Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_10.png b/freebuff/web/public/vly/reviews/vly_testimonial_10.png new file mode 100644 index 0000000000..fc53f8cd1f Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_10.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_11.png b/freebuff/web/public/vly/reviews/vly_testimonial_11.png new file mode 100644 index 0000000000..f5a5ba977a Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_11.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_12.png b/freebuff/web/public/vly/reviews/vly_testimonial_12.png new file mode 100644 index 0000000000..8c3a838070 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_12.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_14.png b/freebuff/web/public/vly/reviews/vly_testimonial_14.png new file mode 100644 index 0000000000..21d6a232a2 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_14.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_2.png b/freebuff/web/public/vly/reviews/vly_testimonial_2.png new file mode 100644 index 0000000000..966ae3662d Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_2.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_3.png b/freebuff/web/public/vly/reviews/vly_testimonial_3.png new file mode 100644 index 0000000000..775ffbf523 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_3.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_4.png b/freebuff/web/public/vly/reviews/vly_testimonial_4.png new file mode 100644 index 0000000000..ea4cadbb4b Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_4.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_5.png b/freebuff/web/public/vly/reviews/vly_testimonial_5.png new file mode 100644 index 0000000000..fd05111dce Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_5.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_6.png b/freebuff/web/public/vly/reviews/vly_testimonial_6.png new file mode 100644 index 0000000000..c1a74668bd Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_6.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_7.png b/freebuff/web/public/vly/reviews/vly_testimonial_7.png new file mode 100644 index 0000000000..63e04c8ccf Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_7.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_8.png b/freebuff/web/public/vly/reviews/vly_testimonial_8.png new file mode 100644 index 0000000000..6cffcd3905 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_8.png differ diff --git a/freebuff/web/public/vly/reviews/vly_testimonial_9.png b/freebuff/web/public/vly/reviews/vly_testimonial_9.png new file mode 100644 index 0000000000..aff74a2dd4 Binary files /dev/null and b/freebuff/web/public/vly/reviews/vly_testimonial_9.png differ diff --git a/freebuff/web/public/vly/site.webmanifest b/freebuff/web/public/vly/site.webmanifest new file mode 100644 index 0000000000..c859750c70 --- /dev/null +++ b/freebuff/web/public/vly/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "VLY AI", + "short_name": "vly.ai", + "description": "Build production-ready web apps with AI.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/manifest/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/manifest/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/freebuff/web/public/vly/trustpilot_emergent.png b/freebuff/web/public/vly/trustpilot_emergent.png new file mode 100644 index 0000000000..56d3d1c60c Binary files /dev/null and b/freebuff/web/public/vly/trustpilot_emergent.png differ diff --git a/freebuff/web/public/vly/trustpilot_vly.png b/freebuff/web/public/vly/trustpilot_vly.png new file mode 100644 index 0000000000..12f5b16a3e Binary files /dev/null and b/freebuff/web/public/vly/trustpilot_vly.png differ diff --git a/freebuff/web/public/vly/vc_logo/0ad.png b/freebuff/web/public/vly/vc_logo/0ad.png new file mode 100644 index 0000000000..6d39cefa04 Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/0ad.png differ diff --git a/freebuff/web/public/vly/vc_logo/468.png b/freebuff/web/public/vly/vc_logo/468.png new file mode 100644 index 0000000000..eef0b77e7b Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/468.png differ diff --git a/freebuff/web/public/vly/vc_logo/bluebrown.png b/freebuff/web/public/vly/vc_logo/bluebrown.png new file mode 100644 index 0000000000..1836f00b0e Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/bluebrown.png differ diff --git a/freebuff/web/public/vly/vc_logo/goodwater.png b/freebuff/web/public/vly/vc_logo/goodwater.png new file mode 100644 index 0000000000..79b09daf90 Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/goodwater.png differ diff --git a/freebuff/web/public/vly/vc_logo/olive.png b/freebuff/web/public/vly/vc_logo/olive.png new file mode 100644 index 0000000000..582944053e Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/olive.png differ diff --git a/freebuff/web/public/vly/vc_logo/treeo.png b/freebuff/web/public/vly/vc_logo/treeo.png new file mode 100644 index 0000000000..8a7f2e9110 Binary files /dev/null and b/freebuff/web/public/vly/vc_logo/treeo.png differ diff --git a/freebuff/web/public/vly/white-check-vly.png b/freebuff/web/public/vly/white-check-vly.png new file mode 100644 index 0000000000..39f410e4a3 Binary files /dev/null and b/freebuff/web/public/vly/white-check-vly.png differ diff --git a/freebuff/web/public/white-check-vly.png b/freebuff/web/public/white-check-vly.png new file mode 100644 index 0000000000..39f410e4a3 Binary files /dev/null and b/freebuff/web/public/white-check-vly.png differ diff --git a/freebuff/web/scripts/assemble-standalone.mjs b/freebuff/web/scripts/assemble-standalone.mjs new file mode 100644 index 0000000000..edca56892b --- /dev/null +++ b/freebuff/web/scripts/assemble-standalone.mjs @@ -0,0 +1,242 @@ +/** + * Assemble the Next.js `output: 'standalone'` bundle into a self-contained, + * runnable directory so the Render deploy artifact can drop the 3.9GB hoisted + * monorepo `node_modules` (see render-preview-deploy.ts prune step). + * + * Next's standalone output copies the traced server bundle but NOT: + * 1. `.next/static` and `public` (Next docs say to copy these yourself), and + * 2. runtime assets loaded via `__dirname`-string / `process.cwd()` / + * `existsSync` paths that the file tracer (@vercel/nft) cannot follow — + * the SDK's tree-sitter WASM + ripgrep binaries, geoip-country data, + * lz4-wasm, and the pino/doc-extraction packages' dynamic requires. + * + * We place the WASM + ripgrep assets in a known dir and point the launcher's + * CODEBUFF_WASM_DIR / CODEBUFF_RG_PATH env at them (see start-standalone.mjs), + * bypassing the SDK's fragile path resolution entirely. Other risky packages + * are copied wholesale into the standalone node_modules to guarantee their + * data/worker files survive. + * + * The script VERIFIES critical assets exist and exits non-zero if any are + * missing, so a broken artifact fails the build instead of shipping and 500ing + * at runtime. It never prunes anything (that's render-preview-deploy.ts) and is + * safe to run locally. + */ +import { access, cp, mkdir, readdir, stat } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' + +const webRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..') +const repoRoot = join(webRoot, '..', '..') +const rootNodeModules = join(repoRoot, 'node_modules') + +// With outputFileTracingRoot = monorepo root, the standalone tree mirrors the +// repo layout, so the server entry is nested under freebuff/web. +const standaloneRoot = join(webRoot, '.next', 'standalone') +const standaloneApp = join(standaloneRoot, 'freebuff', 'web') +const serverEntry = join(standaloneApp, 'server.js') + +const RUNTIME_ASSETS = join(standaloneRoot, '_runtime-assets') +const WASM_DEST = join(RUNTIME_ASSETS, 'wasm') +const RIPGREP_DEST = join(RUNTIME_ASSETS, 'ripgrep') + +function fail(msg) { + console.error(`[assemble-standalone] ERROR: ${msg}`) + process.exit(1) +} + +async function exists(p) { + try { + await access(p) + return true + } catch { + return false + } +} + +async function copyDir(from, to, { required = false } = {}) { + if (!(await exists(from))) { + if (required) fail(`expected source dir missing: ${from}`) + console.log(`[assemble-standalone] skip (absent): ${from}`) + return false + } + await mkdir(join(to, '..'), { recursive: true }) + await cp(from, to, { recursive: true, force: true, dereference: true }) + return true +} + +async function dirSize(path) { + let total = 0 + const entries = await readdir(path, { withFileTypes: true }).catch(() => []) + for (const e of entries) { + const child = join(path, e.name) + if (e.isDirectory()) total += await dirSize(child) + else if (e.isFile()) total += (await stat(child).catch(() => ({ size: 0 }))).size + } + return total +} + +if (!existsSync(serverEntry)) { + fail( + `standalone server entry not found at ${serverEntry}. ` + + `Did \`next build\` run with output: 'standalone'?`, + ) +} + +// 1. Static + public assets Next does not copy into standalone. +await copyDir(join(webRoot, '.next', 'static'), join(standaloneApp, '.next', 'static'), { + required: true, +}) +await copyDir(join(webRoot, 'public'), join(standaloneApp, 'public')) + +// 2. Tree-sitter WASM: merge SDK-vendored + @vscode/tree-sitter-wasm into one +// dir that CODEBUFF_WASM_DIR points at. SDK copy wins on overlap. +await mkdir(WASM_DEST, { recursive: true }) +const vscodeWasm = join(rootNodeModules, '@vscode', 'tree-sitter-wasm', 'wasm') +if (await exists(vscodeWasm)) await cp(vscodeWasm, WASM_DEST, { recursive: true, force: true }) +await copyDir(join(rootNodeModules, '@codebuff', 'sdk', 'dist', 'wasm'), WASM_DEST, { + required: true, +}) + +// 3. Ripgrep binaries (all vendored arches; ~20MB, trivial vs the GBs saved so +// the same artifact runs on Render linux and a local mac). +await copyDir( + join(rootNodeModules, '@codebuff', 'sdk', 'dist', 'vendor', 'ripgrep'), + RIPGREP_DEST, + { required: true }, +) + +// 4. Workspace packages in serverExternalPackages that Turbopack's standalone +// copier misses: it skips symlinked (workspace) externals entirely, so the +// runtime `import("@codebuff/sdk")` in the chat stream route has nothing to +// resolve once Render prunes the root node_modules (ERR_MODULE_NOT_FOUND in +// prod). Copy each package plus its transitive npm dependency closure. +const WORKSPACE_EXTERNALS = ['@codebuff/sdk'] + +async function readPkgJson(dir) { + try { + const { readFile } = await import('fs/promises') + return JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) + } catch { + return null + } +} + +/** Resolve a dependency's package dir: nested node_modules first, then root. */ +async function resolveDepDir(fromDir, dep) { + const nested = join(fromDir, 'node_modules', dep) + if (await exists(join(nested, 'package.json'))) return nested + const hoisted = join(rootNodeModules, dep) + if (await exists(join(hoisted, 'package.json'))) return hoisted + return null +} + +async function copyWithDependencyClosure(rootPkgs) { + const seen = new Set() + // queue of [packageName, sourceDir] + const queue = [] + for (const name of rootPkgs) { + const dir = join(rootNodeModules, name) + if (!(await exists(join(dir, 'package.json')))) { + fail(`workspace external missing from root node_modules: ${name}`) + } + queue.push([name, dir]) + } + while (queue.length) { + const [name, dir] = queue.shift() + if (seen.has(name)) continue + seen.add(name) + // dereference: true resolves workspace symlinks into real copies. + await cp(dir, join(standaloneNodeModules, name), { + recursive: true, + force: true, + dereference: true, + }) + const pkg = await readPkgJson(dir) + for (const dep of Object.keys(pkg?.dependencies ?? {})) { + if (seen.has(dep)) continue + const depDir = await resolveDepDir(dir, dep) + if (!depDir) { + console.log( + `[assemble-standalone] warn: dep ${dep} of ${name} not found; skipping`, + ) + continue + } + queue.push([dep, depDir]) + } + // Optional deps: copy when present, skip silently otherwise. + for (const dep of Object.keys(pkg?.optionalDependencies ?? {})) { + if (seen.has(dep)) continue + const depDir = await resolveDepDir(dir, dep) + if (depDir) queue.push([dep, depDir]) + } + } + console.log( + `[assemble-standalone] copied workspace externals + deps (${seen.size} packages)`, + ) +} + +// 5. Packages whose data/worker files the tracer misses — copy wholesale into +// the standalone node_modules so runtime __dirname resolution finds them. +const WHOLESALE_PACKAGES = [ + 'geoip-country', + 'lz4-wasm', + 'unpdf', + 'mammoth', + 'exceljs', + 'turndown', + 'turndown-plugin-gfm', + 'pino', + 'thread-stream', + 'pino-pretty', +] +const standaloneNodeModules = join(standaloneRoot, 'node_modules') +for (const pkg of WHOLESALE_PACKAGES) { + await copyDir(join(rootNodeModules, pkg), join(standaloneNodeModules, pkg)) +} + +await copyWithDependencyClosure(WORKSPACE_EXTERNALS) + +// 6. Verify the assets that would otherwise 500 at runtime actually landed. +const REQUIRED = [ + join(WASM_DEST, 'tree-sitter.wasm'), + join(WASM_DEST, 'tree-sitter-typescript.wasm'), + join(RIPGREP_DEST, 'x64-linux', 'rg'), + join(RIPGREP_DEST, 'arm64-linux', 'rg'), + join(standaloneNodeModules, 'geoip-country', 'data', 'geoip-country.dat'), + join(standaloneNodeModules, '@codebuff', 'sdk', 'dist', 'index.mjs'), +] +const missing = REQUIRED.filter((p) => !existsSync(p)) +if (missing.length) fail(`required runtime assets missing after copy:\n ${missing.join('\n ')}`) + +// 7. Prove the runtime `import("@codebuff/sdk")` actually resolves from inside +// the standalone tree (catches missing transitive deps, not just the entry +// file), using a child Node process rooted at the server's location. +const { execFileSync } = await import('child_process') +const verifySrc = ` + import { createRequire } from 'module' + const req = createRequire(process.cwd() + '/server.js') + const resolved = req.resolve('@codebuff/sdk/package.json') + if (!resolved.startsWith(${JSON.stringify(standaloneRoot)})) { + console.error('resolved OUTSIDE standalone tree: ' + resolved) + process.exit(1) + } + await import('@codebuff/sdk') +` +try { + execFileSync(process.execPath, ['--input-type=module', '-e', verifySrc], { + cwd: standaloneApp, + stdio: 'pipe', + }) + console.log('[assemble-standalone] verified: @codebuff/sdk imports from standalone') +} catch (err) { + fail( + `@codebuff/sdk failed to import from the standalone tree:\n` + + String(err.stderr ?? err), + ) +} + +const sizeMb = ((await dirSize(standaloneRoot)) / 1024 / 1024).toFixed(0) +console.log( + `[assemble-standalone] OK — standalone ready at ${standaloneRoot} (${sizeMb} MB)`, +) diff --git a/freebuff/web/scripts/cloud-typecheck-setup.sh b/freebuff/web/scripts/cloud-typecheck-setup.sh new file mode 100755 index 0000000000..1ce08a3e0a --- /dev/null +++ b/freebuff/web/scripts/cloud-typecheck-setup.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Minimal Freebuff Web typecheck setup for Cloud sandboxes (no Next dev/build). +# +# Run from the repo root after a shallow clone, e.g.: +# git clone --depth 1 --filter=blob:none --single-branch \ +# https://github.com/<org>/freebuff-private.git . +# bash freebuff/web/scripts/cloud-typecheck-setup.sh +# +# Expected disk (approximate on a 6 GB Cloud VM): +# shallow clone (blob filter) ~80–150 MB +# node_modules (filtered install) ~900 MB–1.4 GB +# sdk/dist ~60 MB +# total ~1.0–1.6 GB (vs ~5+ GB full monorepo install) +# +# Does NOT run `next build` — no `.next` output is created. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$ROOT" + +if ! command -v bun >/dev/null 2>&1; then + echo "error: bun is required (install via golden snapshot or https://bun.sh)" >&2 + exit 1 +fi + +echo "[cloud-typecheck-setup] installing @codebuff/freebuff-web workspace deps..." +# @codebuff/sdk source-imports @codebuff/agent-runtime, @codebuff/llm-providers, and +# @codebuff/code-map via tsconfig path aliases (baseUrl mapping to their src/), not as +# package.json dependencies. bun's --filter dependency graph only follows declared +# package.json deps, so those three workspaces must be filtered in explicitly or their +# own node_modules (e.g. gpt-tokenizer for agent-runtime) never get installed and the +# sdk build fails with "Could not resolve" errors. +bun install \ + --filter '@codebuff/freebuff-web' \ + --filter '@codebuff/agent-runtime' \ + --filter '@codebuff/llm-providers' \ + --filter '@codebuff/code-map' + +echo "[cloud-typecheck-setup] building @codebuff/sdk..." +(cd sdk && bun run build) + +NEXT_ENV="$ROOT/freebuff/web/next-env.d.ts" +if [[ ! -f "$NEXT_ENV" ]]; then + printf '/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n' >"$NEXT_ENV" +fi + +echo "[cloud-typecheck-setup] typechecking freebuff/web..." +# A filtered/hoisted install does not always create the local +# freebuff/web/node_modules/typescript symlink even though typescript is a declared +# devDependency there — fall back to the hoisted root copy if the local one is missing. +TSC_BIN="$ROOT/freebuff/web/node_modules/typescript/bin/tsc" +if [[ ! -f "$TSC_BIN" ]]; then + TSC_BIN="$ROOT/node_modules/typescript/bin/tsc" +fi +if [[ ! -f "$TSC_BIN" ]]; then + echo "error: could not find the typescript binary in freebuff/web/node_modules or root node_modules" >&2 + exit 1 +fi +(cd freebuff/web && node --max-old-space-size=4096 "$TSC_BIN" --noEmit -p .) + +echo "[cloud-typecheck-setup] done." +du -sh node_modules sdk/dist freebuff/web 2>/dev/null || true diff --git a/freebuff/web/scripts/deploy-golden-snapshots.ts b/freebuff/web/scripts/deploy-golden-snapshots.ts new file mode 100644 index 0000000000..6b49cf6dab --- /dev/null +++ b/freebuff/web/scripts/deploy-golden-snapshots.ts @@ -0,0 +1,196 @@ +import { join } from 'path' + +type BuildResult = { + snapshotId: string + recordId: string +} + +type DeployOptions = { + subject: string + email?: string + deployment?: string + prod: boolean +} + +const webRoot = join(import.meta.dir, '..') + +function parseArgs(argv: string[]) { + const getValue = (flag: string) => { + const item = argv.find((arg) => arg.startsWith(`${flag}=`)) + if (!item) return undefined + return item.slice(flag.length + 1) + } + + return { + subject: getValue('--subject') ?? process.env.FREEBUFF_ADMIN_SUBJECT, + email: getValue('--email') ?? process.env.FREEBUFF_ADMIN_EMAIL, + deployment: getValue('--deployment'), + prod: argv.includes('--prod'), + } +} + +function usage() { + return [ + 'Usage:', + ' bun run snapshot:deploy -- --subject=github:123 --email=me@example.com', + ' bun run snapshot:deploy -- --subject=github:123 --prod', + '', + 'Optional:', + ' --deployment=<name|reference> target Convex deployment', + ' --prod target project default production deployment', + '', + 'Environment fallback:', + ' FREEBUFF_ADMIN_SUBJECT, FREEBUFF_ADMIN_EMAIL', + ].join('\n') +} + +async function runConvex(functionName: string, args: object, options: DeployOptions) { + const identity = { + subject: options.subject, + ...(options.email ? { email: options.email } : {}), + } + + const command = [ + 'bunx', + 'convex', + 'run', + functionName, + JSON.stringify(args), + '--identity', + JSON.stringify(identity), + ] + + if (options.prod) { + command.push('--prod') + } + if (options.deployment) { + command.push('--deployment', options.deployment) + } + + console.log(`\n[snapshot:deploy] $ ${command.join(' ')}`) + + const proc = Bun.spawn(command, { + cwd: webRoot, + env: process.env, + stdout: 'pipe', + stderr: 'pipe', + }) + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + if (stderr.trim()) { + console.log(stderr.trim()) + } + + if (exitCode !== 0) { + throw new Error(`convex run failed (${functionName}):\n${stdout}\n${stderr}`) + } + + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + + try { + return JSON.parse(trimmed) + } catch { + const start = trimmed.indexOf('{') + const end = trimmed.lastIndexOf('}') + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) + } + return null + } +} + +async function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) { + console.log(usage()) + return + } + + const parsed = parseArgs(argv) + if (!parsed.subject) { + throw new Error(`Missing admin subject.\n\n${usage()}`) + } + + const options: DeployOptions = { + subject: parsed.subject, + email: parsed.email, + deployment: parsed.deployment, + prod: parsed.prod, + } + + console.log('[snapshot:deploy] building Cloud standard snapshot (6 GB)') + const cloudStandard = (await runConvex( + 'admin/snapshots:buildGoldenSnapshot', + { tier: 'cloud_standard' }, + options, + )) as BuildResult + + if (!cloudStandard?.recordId || !cloudStandard?.snapshotId) { + throw new Error('Failed to parse Cloud standard build result') + } + + console.log('[snapshot:deploy] building Web standard snapshot (4 GB)') + const webStandard = (await runConvex( + 'admin/snapshots:buildGoldenSnapshot', + { tier: 'web_standard' }, + options, + )) as BuildResult + + if (!webStandard?.recordId || !webStandard?.snapshotId) { + throw new Error('Failed to parse Web standard build result') + } + + console.log('[snapshot:deploy] building small snapshot (4 GB)') + const small = (await runConvex( + 'admin/snapshots:buildGoldenSnapshot', + { tier: 'small' }, + options, + )) as BuildResult + + if (!small?.recordId || !small?.snapshotId) { + throw new Error('Failed to parse small build result') + } + + // Only the Cloud primaries are promoted. The Web standard snapshot is wired + // via the DAYTONA_SNAPSHOT_ID env var instead (see logs below). + console.log('[snapshot:deploy] promoting Cloud standard snapshot') + await runConvex( + 'admin/snapshot_mutations:promoteSnapshotToPrimary', + { id: cloudStandard.recordId }, + options, + ) + + console.log('[snapshot:deploy] promoting small snapshot') + await runConvex( + 'admin/snapshot_mutations:promoteSnapshotToPrimary', + { id: small.recordId }, + options, + ) + + console.log('\n[snapshot:deploy] done') + console.log( + `[snapshot:deploy] Cloud standard (primary): ${cloudStandard.snapshotId} (${cloudStandard.recordId})`, + ) + console.log( + `[snapshot:deploy] small (primary): ${small.snapshotId} (${small.recordId})`, + ) + console.log( + `[snapshot:deploy] Web standard (NOT promoted): ${webStandard.snapshotId} (${webStandard.recordId})`, + ) + console.log( + '\n[snapshot:deploy] Next steps for Freebuff Web pool:\n' + + ` - Set DAYTONA_SNAPSHOT_ID=${webStandard.snapshotId}\n` + + ` - Set DAYTONA_SNAPSHOT_SMALL_ID=${small.snapshotId}\n` + + ' - Flush the project pool so new pooled sandboxes use them.', + ) +} + +await main() diff --git a/freebuff/web/scripts/generate-app-builder-blog-posts.ts b/freebuff/web/scripts/generate-app-builder-blog-posts.ts new file mode 100644 index 0000000000..d07557c09d --- /dev/null +++ b/freebuff/web/scripts/generate-app-builder-blog-posts.ts @@ -0,0 +1,788 @@ +/** + * One-off generator for DesignArena competitor comparison + savings blog posts. + * Run: bun freebuff/web/scripts/generate-app-builder-blog-posts.ts + */ +import { mkdirSync, writeFileSync } from 'fs' +import { join } from 'path' + +const POSTS_DIR = join(import.meta.dir, '../src/lib/blog/posts') + +interface Competitor { + id: string + name: string + displayName: string + middleTierName: string + middleTierMonthly: number + priceRange: string + pricingNote: string + productUrl: string + whatItDoes: string + whenTheyWin: string + migrationNote: string + extraTldr?: string[] + compareRows: Array<{ feature: string; freebuff: string; competitor: string }> + keywords: string[] + /** If true, only generate savings post (comparison already exists). */ + comparisonExists?: boolean + /** Special framing for shutdown etc. */ + specialNote?: string +} + +const competitors: Competitor[] = [ + { + id: 'replit', + name: 'Replit', + displayName: 'Replit Agent', + middleTierName: 'Replit Core', + middleTierMonthly: 25, + priceRange: '$25/mo (Core)', + pricingNote: + 'Replit Core is $25/mo on monthly billing ($20/mo billed annually). Agent and Design Mode features sit behind paid plans; heavier use pushes many teams toward Replit Pro at $100/mo.', + productUrl: 'https://replit.com/pricing', + whatItDoes: + 'Replit Agent (and Replit Design Mode) turn natural-language prompts into runnable apps inside a cloud IDE, with deploys, databases, and multiplayer editing.', + whenTheyWin: + 'Replit still wins for classrooms, multiplayer pair programming, and teams already deep in the Replit ecosystem.', + migrationNote: 'Export your repl to GitHub, then import the repo into Freebuff Web.', + comparisonExists: true, + keywords: ['replit savings', 'switch from replit', 'replit alternative free'], + compareRows: [], + }, + { + id: 'emergent', + name: 'Emergent', + displayName: 'Emergent', + middleTierName: 'Emergent Pro', + middleTierMonthly: 50, + priceRange: '$50/mo (typical paid tier)', + pricingNote: + 'Emergent paid plans commonly land between $25/mo and $99/mo depending on credits. Many active builders report spending around $50/mo once they move past the free tier.', + productUrl: 'https://emergent.sh', + whatItDoes: + 'Emergent is an agentic full-stack builder: describe an app, watch agents scaffold auth, database, and deployable code.', + whenTheyWin: + 'Emergent has deeper SaaS-shaped templates (billing, admin dashboards, multi-tenant auth) out of the box.', + migrationNote: 'Export to GitHub from Emergent and import into Freebuff Web.', + comparisonExists: true, + keywords: ['emergent savings', 'switch from emergent', 'emergent.sh free alternative'], + compareRows: [], + }, + { + id: 'anything', + name: 'Anything', + displayName: 'Anything', + middleTierName: 'Anything Pro', + middleTierMonthly: 24, + priceRange: '$24/mo (Pro, monthly billing)', + pricingNote: + 'Anything Pro is $19/mo billed annually or $24/mo billed monthly for 20,000 monthly credits. The Max tier jumps to $199–$239/mo for parallel agents and higher credit pools (per [anything.com pricing docs](https://www.anything.com/docs/account/subscriptions)).', + productUrl: 'https://anything.com/pricing', + whatItDoes: + 'Anything (createanything.com) builds web and mobile apps from prompts, with Stripe/RevenueCat payments, custom domains, and App Store publishing on paid tiers.', + whenTheyWin: + 'Anything is strong if you need native mobile + web from one project, or you are migrating from Mocha with their one-click import.', + migrationNote: 'Export to GitHub from Anything, or start fresh in Freebuff Web with the same prompt.', + keywords: [ + 'free anything alternative', + 'anything.com free', + 'createanything alternative', + 'anything vs freebuff', + 'anything app builder free', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$24/mo Pro ($199/mo Max)' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — monthly credits' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Wired in' }, + { feature: 'Deployed URL', freebuff: 'Yes, free', competitor: 'Yes (paid for private)' }, + { feature: 'Custom domain', freebuff: 'Yes', competitor: 'Pro plan and above' }, + { feature: 'Eject to GitHub', freebuff: 'One click', competitor: 'Yes' }, + { feature: 'Paired CLI agent', freebuff: 'Yes (Freebuff CLI)', competitor: 'No' }, + { feature: 'Mobile app publishing', freebuff: 'Web-first (PWA)', competitor: 'App Store on Pro+' }, + ], + }, + { + id: 'lovable', + name: 'Lovable', + displayName: 'Lovable', + middleTierName: 'Lovable Business', + middleTierMonthly: 50, + priceRange: '$50/mo (Business)', + pricingNote: + 'Lovable Pro starts at $25/mo for 100 credits. Business is $50/mo with SSO and team features. Credit tiers scale to $100/mo (400 credits) and beyond for heavy iteration (per [lovable.dev pricing](https://lovable.dev/pricing)).', + productUrl: 'https://lovable.dev/pricing', + whatItDoes: + 'Lovable.dev is the archetypal vibe-coding app builder: prompt → React + Supabase full-stack app → deployed URL.', + whenTheyWin: + 'Lovable has a mature template gallery and tight Supabase integrations for teams already on that stack.', + migrationNote: 'Export to GitHub from Lovable, then import into Freebuff Web.', + comparisonExists: true, + keywords: ['lovable savings', 'switch from lovable', 'lovable.dev free alternative'], + compareRows: [], + }, + { + id: 'floot', + name: 'Floot', + displayName: 'Floot', + middleTierName: 'Floot paid tier', + middleTierMonthly: 25, + priceRange: '~$25/mo (estimated paid tier)', + pricingNote: + 'Floot launched with a free preview; paid tiers are rolling out. Budget for a typical AI app-builder subscription ($20–$50/mo) once you need production features beyond the preview.', + productUrl: 'https://floot.com', + whatItDoes: + 'Floot is an agentic web app builder with a clean prompt-to-repo workflow.', + whenTheyWin: + 'Floot is worth watching if their template library or agent UX fits your workflow during preview.', + migrationNote: 'Export your repo to GitHub and import into Freebuff Web.', + comparisonExists: true, + keywords: ['floot savings', 'switch from floot', 'floot alternative free'], + compareRows: [], + }, + { + id: 'magic-patterns', + name: 'Magic Patterns', + displayName: 'Magic Patterns', + middleTierName: 'Magic Patterns Business', + middleTierMonthly: 100, + priceRange: '$100/seat/mo (Business)', + pricingNote: + 'Magic Patterns Starter is $20/seat/mo (1,000 credits). Business is $100/seat/mo (5,000 credits). On-demand usage bills at $0.02/credit after limits (per [Magic Patterns billing docs](https://magicpatterns.mintlify.app/documentation/get-started/credits-and-billing)).', + productUrl: 'https://www.magicpatterns.com/pricing', + whatItDoes: + 'Magic Patterns generates UI prototypes and component libraries from prompts — popular with product teams moving from Figma to code.', + whenTheyWin: + 'Magic Patterns wins for design-system-aware UI generation inside a product-team workflow with Figma export.', + migrationNote: + 'Export generated code from Magic Patterns and import the GitHub repo into Freebuff Web for full-stack wiring.', + keywords: [ + 'free magic patterns alternative', + 'magic patterns free', + 'magicpatterns alternative', + 'magic patterns vs freebuff', + 'free ai prototype builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$100/seat/mo (Business)' }, + { feature: 'Per-seat billing', freebuff: 'No', competitor: 'Yes' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — usage-based credits' }, + { feature: 'Full-stack app (auth + DB)', freebuff: 'Wired in', competitor: 'Prototype-focused' }, + { feature: 'Deployed production URL', freebuff: 'Yes, free', competitor: 'Export / hand-off' }, + { feature: 'Figma export', freebuff: 'Via import', competitor: 'Native' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'orchids', + name: 'Orchids', + displayName: 'Orchids', + middleTierName: 'Orchids Premium', + middleTierMonthly: 50, + priceRange: '$50/mo (Premium, monthly billing)', + pricingNote: + 'Orchids Premium is $42/mo billed annually or $50/mo billed monthly with 4M monthly credits and unlimited projects. Ultra is $99/mo; Max is $200/mo (per [Orchids plans docs](https://docs.orchids.app/plans-and-token-usage)).', + productUrl: 'https://orchids.app', + whatItDoes: + 'Orchids (YC W25) is a design-first AI website and app builder with strong visual defaults and analytics.', + whenTheyWin: + 'Orchids excels at polished marketing sites and portfolios where design quality is the primary constraint.', + migrationNote: 'Export to GitHub from Orchids and import into Freebuff Web.', + keywords: [ + 'free orchids alternative', + 'orchids ai free', + 'orchids.app alternative', + 'orchids vs freebuff', + 'free ai website builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$50/mo Premium' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — monthly credits' }, + { feature: 'Free tier', freebuff: 'Full product', competitor: '100k credits, 3 projects' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Wired in' }, + { feature: 'Custom domain', freebuff: 'Yes', competitor: 'Paid plans' }, + { feature: 'Eject to GitHub', freebuff: 'One click', competitor: 'Yes' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'same-new', + name: 'Same.new', + displayName: 'Same.new', + middleTierName: 'Same.new Max', + middleTierMonthly: 50, + priceRange: '$50/mo (Max)', + pricingNote: + 'Same.new tiers: Free (500k tokens), Basic $10/mo, Pro $25/mo, Max $50/mo, Ultra $100/mo. Ultra adds pay-as-you-go beyond 20M tokens at $10 per 2M (per [Same.new pricing docs](https://docs.same.new/usage/pricing)).', + productUrl: 'https://same.new', + whatItDoes: + 'Same.new generates full-stack web apps from prompts with remix/download flows on paid plans.', + whenTheyWin: + 'Same.new is a solid pick for fast MVPs when you want predictable token tiers and a simple upgrade path.', + migrationNote: 'Download your project on a paid plan and import the repo into Freebuff Web.', + keywords: [ + 'free same.new alternative', + 'same.new free', + 'same new alternative', + 'same.new vs freebuff', + 'free ai app builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$50/mo (Max)' }, + { feature: 'Token meter', freebuff: 'None', competitor: 'Yes — fixed monthly tiers' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Varies by stack' }, + { feature: 'Project download', freebuff: 'GitHub eject, free', competitor: 'Paid plans' }, + { feature: 'Deployed URL', freebuff: 'Yes, free', competitor: 'Yes' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'base44', + name: 'Base44', + displayName: 'Base44', + middleTierName: 'Base44 Pro', + middleTierMonthly: 80, + priceRange: '$80/mo (Pro, annual billing)', + pricingNote: + 'Base44 Builder is $40/mo (annual) with 250 message credits. Pro is $80/mo with 500 message credits plus integrations. Elite is $160/mo (per [base44.com/pricing](https://www.base44.com/pricing)).', + productUrl: 'https://www.base44.com/pricing', + whatItDoes: + 'Base44 generates internal tools and full apps from prompts with built-in auth, database, payments, and email.', + whenTheyWin: + 'Base44 is strong for no-code operators who want payments and email marketing bundled into the builder.', + migrationNote: 'Use GitHub integration in Base44, then import into Freebuff Web.', + comparisonExists: true, + keywords: ['base44 savings', 'switch from base44', 'base44 free alternative'], + compareRows: [], + }, + { + id: 'figma-make', + name: 'Figma Make', + displayName: 'Figma Make', + middleTierName: 'Figma Professional Full seat', + middleTierMonthly: 20, + priceRange: '$20/mo (Professional Full seat)', + pricingNote: + 'Figma Make is bundled into Figma seat pricing — not sold separately. A Professional Full seat is ~$16–20/mo (annual vs monthly) with 3,000 AI credits/mo shared across Figma AI tools. Organization Full is $55/seat/mo (per [figma.com/pricing](https://www.figma.com/pricing)).', + productUrl: 'https://www.figma.com/pricing', + whatItDoes: + 'Figma Make turns designs and prompts into functional web apps inside Figma, with code export and beta publishing.', + whenTheyWin: + 'Figma Make wins when your team already pays for Figma Full seats and wants app experiments adjacent to design files.', + migrationNote: + 'Export code from Figma Make, push to GitHub, and import into Freebuff Web for auth, database, and hosting.', + keywords: [ + 'free figma make alternative', + 'figma make free', + 'figma make alternative', + 'figma make vs freebuff', + 'free ai app builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$20/mo+ (Figma Full seat)' }, + { feature: 'Requires Figma subscription', freebuff: 'No', competitor: 'Yes for production use' }, + { feature: 'AI credit pool', freebuff: 'None — no meter', competitor: '3,000 credits/mo (Pro Full)' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Add backend manually' }, + { feature: 'Standalone deployed URL', freebuff: 'Yes, free', competitor: 'Beta publishing in Figma' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'macaly', + name: 'Macaly', + displayName: 'Macaly', + middleTierName: 'Macaly Pro', + middleTierMonthly: 25, + priceRange: '$25/mo (Pro)', + pricingNote: + 'Macaly Pro is $25/mo with custom domains, code access, and monthly AI credits. Enterprise is custom. A $5/mo Hosting-only plan keeps domains live without AI credits (per [macaly.com/pricing](https://www.macaly.com/pricing)).', + productUrl: 'https://www.macaly.com/pricing', + whatItDoes: + 'Macaly builds websites, dashboards, and web apps from natural-language prompts with hosting and database included.', + whenTheyWin: + 'Macaly is approachable for founders and small businesses who want a guided builder with SEO tooling built in.', + migrationNote: 'Export code from Macaly Pro and import the repo into Freebuff Web.', + keywords: [ + 'free macaly alternative', + 'macaly free', + 'macaly.com alternative', + 'macaly vs freebuff', + 'free ai website builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$25/mo (Pro)' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — monthly credits' }, + { feature: 'Custom domain', freebuff: 'Yes', competitor: 'Pro plan' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Included' }, + { feature: 'Code access', freebuff: 'Full repo', competitor: 'Pro plan' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'mocha', + name: 'Mocha', + displayName: 'Mocha', + middleTierName: 'Mocha Silver', + middleTierMonthly: 50, + priceRange: '~$50/mo (Silver, third-party reports)', + pricingNote: + 'Mocha is shutting down August 1, 2026. Official docs list Bronze/Silver/Gold tiers by credits but not public dollar amounts; third-party pricing guides commonly cite ~$20/mo Bronze, ~$50/mo Silver, ~$200/mo Gold. Verify in-app before subscribing.', + productUrl: 'https://getmocha.com', + whatItDoes: + 'Mocha built full-stack web apps from prompts with custom domains and credit-based plans.', + whenTheyWin: + 'Mocha is no longer a long-term pick — the team recommends migrating to Anything before the August 2026 shutdown.', + migrationNote: + 'Use Mocha Settings → Export, or migrate to Freebuff Web via GitHub import. Do not wait until August 2026.', + specialNote: + 'Mocha announced a permanent shutdown on August 1, 2026. Treat this as a migration guide, not a long-term vendor comparison.', + keywords: [ + 'free mocha alternative', + 'mocha getmocha alternative', + 'mocha shutdown alternative', + 'mocha vs freebuff', + 'migrate from mocha', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: 'Paid tiers (~$20–$200/mo)' }, + { feature: 'Platform status', freebuff: 'Active', competitor: 'Shutting down Aug 2026' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Wired in' }, + { feature: 'Custom domain', freebuff: 'Yes', competitor: 'Paid tiers' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'v0', + name: 'v0', + displayName: 'v0', + middleTierName: 'v0 Team', + middleTierMonthly: 30, + priceRange: '$30/user/mo (Team)', + pricingNote: + 'v0 Team is $30/user/mo with $30 in monthly credits per user. Business is $100/user/mo. The legacy Premium ($20/mo) plan is being sunset for new users (per [v0.app pricing docs](https://v0.app/docs/pricing)).', + productUrl: 'https://v0.app/docs/pricing', + whatItDoes: + 'v0 by Vercel generates React/Next.js UI and full-stack apps from prompts, with Vercel deployment integration.', + whenTheyWin: + 'v0 wins for teams already on Vercel who want shadcn-aligned components and tight deploy pipelines.', + migrationNote: 'Push your v0 project to GitHub and import into Freebuff Web.', + keywords: [ + 'free v0 alternative', + 'v0 vercel free', + 'v0.dev alternative', + 'v0 vs freebuff', + 'free vercel v0 alternative', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$30/user/mo (Team)' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — token-based credits' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Manual / add-ons' }, + { feature: 'Next.js + shadcn output', freebuff: 'Yes', competitor: 'Native specialty' }, + { feature: 'Vercel deploy integration', freebuff: 'Any host', competitor: 'Native Vercel' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'cosmic', + name: 'Cosmic', + displayName: 'Cosmic.new', + middleTierName: 'Cosmic.new Pro', + middleTierMonthly: 30, + priceRange: '$29.99/mo (Pro)', + pricingNote: + 'Cosmic.new Pro is $29.99/mo. Enterprise is $199.99/mo for large teams (per [cosmic.new](https://www.cosmic.new/)). API access is listed at $29.99/mo (reduced from $49.99).', + productUrl: 'https://www.cosmic.new/', + whatItDoes: + 'Cosmic.new is an all-in-one AI platform for websites, shops, and SaaS — with built-in auth, database, payments, and one-click deploy.', + whenTheyWin: + 'Cosmic.new is compelling if you want integrated payments and storefront tooling in one branded platform.', + migrationNote: 'Export your project code and import into Freebuff Web via GitHub.', + keywords: [ + 'free cosmic.new alternative', + 'cosmic.new free', + 'cosmic new alternative', + 'cosmic.new vs freebuff', + 'free ai saas builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$29.99/mo Pro ($199.99 Enterprise)' }, + { feature: 'Enterprise tier', freebuff: 'N/A — free', competitor: '$199.99/mo' }, + { feature: 'Built-in payments', freebuff: 'Via integrations', competitor: 'Cosmic Payments native' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Cosmic Auth + DB' }, + { feature: 'One-click deploy', freebuff: 'Yes, free', competitor: 'Yes (Pro+)' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'bolt', + name: 'Bolt', + displayName: 'Bolt.new', + middleTierName: 'Bolt.new Max', + middleTierMonthly: 50, + priceRange: '$50/mo (Max)', + pricingNote: + 'Bolt Pro is $25/mo (10M tokens). Max is $50/mo; Ultra is $100/mo on monthly token tiers (per Bolt.new pricing as of 2026).', + productUrl: 'https://bolt.new', + whatItDoes: + 'Bolt.new runs full-stack apps in WebContainers from a single prompt, with Netlify deploy integration.', + whenTheyWin: + 'Bolt is great for throwaway prototypes you want running in seconds inside the browser.', + migrationNote: 'Export to GitHub from Bolt and import into Freebuff Web.', + comparisonExists: true, + keywords: ['bolt.new savings', 'switch from bolt', 'bolt free alternative'], + compareRows: [], + }, + { + id: 'google-ai-studio', + name: 'Google AI Studio', + displayName: 'Google AI Studio', + middleTierName: 'Google AI Pro', + middleTierMonthly: 20, + priceRange: '$19.99/mo (Google AI Pro)', + pricingNote: + 'Google AI Studio itself is free for prototyping with Flash models. Pro-tier models in the UI require Google AI Pro at $19.99/mo, or paid Gemini API usage via Cloud Billing (per-token, often $20–$100+/mo for active builders).', + productUrl: 'https://aistudio.google.com', + whatItDoes: + 'Google AI Studio lets you prototype Gemini-powered apps and agents in the browser, then export to code and APIs.', + whenTheyWin: + 'Google AI Studio wins when you are already in the Google Cloud ecosystem and want raw API access to Gemini models.', + migrationNote: + 'Export generated code to GitHub and import into Freebuff Web for auth, database, hosting, and iteration without API billing.', + keywords: [ + 'free google ai studio alternative', + 'google ai studio app builder free', + 'gemini app builder free', + 'google ai studio vs freebuff', + 'free ai app builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$0 prototype; ~$20/mo+ for Pro models' }, + { feature: 'Full-stack app out of the box', freebuff: 'Yes', competitor: 'Prototype / API-first' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'You wire it up' }, + { feature: 'Deployed URL', freebuff: 'Yes, free', competitor: 'Separate hosting + API costs' }, + { feature: 'Credit / token meter', freebuff: 'None', competitor: 'Yes on paid API' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'aura', + name: 'Aura', + displayName: 'Aura', + middleTierName: 'Aura Max', + middleTierMonthly: 50, + priceRange: '$50/mo (Max)', + pricingNote: + 'Aura Pro is $25/mo (120 prompts/mo). Max is $50/mo (240 prompts/mo). Ultra is $100/mo (560 prompts/mo) per [aura.build/pricing](https://www.aura.build/pricing). Annual plans are 50% off while subscribed.', + productUrl: 'https://www.aura.build/pricing', + whatItDoes: + 'Aura (aura.build) is a design-first AI website builder with premium templates, Figma export, and CMS features.', + whenTheyWin: + 'Aura wins for visually polished marketing sites where design templates and Figma handoff matter more than backend depth.', + migrationNote: 'Export HTML or push code to GitHub, then import into Freebuff Web for backend features.', + keywords: [ + 'free aura alternative', + 'aura.build free', + 'aura ai website builder alternative', + 'aura vs freebuff', + 'free ai landing page builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$50/mo (Max)' }, + { feature: 'Prompt/message limits', freebuff: 'None', competitor: '240/mo on Max' }, + { feature: 'Full-stack backend', freebuff: 'Wired in', competitor: 'CMS-focused' }, + { feature: 'Figma export', freebuff: 'Via import', competitor: 'Native on paid' }, + { feature: 'Custom domain', freebuff: 'Yes', competitor: 'Paid plans' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, + { + id: 'canva', + name: 'Canva', + displayName: 'Canva Code', + middleTierName: 'Canva Business', + middleTierMonthly: 21, + priceRange: '$21/mo (Business, annual)', + pricingNote: + 'Canva Code is a Premium AI tool inside Canva — not sold separately. Canva Pro is ~$12/mo ($144/yr) with up to 200 Premium AI uses/mo. Business is $250/person/yr (~$21/mo) with up to 400 Premium AI uses/mo (per [canva.com/help/ai-access](https://www.canva.com/help/ai-access)).', + productUrl: 'https://www.canva.com/pricing', + whatItDoes: + 'Canva Code generates interactive HTML/CSS/JS widgets and mini-apps inside the Canva design platform.', + whenTheyWin: + 'Canva Code wins for marketers embedding calculators and widgets inside Canva presentations and sites.', + migrationNote: + 'Export widget code from Canva Code and rebuild as a full app in Freebuff Web if you need auth, database, or standalone hosting.', + keywords: [ + 'free canva code alternative', + 'canva code free', + 'canva ai app builder alternative', + 'canva code vs freebuff', + 'free interactive widget builder', + ], + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '~$21/mo (Business) for AI allowance' }, + { feature: 'Standalone full-stack app', freebuff: 'Yes', competitor: 'Widgets inside Canva' }, + { feature: 'Auth + database', freebuff: 'Wired in', competitor: 'Not included' }, + { feature: 'AI usage limits', freebuff: 'None', competitor: '200–400 Premium AI uses/mo' }, + { feature: 'Custom domain hosting', freebuff: 'Yes', competitor: 'Canva Sites ecosystem' }, + { feature: 'Paired CLI agent', freebuff: 'Yes', competitor: 'No' }, + ], + }, +] + +function esc(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n') +} + +function formatRows(rows: Competitor['compareRows']): string { + return rows + .map( + (r) => + ` { feature: '${esc(r.feature)}', freebuff: '${esc(r.freebuff)}', competitor: '${esc(r.competitor)}' },`, + ) + .join('\n') +} + +function comparisonPost(c: Competitor): string { + const slug = `free-alternative-to-${c.id}` + const extraTldr = (c.extraTldr ?? []).map((t) => ` '${esc(t)}',`).join('\n') + const shutdownBlock = c.specialNote + ? ` + { + type: 'callout', + tone: 'warning', + title: 'Important: platform status', + text: '${esc(c.specialNote)}', + },` + : '' + + return `import type { Post } from '../types' + +export const post: Post = { + slug: '${slug}', + title: 'The free alternative to ${esc(c.displayName)}', + subtitle: 'Same prompt-to-deployed-app loop — without the ${c.priceRange} bill.', + description: + 'Freebuff Web is the free alternative to ${esc(c.displayName)}. Build full-stack apps with auth, database, and hosting included — 100% free, no credit meter.', + category: 'Comparisons', + publishedAt: '2026-06-24', + readingMinutes: 7, + authorId: 'victor-cheng', + keywords: ${JSON.stringify(c.keywords, null, 4).replace(/\n/g, '\n ')}, + body: [ + { + type: 'tldr', + items: [ + 'Freebuff Web is a free alternative to ${esc(c.displayName)} — prompt to deployed full-stack app.', + '${esc(c.displayName)} typical paid tier: ${esc(c.priceRange)}. Freebuff Web is $0.', + 'Auth, database, file storage, hosting, and a paired CLI are included.', + 'You own the code — eject to GitHub any time.', +${extraTldr} + ], + }, + { + type: 'lede', + text: '${esc(c.whatItDoes)} Freebuff Web does the same job without a subscription or credit meter.', + }, + { type: 'h2', text: 'What ${esc(c.displayName)} costs in 2026' }, + { + type: 'p', + text: '${esc(c.pricingNote)} Most builders who iterate daily land on a paid tier quickly once the free allowance runs out.', + }, + { type: 'h2', text: 'Feature-by-feature: Freebuff Web vs ${esc(c.displayName)}' }, + { + type: 'compare', + competitor: '${esc(c.name)}', + rows: [ +${formatRows(c.compareRows)} + ], + }, + { type: 'h2', text: 'When ${esc(c.displayName)} is still the better pick' }, + { + type: 'p', + text: '${esc(c.whenTheyWin)}', + }, + { type: 'h2', text: 'How to move from ${esc(c.displayName)} to Freebuff' }, + { + type: 'ol', + items: [ + '${esc(c.migrationNote)}', + 'In Freebuff Web, click Import from GitHub and paste the repo URL (or start a fresh project with the same prompt).', + 'Freebuff wires up auth, database, and a deployed URL automatically.', + 'Keep iterating in the browser, or eject and run \`freebuff\` in your terminal for heavy refactors.', + ], + },${shutdownBlock} + { + type: 'callout', + tone: 'info', + title: 'No lock-in', + text: 'Freebuff projects are vanilla TypeScript repos. Host on Vercel, Cloudflare, or anywhere. We give you a free URL by default.', + }, + { + type: 'cta', + title: 'Try the free alternative to ${esc(c.displayName)}', + description: 'Ship a deployed full-stack app in minutes — $0/month.', + href: '/', + label: 'Open Freebuff Web', + }, + { + type: 'faq', + items: [ + { + q: 'Is Freebuff Web really free?', + a: 'Yes. No per-prompt credits, no daily generation cap for normal use. Auth, database, and hosting are included.', + }, + { + q: 'Can Freebuff do everything ${esc(c.displayName)} does?', + a: 'For the core loop — prompt, iterate, deploy a real app with auth and database — yes. Niche integrations may differ; you can always import code and extend in the Freebuff CLI.', + }, + { + q: 'How much does ${esc(c.displayName)} cost per year on a typical paid plan?', + a: 'At ${esc(c.priceRange)}, expect roughly $${c.middleTierMonthly * 12}/year before overages. Freebuff is $0.', + }, + { + q: 'Can I import my existing ${esc(c.displayName)} project?', + a: 'Yes — export to GitHub from ${esc(c.displayName)} (where supported) and import into Freebuff Web. We auto-detect the stack.', + }, + ], + }, + ], +} +` +} + +function savingsPost(c: Competitor): string { + const annual = c.middleTierMonthly * 12 + const slug = `how-one-${c.id}-user-saved-${annual}-switching-to-freebuff` + const comparisonSlug = `free-alternative-to-${c.id}` + const persona = + c.id === 'mocha' + ? 'a solo founder migrating before the August 2026 shutdown' + : c.id === 'figma-make' || c.id === 'canva' + ? 'a marketer shipping client landing pages' + : c.id === 'magic-patterns' || c.id === 'aura' + ? 'a freelance designer building client sites' + : 'an indie hacker shipping side projects' + + const extraKeywords = c.keywords.slice(0, 3).map((k) => ` '${esc(k)}',`).join('\n') + + return `import type { Post } from '../types' + +export const post: Post = { + slug: '${slug}', + title: 'How one ${esc(c.displayName)} user saved $${annual} by switching to Freebuff', + subtitle: 'Same shipped apps. $${c.middleTierMonthly}/mo ${esc(c.middleTierName)} bill → $0.', + description: + 'A real-world savings breakdown: what ${esc(c.displayName)} costs on ${esc(c.middleTierName)} ($${c.middleTierMonthly}/mo) vs Freebuff Web ($0), and how ${persona} kept shipping.', + category: 'Community', + publishedAt: '2026-06-24', + readingMinutes: 5, + authorId: 'freebuff-team', + keywords: [ + '${c.id} savings', + 'switch from ${c.id} to freebuff', + '${c.id} pricing', + '${c.id} vs freebuff cost', + 'free ${c.id} alternative', +${extraKeywords} + ], + body: [ + { + type: 'tldr', + items: [ + '${esc(c.middleTierName)} on ${esc(c.displayName)}: about $${c.middleTierMonthly}/month ($${annual}/year).', + 'Freebuff Web: $0/month for the same prompt → deployed app loop with auth, database, and hosting.', + 'Net savings: $${annual}/year before credit top-ups or seat multipliers.', + 'This is a modeled example for a typical solo builder — your mileage depends on usage.', + ], + }, + { + type: 'lede', + text: 'We talk to builders every week who like ${esc(c.displayName)} but hate watching credits disappear. Here is a straightforward math story for ${persona} — not a fabricated testimonial, but the kind of switch we see in practice.', + }, + { type: 'h2', text: 'The bill on ${esc(c.middleTierName)}' }, + { + type: 'p', + text: '${esc(c.pricingNote)} For this example we use the middle paid tier most solo builders aim for: **${esc(c.middleTierName)} at $${c.middleTierMonthly}/month**, or **$${annual}/year**.', + }, + { + type: 'compare', + competitor: '${esc(c.middleTierName)}', + rows: [ + { feature: 'Monthly subscription', freebuff: '$0', competitor: '$${c.middleTierMonthly}' }, + { feature: 'Annual cost', freebuff: '$0', competitor: '$${annual}' }, + { feature: 'Credit / token overages', freebuff: 'None', competitor: 'Common on active projects' }, + { feature: 'Auth + database + hosting', freebuff: 'Included', competitor: 'Included (varies by plan)' }, + { feature: 'CLI for big refactors', freebuff: 'Freebuff CLI', competitor: 'Not included' }, + ], + }, + { type: 'h2', text: 'What changed after switching' }, + { + type: 'ul', + items: [ + '**Same workflow.** Prompt in the browser, get a deployed URL, click to iterate.', + '**No rationing.** Heavy debugging weekends do not burn a credit balance.', + '**GitHub eject any time.** The repo is yours; keep editing in Freebuff CLI locally.', + '**$${annual}/year back.** That covers domains, email, or a connected ChatGPT subscription for hard turns.', + ], + }, + { type: 'h2', text: 'Three-step migration' }, + { + type: 'ol', + items: [ + '${esc(c.migrationNote)}', + 'Import the repo into Freebuff Web (or recreate the app with your best prompt).', + 'Cancel ${esc(c.displayName)} once you have verified the Freebuff deploy — welcome to $0/month.', + ], + }, + { + type: 'callout', + tone: 'success', + title: 'Want the full feature comparison?', + text: 'Read [The free alternative to ${esc(c.displayName)}](/blog/${comparisonSlug}) for an honest side-by-side.', + }, + { + type: 'cta', + title: 'Stop paying $${c.middleTierMonthly}/mo', + description: 'Open Freebuff Web and ship your next app for free.', + href: '/', + label: 'Open Freebuff Web', + }, + { + type: 'faq', + items: [ + { + q: 'Is $${annual}/year realistic?', + a: 'It is the base subscription for ${esc(c.middleTierName)}. Many users pay more after credit top-ups, extra seats, or higher tiers.', + }, + { + q: 'Will I lose features?', + a: 'You keep auth, database, hosting, and code ownership. Some vendor-specific integrations may need re-wiring — usually a one-time CLI task.', + }, + { + q: 'Can teams use Freebuff for free too?', + a: 'Yes. No per-seat pricing on Freebuff Web. Collaborate via GitHub like any normal repo.', + }, + ], + }, + ], +} +` +} + +mkdirSync(POSTS_DIR, { recursive: true }) + +const generated: string[] = [] + +for (const c of competitors) { + const savingsFile = `how-one-${c.id}-user-saved-${c.middleTierMonthly * 12}-switching-to-freebuff.ts` + writeFileSync(join(POSTS_DIR, savingsFile), savingsPost(c)) + generated.push(savingsFile) + + if (!c.comparisonExists) { + const compFile = `free-alternative-to-${c.id}.ts` + writeFileSync(join(POSTS_DIR, compFile), comparisonPost(c)) + generated.push(compFile) + } +} + +console.log('Generated', generated.length, 'files:') +for (const f of generated) console.log(' ', f) diff --git a/freebuff/web/scripts/generate-blog-redirects.mjs.ts b/freebuff/web/scripts/generate-blog-redirects.mjs.ts new file mode 100644 index 0000000000..bae3f35cab --- /dev/null +++ b/freebuff/web/scripts/generate-blog-redirects.mjs.ts @@ -0,0 +1,18 @@ +/** + * Generate blog-redirects.mjs for next.config (Node cannot import .ts directly). + * Run: bun freebuff/web/scripts/generate-blog-redirects.mjs.ts + */ +import { writeFileSync } from 'fs' +import { join } from 'path' + +import { getBlogSlugRedirects } from '../src/lib/blog/blog-seo-config' + +const redirects = getBlogSlugRedirects() +const out = join(import.meta.dir, '../blog-redirects.mjs') + +const body = `// AUTO-GENERATED — run: bun scripts/generate-blog-redirects.mjs.ts +export const blogSlugRedirects = ${JSON.stringify(redirects, null, 2)} +` + +writeFileSync(out, body) +console.log('Wrote', redirects.length, 'redirects to blog-redirects.mjs') diff --git a/freebuff/web/scripts/generate-cli-blog-posts.ts b/freebuff/web/scripts/generate-cli-blog-posts.ts new file mode 100644 index 0000000000..177371f6b9 --- /dev/null +++ b/freebuff/web/scripts/generate-cli-blog-posts.ts @@ -0,0 +1,374 @@ +/** + * Generate CLI comparison + savings posts (pi, GitHub Copilot CLI) and CLI savings stories. + * Run after migrate-blog-seo-slugs.ts + * Run: bun freebuff/web/scripts/generate-cli-blog-posts.ts + */ +import { writeFileSync, existsSync } from 'fs' +import { join } from 'path' + +import { blogSeoEntries } from '../src/lib/blog/blog-seo-config' +import { + comparisonKeywordsById, + formatKeywordsArray, + savingsKeywordsFor, +} from '../src/lib/blog/competitor-seo-keywords' + +const POSTS_DIR = join(import.meta.dir, '../src/lib/blog/posts') + +interface CliCompetitor { + id: string + displayName: string + middleTierName: string + middleTierMonthly: number + priceRange: string + pricingNote: string + whatItDoes: string + whenTheyWin: string + migrationNote: string + compareRows: Array<{ feature: string; freebuff: string; competitor: string }> + specialNote?: string +} + +const cliCompetitors: CliCompetitor[] = [ + { + id: 'pi', + displayName: 'Pi Coding Agent', + middleTierName: 'typical model API spend', + middleTierMonthly: 20, + priceRange: '~$20/mo in API tokens (BYOK)', + pricingNote: + 'Pi itself is free and open-source (pi-mono / pi-coding-agent on npm). You pay your model provider per token — many developers report **$10–$30/mo** in API costs for daily use, or use `/login` with an existing Claude Pro, ChatGPT Plus, or GitHub Copilot subscription.', + whatItDoes: + 'Pi is a minimal, hackable terminal coding agent by Mario Zechner — four core tools (Read, Write, Edit, Bash), 15+ model providers, and a tree-structured session you can branch and rewind.', + whenTheyWin: + 'Pi wins if you want a tiny transparent agent loop you extend yourself with TypeScript skills — and you are happy bringing your own keys or subscriptions.', + migrationNote: 'Pi works on any repo. `cd` into your project and run `freebuff` in the same terminal — no export step.', + compareRows: [ + { feature: 'Price', freebuff: 'Free (models included)', competitor: 'Free agent; ~$20/mo API typical' }, + { feature: 'Model bundle', freebuff: 'DeepSeek V4 Pro, Kimi K2.6, MiniMax M2.7 included', competitor: 'BYOK or /login subscriptions' }, + { feature: 'Subagents', freebuff: '9 specialized, shipped', competitor: 'Via extensions only' }, + { feature: 'Browser-use', freebuff: 'Built-in subagent', competitor: 'Via extension' }, + { feature: 'Slash commands', freebuff: '/plan, /review, /pr, /deploy, more', competitor: 'Minimal defaults' }, + { feature: 'Connect ChatGPT', freebuff: 'Yes (GPT-5.4)', competitor: 'Via OpenAI API or Plus login' }, + ], + }, + { + id: 'github-copilot-cli', + displayName: 'GitHub Copilot CLI', + middleTierName: 'GitHub Copilot Pro+', + middleTierMonthly: 39, + priceRange: '$39/mo (Copilot Pro+)', + pricingNote: + 'GitHub Copilot CLI is included on all Copilot plans. **Copilot Pro is $10/mo**; **Copilot Pro+ is $39/mo** with a larger AI Credits pool. As of June 2026, usage is metered in GitHub AI Credits (1 credit = $0.01) per token — heavy agent sessions can burn through included credits fast (per [GitHub Copilot billing docs](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing)).', + whatItDoes: + 'GitHub Copilot CLI is GitHub\'s terminal agent: plan, edit, run shell commands, and open PRs — tied to your GitHub account and Copilot subscription.', + whenTheyWin: + 'Copilot CLI wins if your team already pays for Copilot Enterprise, needs GitHub-native PR flows, and wants usage pooled at the org level.', + migrationNote: 'Clone your repo locally (if not already), install Freebuff with `npm i -g freebuff`, run `freebuff` in the project root.', + compareRows: [ + { feature: 'Price', freebuff: 'Free', competitor: '$10–$39/mo + credit overages' }, + { feature: 'Credit meter', freebuff: 'None', competitor: 'Yes — AI Credits per token' }, + { feature: 'Model choice', freebuff: 'Multiple included + ChatGPT connect', competitor: 'Copilot model menu' }, + { feature: 'Subagents', freebuff: '9 specialized', competitor: 'Single agent' }, + { feature: 'Editor lock-in', freebuff: 'None — any terminal', competitor: 'None — CLI' }, + { feature: 'GitHub integration', freebuff: 'Via `gh` CLI / /pr', competitor: 'Native GitHub' }, + ], + }, +] + +function esc(s: string) { + return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} + +function comparisonPost(c: CliCompetitor, slug: string): string { + const keywords = comparisonKeywordsById[c.id] ?? [] + const shutdown = c.specialNote + ? ` + { + type: 'callout', + tone: 'warning', + title: 'Note', + text: '${esc(c.specialNote)}', + },` + : '' + + return `import type { Post } from '../types' + +export const post: Post = { + slug: '${slug}', + title: 'The free alternative to ${esc(c.displayName)}', + subtitle: 'Same CLI agent loop — without the ${esc(c.priceRange)} bill.', + description: + 'Freebuff is the free alternative to ${esc(c.displayName)}. CLI coding agent with subagents, slash commands, and included models — $0/month.', + category: 'Comparisons', + publishedAt: '2026-06-24', + readingMinutes: 7, + authorId: 'freebuff-team', +${formatKeywordsArray(keywords)} + body: [ + { + type: 'tldr', + items: [ + 'Freebuff is a free alternative to ${esc(c.displayName)} — same terminal agent loop.', + '${esc(c.displayName)} typical cost: ${esc(c.priceRange)}. Freebuff CLI is $0.', + '9 subagents, slash commands, and frontier models included — no API key required to start.', + 'Runs in any editor terminal: VS Code, JetBrains, Vim, Cursor, or bare shell.', + ], + }, + { + type: 'lede', + text: '${esc(c.whatItDoes)} Freebuff does the same job with models and subagents bundled in — no subscription or per-token meter on day one.', + }, + { type: 'h2', text: 'What ${esc(c.displayName)} costs in 2026' }, + { + type: 'p', + text: '${esc(c.pricingNote)}', + }, + { type: 'h2', text: 'Feature-by-feature: Freebuff CLI vs ${esc(c.displayName)}' }, + { + type: 'compare', + competitor: '${esc(c.displayName)}', + rows: [ +${c.compareRows.map((r) => ` { feature: '${esc(r.feature)}', freebuff: '${esc(r.freebuff)}', competitor: '${esc(r.competitor)}' },`).join('\n')} + ], + }, + { type: 'h2', text: 'When ${esc(c.displayName)} is still the better pick' }, + { + type: 'p', + text: '${esc(c.whenTheyWin)}', + }, + { type: 'h2', text: 'How to switch from ${esc(c.displayName)} to Freebuff' }, + { + type: 'ol', + items: [ + '${esc(c.migrationNote)}', + 'Run \`npm i -g freebuff\` (or use the install script from freebuff.com/cli).', + 'In your repo: \`freebuff\` — same terminal workflow, $0/month.', + 'Optional: connect ChatGPT for GPT-5.4 on the hardest turns.', + ], + },${shutdown} + { + type: 'cta', + title: 'Try the free alternative to ${esc(c.displayName)}', + description: 'Install Freebuff CLI and ship from your terminal for $0.', + href: '/cli', + label: 'Install Freebuff CLI', + }, + { + type: 'faq', + items: [ + { + q: 'Is Freebuff CLI really free?', + a: 'Yes. No per-token meter for normal use. Models are included; connect ChatGPT optionally for premium turns.', + }, + { + q: 'Can I use Freebuff inside Cursor or VS Code?', + a: 'Yes — open the integrated terminal and run freebuff. No editor switch required.', + }, + { + q: 'How much does ${esc(c.displayName)} cost per year?', + a: 'At ${esc(c.priceRange)}, expect roughly $${c.middleTierMonthly * 12}/year before overages. Freebuff is $0.', + }, + ], + }, + ], +} +` +} + +function savingsPost( + c: CliCompetitor, + slug: string, + comparisonSlug: string, + annual: number, +): string { + const keywords = savingsKeywordsFor(c.id).map((k) => + k.replace('freebuff', 'freebuff cli').replace('switch from', 'switch from'), + ) + // Dedupe and add cli-specific + const allKw = [...new Set([...keywords, `${c.id} cli savings`, 'freebuff cli alternative'])] + + return `import type { Post } from '../types' + +export const post: Post = { + slug: '${slug}', + title: 'How one ${esc(c.displayName)} user saved $${annual} by switching to Freebuff CLI', + subtitle: '$${c.middleTierMonthly}/mo ${esc(c.middleTierName)} → $0 with Freebuff.', + description: + 'Savings breakdown: ${esc(c.displayName)} at ${esc(c.priceRange)} vs Freebuff CLI at $0. A modeled example for terminal-first developers.', + category: 'Community', + publishedAt: '2026-06-24', + readingMinutes: 5, + authorId: 'freebuff-team', +${formatKeywordsArray(allKw)} + body: [ + { + type: 'tldr', + items: [ + '${esc(c.middleTierName)} on ${esc(c.displayName)}: about $${c.middleTierMonthly}/month ($${annual}/year).', + 'Freebuff CLI: $0/month with models and subagents included.', + 'Net savings: $${annual}/year before API or credit overages.', + 'Modeled example — your usage may differ.', + ], + }, + { + type: 'lede', + text: 'Terminal agents are where subscription and token costs add up fastest. Here is the math for ${esc(c.displayName)} vs Freebuff CLI — not a fabricated testimonial, but the switch we see often.', + }, + { type: 'h2', text: 'The bill on ${esc(c.middleTierName)}' }, + { + type: 'p', + text: '${esc(c.pricingNote)} For this example: **$${c.middleTierMonthly}/month** or **$${annual}/year**.', + }, + { + type: 'compare', + competitor: '${esc(c.middleTierName)}', + rows: [ + { feature: 'Monthly cost', freebuff: '$0', competitor: '$${c.middleTierMonthly}' }, + { feature: 'Annual cost', freebuff: '$0', competitor: '$${annual}' }, + { feature: 'Token / credit overages', freebuff: 'None', competitor: 'Common on long agent sessions' }, + { feature: 'Subagents', freebuff: '9 included', competitor: 'Varies' }, + { feature: 'Model bundle', freebuff: 'Included', competitor: 'Subscription or BYOK' }, + ], + }, + { type: 'h2', text: 'Three-step switch' }, + { + type: 'ol', + items: [ + '${esc(c.migrationNote)}', + 'Install Freebuff: \`npm i -g freebuff\`', + 'Cancel ${esc(c.displayName)} billing once you have verified Freebuff on your repos.', + ], + }, + { + type: 'callout', + tone: 'success', + title: 'Full comparison', + text: 'Read [The free alternative to ${esc(c.displayName)}](/blog/${comparisonSlug}) for the feature-by-feature table.', + }, + { + type: 'cta', + title: 'Stop paying $${c.middleTierMonthly}/mo', + href: '/cli', + label: 'Install Freebuff CLI', + }, + { + type: 'faq', + items: [ + { + q: 'Can I run both Copilot CLI and Freebuff?', + a: 'Yes. Many developers use Freebuff for heavy agent work and keep IDE completions elsewhere — or drop the paid tier entirely.', + }, + { + q: 'Do I need to change editors?', + a: 'No. Freebuff runs in whatever terminal you already use.', + }, + ], + }, + ], +} +` +} + +// CLI savings for existing agents (data only — files created by savings generator) +const cliSavingsMeta: Record<string, CliCompetitor> = { + 'claude-code': { + id: 'claude-code', + displayName: 'Claude Code', + middleTierName: 'Claude Code Pro', + middleTierMonthly: 20, + priceRange: '$20/mo (Pro)', + pricingNote: + 'Claude Code Pro is **$20/mo**; Max is **$200/mo**. Pro includes agent access tied to your Anthropic subscription.', + whatItDoes: 'Anthropic\'s CLI coding agent.', + whenTheyWin: '', + migrationNote: 'Same repo, same terminal — run `freebuff` instead of `claude`.', + compareRows: [], + }, + codex: { + id: 'codex', + displayName: 'OpenAI Codex CLI', + middleTierName: 'ChatGPT Plus', + middleTierMonthly: 20, + priceRange: '$20/mo (ChatGPT Plus)', + pricingNote: + 'Codex and the Codex CLI require **ChatGPT Plus at $20/mo** or higher for cloud agent access.', + whatItDoes: 'OpenAI\'s Codex CLI agent.', + whenTheyWin: '', + migrationNote: 'Run `freebuff` in the same repo — local-first, no Plus subscription.', + compareRows: [], + }, + opencode: { + id: 'opencode', + displayName: 'OpenCode', + middleTierName: 'typical API spend', + middleTierMonthly: 30, + priceRange: '~$30/mo (model APIs)', + pricingNote: + 'OpenCode is free software but **BYOK** — most users pay **$20–$50/mo** to model providers depending on volume.', + whatItDoes: 'Open-source CLI agent from SST.', + whenTheyWin: '', + migrationNote: 'Keep your repo; swap the CLI command to `freebuff`.', + compareRows: [], + }, + cursor: { + id: 'cursor', + displayName: 'Cursor Agent', + middleTierName: 'Cursor Pro', + middleTierMonthly: 20, + priceRange: '$20/mo (Pro)', + pricingNote: + 'Cursor Pro is **$20/mo**; Ultra is **$200/mo**. The agent loop is gated behind Pro for serious use.', + whatItDoes: 'Cursor\'s in-editor and terminal agent.', + whenTheyWin: '', + migrationNote: 'Keep Cursor for tab completion; run `freebuff` in the terminal panel and cancel Pro.', + compareRows: [], + }, + antigravity: { + id: 'antigravity', + displayName: 'Antigravity CLI', + middleTierName: 'future Gemini spend', + middleTierMonthly: 20, + priceRange: 'Free today; Gemini API ~$20/mo typical', + pricingNote: + 'Antigravity is **free at launch** but Gemini-powered; long-term pricing is TBD. Budget **~$20/mo** equivalent in API or future seat fees for planning.', + whatItDoes: 'Google\'s agentic IDE with CLI surfaces.', + whenTheyWin: '', + migrationNote: 'Use Freebuff in your existing editor instead of switching to Antigravity\'s fork.', + compareRows: [], + }, +} + +let written = 0 + +for (const c of cliCompetitors) { + const entry = blogSeoEntries.find((e) => e.id === c.id)! + const compPath = join(POSTS_DIR, `${entry.comparisonSlug}.ts`) + writeFileSync(compPath, comparisonPost(c, entry.comparisonSlug)) + written++ + if (entry.savingsSlug && entry.savingsAnnual) { + writeFileSync( + join(POSTS_DIR, `${entry.savingsSlug}.ts`), + savingsPost(c, entry.savingsSlug, entry.comparisonSlug, entry.savingsAnnual), + ) + written++ + } +} + +for (const [id, meta] of Object.entries(cliSavingsMeta)) { + const entry = blogSeoEntries.find((e) => e.id === id) + if (!entry?.savingsSlug || !entry.savingsAnnual) continue + const path = join(POSTS_DIR, `${entry.savingsSlug}.ts`) + if (existsSync(path)) { + console.log('skip existing savings:', entry.savingsSlug) + continue + } + writeFileSync( + path, + savingsPost(meta, entry.savingsSlug, entry.comparisonSlug, entry.savingsAnnual), + ) + written++ + console.log('savings:', entry.savingsSlug) +} + +console.log(`Wrote ${written} CLI post files.`) diff --git a/freebuff/web/scripts/migrate-blog-seo-slugs.ts b/freebuff/web/scripts/migrate-blog-seo-slugs.ts new file mode 100644 index 0000000000..3a9dd937c0 --- /dev/null +++ b/freebuff/web/scripts/migrate-blog-seo-slugs.ts @@ -0,0 +1,76 @@ +/** + * Migrate blog post files to SEO-optimized slugs and fix internal links. + * Run: bun freebuff/web/scripts/migrate-blog-seo-slugs.ts + */ +import { existsSync, readFileSync, renameSync, readdirSync, writeFileSync } from 'fs' +import { join } from 'path' + +import { blogSeoEntries } from '../src/lib/blog/blog-seo-config' + +const POSTS_DIR = join(import.meta.dir, '../src/lib/blog/posts') + +function updateSlugInFile(path: string, newSlug: string) { + let content = readFileSync(path, 'utf8') + content = content.replace(/slug: '[^']+'/, `slug: '${newSlug}'`) + writeFileSync(path, content) +} + +function migrateFile(oldSlug: string | undefined, newSlug: string) { + if (!oldSlug || oldSlug === newSlug) { + const direct = join(POSTS_DIR, `${newSlug}.ts`) + if (existsSync(direct)) { + updateSlugInFile(direct, newSlug) + return + } + return + } + const oldPath = join(POSTS_DIR, `${oldSlug}.ts`) + const newPath = join(POSTS_DIR, `${newSlug}.ts`) + if (!existsSync(oldPath)) { + if (existsSync(newPath)) { + updateSlugInFile(newPath, newSlug) + return + } + console.warn('missing:', oldSlug) + return + } + renameSync(oldPath, newPath) + updateSlugInFile(newPath, newSlug) + console.log(`${oldSlug} → ${newSlug}`) +} + +// 1. Rename comparison + savings files +for (const e of blogSeoEntries) { + migrateFile(e.legacyComparisonSlug, e.comparisonSlug) + if (e.savingsSlug) { + migrateFile(e.legacySavingsSlug, e.savingsSlug) + } +} + +// 2. Fix internal /blog/ links across all posts +const slugMap = new Map<string, string>() +for (const e of blogSeoEntries) { + if (e.legacyComparisonSlug) slugMap.set(e.legacyComparisonSlug, e.comparisonSlug) + if (e.legacySavingsSlug && e.savingsSlug) slugMap.set(e.legacySavingsSlug, e.savingsSlug) +} + +for (const file of readdirSync(POSTS_DIR)) { + if (!file.endsWith('.ts')) continue + const path = join(POSTS_DIR, file) + let content = readFileSync(path, 'utf8') + let changed = false + for (const [oldSlug, newSlug] of slugMap) { + const needle = `/blog/${oldSlug}` + const replacement = `/blog/${newSlug}` + if (content.includes(needle)) { + content = content.split(needle).join(replacement) + changed = true + } + } + if (changed) { + writeFileSync(path, content) + console.log('updated links:', file) + } +} + +console.log('Slug migration complete.') diff --git a/freebuff/web/scripts/patch-blog-seo-keywords.ts b/freebuff/web/scripts/patch-blog-seo-keywords.ts new file mode 100644 index 0000000000..237a4670f9 --- /dev/null +++ b/freebuff/web/scripts/patch-blog-seo-keywords.ts @@ -0,0 +1,56 @@ +/** + * Patch comparison + savings blog posts with expanded SEO keywords. + * Run: bun freebuff/web/scripts/patch-blog-seo-keywords.ts + */ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs' +import { join } from 'path' + +import { blogSeoEntries } from '../src/lib/blog/blog-seo-config' +import { + comparisonKeywordsById, + formatKeywordsArray, + savingsKeywordsFor, +} from '../src/lib/blog/competitor-seo-keywords' + +const POSTS_DIR = join(import.meta.dir, '../src/lib/blog/posts') + +function replaceKeywordsBlock(content: string, keywords: string[]): string { + const replacement = formatKeywordsArray(keywords) + const pattern = /keywords:\s*\[[\s\S]*?\],/ + if (!pattern.test(content)) { + throw new Error('Could not find keywords block') + } + return content.replace(pattern, replacement) +} + +let patched = 0 + +for (const [id, keywords] of Object.entries(comparisonKeywordsById)) { + const entry = blogSeoEntries.find((e) => e.id === id) + const comparisonFile = entry + ? join(POSTS_DIR, `${entry.comparisonSlug}.ts`) + : join(POSTS_DIR, `free-alternative-to-${id}.ts`) + if (!existsSync(comparisonFile)) continue + try { + const content = readFileSync(comparisonFile, 'utf8') + writeFileSync(comparisonFile, replaceKeywordsBlock(content, keywords)) + patched++ + console.log('comparison:', id) + } catch (e) { + console.warn('comparison failed:', id, e) + } + + if (!entry?.savingsSlug) continue + const savingsFile = join(POSTS_DIR, `${entry.savingsSlug}.ts`) + if (!existsSync(savingsFile)) continue + try { + const content = readFileSync(savingsFile, 'utf8') + writeFileSync(savingsFile, replaceKeywordsBlock(content, savingsKeywordsFor(id))) + patched++ + console.log('savings:', id) + } catch (e) { + console.warn('savings failed:', id, e) + } +} + +console.log(`\nPatched ${patched} files.`) diff --git a/freebuff/web/scripts/postbuild-prune-cache.mjs b/freebuff/web/scripts/postbuild-prune-cache.mjs new file mode 100644 index 0000000000..95fd2ea807 --- /dev/null +++ b/freebuff/web/scripts/postbuild-prune-cache.mjs @@ -0,0 +1,27 @@ +/** + * After `next build`, delete `.next/cache` so the Render deploy artifact stays + * small. The webpack/Next cache can be multiple GB and is not needed at runtime; + * removing it before Render compresses/uploads the build cuts deploy time. + */ +import { rm, access } from 'fs/promises' +import { join } from 'path' +import { fileURLToPath } from 'url' + +const webRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..') + +async function exists(path) { + try { + await access(path) + return true + } catch { + return false + } +} + +const cacheDir = join(webRoot, '.next/cache') +if (await exists(cacheDir)) { + await rm(cacheDir, { recursive: true, force: true }) + console.log('[postbuild-prune-cache] removed .next/cache') +} else { + console.log('[postbuild-prune-cache] no .next/cache to remove') +} diff --git a/freebuff/web/scripts/prune-sandbox-artifacts.sh b/freebuff/web/scripts/prune-sandbox-artifacts.sh new file mode 100755 index 0000000000..fd2c94e915 --- /dev/null +++ b/freebuff/web/scripts/prune-sandbox-artifacts.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Remove large, reproducible build artifacts from a Cloud sandbox workspace. +# Safe to run after `next build`, failed builds, or before disk-heavy agent work. +# +# Usage (from repo root): +# bash freebuff/web/scripts/prune-sandbox-artifacts.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +cd "$ROOT" + +prune_path() { + local path="$1" + if [[ -e "$path" ]]; then + rm -rf "$path" + echo "[prune-sandbox-artifacts] removed $path" + fi +} + +prune_path "$ROOT/freebuff/web/.next" +prune_path "$ROOT/web/.next" +prune_path "$ROOT/node_modules/.cache" +prune_path "$ROOT/.turbo" + +find "$ROOT" -name '*.tsbuildinfo' -not -path '*/node_modules/*' -delete 2>/dev/null || true +find "$ROOT" -type d -name '.next' -not -path '*/node_modules/*' -prune -exec rm -rf {} + 2>/dev/null || true + +echo "[prune-sandbox-artifacts] done." +df -h "$ROOT" 2>/dev/null | tail -1 || true diff --git a/freebuff/web/scripts/regenerate-blog-registry.ts b/freebuff/web/scripts/regenerate-blog-registry.ts new file mode 100644 index 0000000000..a5cdd9334e --- /dev/null +++ b/freebuff/web/scripts/regenerate-blog-registry.ts @@ -0,0 +1,187 @@ +/** + * Regenerate blog/registry.ts imports from posts/*.ts + * Run: bun freebuff/web/scripts/regenerate-blog-registry.ts + */ +import { readdirSync, writeFileSync } from 'fs' +import { join } from 'path' + +const POSTS_DIR = join(import.meta.dir, '../src/lib/blog/posts') +const REGISTRY_PATH = join(import.meta.dir, '../src/lib/blog/registry.ts') + +const STATIC_POSTS = [ + 'best-free-cli-coding-agents-2026', + 'coding-agent-benchmarks-what-they-miss', + 'free-alternative-to-a0-dev', + 'free-alternative-to-bloom', + 'free-alternative-to-devin', + 'free-alternative-to-gemini-cli', + 'free-alternative-to-heyboss', + 'free-alternative-to-vibecode', + 'free-alternative-to-windsurf', + 'freebuff-in-the-wild-may', + 'freebuff-launch', + 'freebuff-subagents-deep-dive', + 'freebuff-web-launch', + 'freebuff-web-vs-lovable-bolt-replit', + 'side-income-with-freebuff-india', + 'state-of-free-ai-coding-2026', + 'switch-from-cursor-to-freebuff', + 'vly-becomes-freebuff-web', + 'what-developers-are-saying', + 'why-free-coding-agents-won-2026', +] + +function slugToImportVar(slug: string): string { + return slug + .replace(/[^a-z0-9]+/g, ' ') + .trim() + .split(' ') + .map((w, i) => (i === 0 ? w : w.charAt(0).toUpperCase() + w.slice(1))) + .join('') +} + +const allFiles = readdirSync(POSTS_DIR) + .filter((f) => f.endsWith('.ts')) + .map((f) => f.replace(/\.ts$/, '')) + .sort() + +const seoSlugs = new Set(allFiles.filter((s) => !STATIC_POSTS.includes(s))) +const staticSet = new Set(STATIC_POSTS.filter((s) => allFiles.includes(s))) + +const imports: string[] = [] +for (const slug of allFiles) { + const varName = slugToImportVar(slug) + imports.push(`import { post as ${varName} } from './posts/${slug}'`) +} + +// Group posts for registry array +const CLI_SEO_IDS = [ + 'free-claude-code-cli-coding-agent-alternative-freebuff', + 'free-codex-cli-openai-coding-agent-alternative-freebuff', + 'free-cursor-agent-cli-coding-alternative-freebuff', + 'free-opencode-cli-coding-agent-alternative-freebuff', + 'free-antigravity-cli-coding-agent-alternative-freebuff', + 'free-pi-coding-agent-cli-alternative-freebuff', + 'free-github-copilot-cli-coding-agent-alternative-freebuff', +] + +const cliLegacy = [ + 'freeAlternativeToWindsurf', + 'freeAlternativeToDevin', + 'freeAlternativeToGeminiCli', +] + +const appBuilderComparison = allFiles.filter( + (s) => + s.startsWith('free-') && + s.endsWith('-alternative-freebuff') && + !CLI_SEO_IDS.includes(s), +) + +const cliComparison = CLI_SEO_IDS.filter((s) => allFiles.includes(s)) + +const cliSavings = allFiles.filter( + (s) => s.startsWith('save-') && s.includes('-cli-') && s.endsWith('-alternative'), +) +const webSavings = allFiles.filter( + (s) => s.startsWith('save-') && !cliSavings.includes(s), +) + +function ref(slug: string) { + return slugToImportVar(slug) +} + +const registryBody = `import type { Post } from './types' + +/** + * Canonical list of all published blog posts. + * Regenerated by scripts/regenerate-blog-registry.ts — add static posts there. + */ +const allPosts: Post[] = [ + // Launches + ${ref('freebuff-launch')}, + ${ref('freebuff-web-launch')}, + ${ref('vly-becomes-freebuff-web')}, + + // Comparisons — CLI agents (SEO slugs) + ${cliComparison.map(ref).join(',\n ')}, + ${cliLegacy.join(',\n ')}, + + // Comparisons — App builders + ${appBuilderComparison.map(ref).join(',\n ')}, + ${['freeAlternativeToA0Dev', 'freeAlternativeToBloom', 'freeAlternativeToHeyboss', 'freeAlternativeToVibecode', ref('freebuff-web-vs-lovable-bolt-replit')].join(',\n ')}, + + // Savings — CLI + ${cliSavings.map(ref).join(',\n ')}, + + // Savings — App builders + ${webSavings.map(ref).join(',\n ')}, + + // Guides + ${ref('best-free-cli-coding-agents-2026')}, + ${ref('switch-from-cursor-to-freebuff')}, + + // Research + ${ref('state-of-free-ai-coding-2026')}, + ${ref('why-free-coding-agents-won-2026')}, + ${ref('coding-agent-benchmarks-what-they-miss')}, + + // Engineering + ${ref('freebuff-subagents-deep-dive')}, + + // Community + ${ref('what-developers-are-saying')}, + ${ref('side-income-with-freebuff-india')}, + ${ref('freebuff-in-the-wild-may')}, +] + +export function getAllPosts(): Post[] { + return [...allPosts].sort((a, b) => + b.publishedAt.localeCompare(a.publishedAt), + ) +} + +export function getPostBySlug(slug: string): Post | undefined { + return allPosts.find((p) => p.slug === slug) +} + +export function getFeaturedPosts(limit = 3): Post[] { + const featured = getAllPosts().filter((p) => p.featured) + if (featured.length >= limit) return featured.slice(0, limit) + const seen = new Set(featured.map((p) => p.slug)) + for (const p of getAllPosts()) { + if (featured.length >= limit) break + if (!seen.has(p.slug)) featured.push(p) + } + return featured.slice(0, limit) +} + +export function getRelatedPosts(slug: string, limit = 3): Post[] { + const current = getPostBySlug(slug) + if (!current) return [] + return getAllPosts() + .filter((p) => p.slug !== slug) + .map((p) => ({ + post: p, + score: + (p.category === current.category ? 2 : 0) + + p.keywords.filter((k) => current.keywords.includes(k)).length, + })) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((x) => x.post) +} + +export function getAllCategoriesInUse(): string[] { + const set = new Set<string>() + for (const p of allPosts) set.add(p.category) + return Array.from(set) +} +` + +writeFileSync( + REGISTRY_PATH, + `${imports.join('\n')}\n\n${registryBody}`, +) + +console.log('Registry regenerated with', allFiles.length, 'posts.') diff --git a/freebuff/web/scripts/render-next-cache.mjs b/freebuff/web/scripts/render-next-cache.mjs new file mode 100644 index 0000000000..5db30b9c34 --- /dev/null +++ b/freebuff/web/scripts/render-next-cache.mjs @@ -0,0 +1,103 @@ +/** + * Persist `.next/cache` across Render builds via $XDG_CACHE_HOME, which Render + * keeps between builds (unlike the project directory's `.next/cache`, which we + * delete so the deploy artifact stays small — see postbuild-prune-cache.mjs). + * + * Usage: + * node scripts/render-next-cache.mjs restore # before `next build` + * node scripts/render-next-cache.mjs save # after `next build` + * + * `restore` moves the stashed cache into `.next/cache`; `save` moves it back + * out of the project dir (so it is never compressed into the deploy artifact) + * and doubles as the prune step. Both are no-ops outside Render (RENDER unset) + * so local builds are unaffected. + */ +import { access, mkdir, rename, rm, cp, stat, readdir } from 'fs/promises' +import { join } from 'path' +import { fileURLToPath } from 'url' + +const webRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..') +const projectCache = join(webRoot, '.next/cache') + +// Drop the stash if it balloons past this — a huge bundler cache (Turbopack +// filesystem cache, `.next/cache/turbopack`) slows Render's build-cache +// download/extraction more than it saves in compile time. +// Turbopack's filesystem cache runs ~3-5GB for this app (vs ~1.5GB webpack); +// it turns a full recompile into a sub-second warm hit, so allow it. Revisit +// if Render's cache download/extract cost outgrows the compile savings. +const MAX_STASH_BYTES = 8 * 1024 * 1024 * 1024 + +async function exists(path) { + try { + await access(path) + return true + } catch { + return false + } +} + +async function dirSize(path, cap) { + let total = 0 + const entries = await readdir(path, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + const child = join(path, entry.name) + if (entry.isDirectory()) { + total += await dirSize(child, cap - total) + } else if (entry.isFile()) { + total += (await stat(child).catch(() => ({ size: 0 }))).size + } + if (total > cap) return total + } + return total +} + +async function move(from, to) { + await mkdir(join(to, '..'), { recursive: true }) + try { + await rename(from, to) + } catch { + // Cross-device fallback + await cp(from, to, { recursive: true, force: true }) + await rm(from, { recursive: true, force: true }) + } +} + +const mode = process.argv[2] +if (mode !== 'restore' && mode !== 'save') { + console.error('[render-next-cache] usage: render-next-cache.mjs <restore|save>') + process.exit(1) +} + +if (!process.env.RENDER) { + console.log(`[render-next-cache] not on Render; skipping ${mode}`) + process.exit(0) +} + +const stashRoot = process.env.XDG_CACHE_HOME ?? '/opt/render/.cache' +const stash = join(stashRoot, 'freebuff-web', 'next-cache') + +if (mode === 'restore') { + if (await exists(stash)) { + await rm(projectCache, { recursive: true, force: true }) + await move(stash, projectCache) + console.log(`[render-next-cache] restored ${stash} -> .next/cache`) + } else { + console.log('[render-next-cache] no stashed cache; cold build') + } +} else { + if (await exists(projectCache)) { + const size = await dirSize(projectCache, MAX_STASH_BYTES) + if (size > MAX_STASH_BYTES) { + await rm(projectCache, { recursive: true, force: true }) + console.log( + `[render-next-cache] cache exceeded ${MAX_STASH_BYTES} bytes; dropped instead of stashing`, + ) + } else { + await rm(stash, { recursive: true, force: true }) + await move(projectCache, stash) + console.log(`[render-next-cache] stashed .next/cache -> ${stash}`) + } + } else { + console.log('[render-next-cache] no .next/cache to stash') + } +} diff --git a/freebuff/web/scripts/render-preview-deploy.ts b/freebuff/web/scripts/render-preview-deploy.ts new file mode 100644 index 0000000000..8b498c71c7 --- /dev/null +++ b/freebuff/web/scripts/render-preview-deploy.ts @@ -0,0 +1,196 @@ +import { access, cp, mkdir, realpath, rm } from 'fs/promises' +import { join } from 'path' + +const webRoot = join(import.meta.dir, '..') +const repoRoot = join(webRoot, '..', '..') +const sdkRoot = join(repoRoot, 'sdk') +const sdkDist = join(sdkRoot, 'dist') +type ConvexDeployKeyType = 'preview' | 'project' | 'deployment' + +function sanitizePreviewName(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) +} + +async function runStep(label: string, command: string[], cwd = webRoot) { + console.log(`\n[render-preview] ${label}`) + console.log(`[render-preview] cwd ${cwd}`) + console.log(`[render-preview] $ ${command.join(' ')}`) + + const proc = Bun.spawn(command, { + cwd, + env: process.env, + stdout: 'inherit', + stderr: 'inherit', + }) + + const exitCode = await proc.exited + if (exitCode !== 0) { + throw new Error(`${label} failed with exit code ${exitCode}`) + } +} + +async function pathExists(path: string) { + try { + await access(path) + return true + } catch { + return false + } +} + +async function syncSdkDistForPackageResolution() { + const builtEntry = join(sdkDist, 'index.mjs') + if (!(await pathExists(builtEntry))) { + throw new Error(`Expected built SDK entrypoint at ${builtEntry}`) + } + + const packageDirs = [ + join(repoRoot, 'node_modules', '@codebuff', 'sdk'), + join(webRoot, 'node_modules', '@codebuff', 'sdk'), + join(repoRoot, 'freebuff', 'node_modules', '@codebuff', 'sdk'), + ] + + for (const packageDir of packageDirs) { + if (!(await pathExists(join(packageDir, 'package.json')))) { + continue + } + + const packageDist = join(packageDir, 'dist') + const [sourceDistPath, targetDistPath] = await Promise.all([ + realpath(sdkDist), + realpath(packageDist).catch(() => packageDist), + ]) + if (sourceDistPath === targetDistPath) { + console.log( + `[render-preview] SDK dist already available at ${packageDist}`, + ) + continue + } + + await mkdir(packageDist, { recursive: true }) + await cp(sdkDist, packageDist, { recursive: true, force: true }) + + const packageEntry = join(packageDist, 'index.mjs') + if (!(await pathExists(packageEntry))) { + throw new Error(`Failed to sync SDK dist entrypoint to ${packageEntry}`) + } + console.log(`[render-preview] synced SDK dist to ${packageDist}`) + } +} + +function getRequiredEnv(name: string) { + const value = process.env[name] + if (!value) { + throw new Error(`Missing required Render environment variable ${name}`) + } + return value +} + +function getConvexDeployKeyType(deployKey: string): ConvexDeployKeyType { + const prefix = deployKey.split('|')[0] ?? '' + const prefixParts = prefix.split(':') + + if (prefixParts[0] === 'preview' && prefixParts.length === 3) { + return 'preview' + } + + if (prefixParts[0] === 'project') { + return 'project' + } + + return 'deployment' +} + +function getConvexDeployCommand(deployKey: string) { + const deployKeyType = getConvexDeployKeyType(deployKey) + const command = ['bun', 'x', 'convex', 'deploy', '--typecheck=disable'] + + if (deployKeyType === 'deployment') { + command.push('--allow-deleting-large-indexes') + console.log( + '[render-preview] using deployment key target; omitting --preview-name because Convex ignores it for deployment keys', + ) + } else { + command.push('--preview-name', previewName) + } + + command.push('--cmd', 'bun run build:next') + return command +} + +const previewName = + sanitizePreviewName( + process.env.CONVEX_PREVIEW_NAME ?? 'freebuff-web-preview', + ) || 'freebuff-web-preview' +const convexDeployKey = getRequiredEnv('CONVEX_DEPLOY_KEY') + +if (!process.env.CONVEX_PREVIEW_NAME && process.env.RENDER_GIT_BRANCH) { + console.log( + `[render-preview] ignoring Render branch "${process.env.RENDER_GIT_BRANCH}" for Convex preview naming`, + ) +} +console.log(`[render-preview] using Convex preview deployment "${previewName}"`) +console.log( + `[render-preview] using Convex ${getConvexDeployKeyType(convexDeployKey)} deploy key`, +) + +await runStep( + 'building @codebuff/sdk before Convex snapshots dependencies', + ['bun', 'run', 'build'], + sdkRoot, +) + +await syncSdkDistForPackageResolution() + +await runStep( + 'deploying Convex preview and building Next.js', + getConvexDeployCommand(convexDeployKey), +) + +await pruneArtifactForStandalone() + +/** + * Shrink the Render deploy artifact by deleting the hoisted monorepo + * `node_modules` (~3.9GB of electron/react-native/expo siblings the web server + * never loads). Safe because `next build` emitted `output: 'standalone'` and + * assemble-standalone.mjs made `.next/standalone` self-contained (verified: it + * boots and serves with zero access to the root node_modules). + * + * Runs only on Render, only after the Convex deploy + Next build above have + * finished (nothing left in the build needs node_modules), and only when the + * standalone server actually exists — otherwise we leave node_modules in place + * so the `next start` fallback in start-standalone.mjs still works. + */ +async function pruneArtifactForStandalone() { + if (!process.env.RENDER) { + console.log('[render-preview] not on Render; skipping artifact prune') + return + } + + const standaloneServer = join( + webRoot, + '.next', + 'standalone', + 'freebuff', + 'web', + 'server.js', + ) + if (!(await pathExists(standaloneServer))) { + console.log( + `[render-preview] standalone server missing at ${standaloneServer}; ` + + 'leaving node_modules in place (next start fallback)', + ) + return + } + + const rootNodeModules = join(repoRoot, 'node_modules') + await rm(rootNodeModules, { recursive: true, force: true }) + console.log( + `[render-preview] pruned ${rootNodeModules} from deploy artifact`, + ) +} diff --git a/freebuff/web/scripts/start-standalone.mjs b/freebuff/web/scripts/start-standalone.mjs new file mode 100644 index 0000000000..6af6b4c725 --- /dev/null +++ b/freebuff/web/scripts/start-standalone.mjs @@ -0,0 +1,62 @@ +/** + * Production launcher for the Next standalone server (see next.config.mjs + * `output: 'standalone'` and assemble-standalone.mjs). + * + * Responsibilities: + * - Point the SDK's runtime asset resolution at the copied-in assets via + * CODEBUFF_WASM_DIR / CODEBUFF_RG_PATH, so tree-sitter + ripgrep work even + * though the hoisted monorepo node_modules is pruned from the artifact. + * - Bind 0.0.0.0 so Render's port scan sees the service. + * - Fall back to `next start` when no standalone build exists (local dev), so + * `bun start` is not broken off Render. + */ +import { spawn } from 'child_process' +import { existsSync } from 'fs' +import { join } from 'path' +import { fileURLToPath } from 'url' + +const webRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..') +const standaloneRoot = join(webRoot, '.next', 'standalone') +const serverEntry = join(standaloneRoot, 'freebuff', 'web', 'server.js') +const runtimeAssets = join(standaloneRoot, '_runtime-assets') + +function ripgrepPlatformDir() { + const { platform, arch } = process + if (platform === 'win32' && arch === 'x64') return 'x64-win32' + if (platform === 'darwin') return arch === 'arm64' ? 'arm64-darwin' : 'x64-darwin' + if (platform === 'linux') return arch === 'arm64' ? 'arm64-linux' : 'x64-linux' + return `${arch}-${platform}` +} + +// Force 0.0.0.0 unconditionally: Render's runtime exports HOSTNAME=<pod-name> +// (k8s-style), and Next's standalone server.js binds to $HOSTNAME — inheriting +// it would bind the pod name instead of all interfaces and fail Render's port +// scan. `next start` bound 0.0.0.0 by default; keep that behavior. +const env = { ...process.env, HOSTNAME: '0.0.0.0' } + +let cmd +let args +if (existsSync(serverEntry)) { + const rgBinary = process.platform === 'win32' ? 'rg.exe' : 'rg' + env.CODEBUFF_WASM_DIR = env.CODEBUFF_WASM_DIR || join(runtimeAssets, 'wasm') + env.CODEBUFF_RG_PATH = + env.CODEBUFF_RG_PATH || join(runtimeAssets, 'ripgrep', ripgrepPlatformDir(), rgBinary) + console.log(`[start-standalone] launching standalone server: ${serverEntry}`) + console.log(`[start-standalone] CODEBUFF_WASM_DIR=${env.CODEBUFF_WASM_DIR}`) + console.log(`[start-standalone] CODEBUFF_RG_PATH=${env.CODEBUFF_RG_PATH}`) + cmd = process.execPath // node + args = [serverEntry] +} else { + console.log('[start-standalone] no standalone build; falling back to `next start`') + cmd = 'next' + args = ['start'] +} + +const child = spawn(cmd, args, { stdio: 'inherit', env }) +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, () => child.kill(sig)) +} +child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal) + else process.exit(code ?? 0) +}) diff --git a/freebuff/web/scripts/test-chat-gravity.ts b/freebuff/web/scripts/test-chat-gravity.ts new file mode 100644 index 0000000000..183afed134 --- /dev/null +++ b/freebuff/web/scripts/test-chat-gravity.ts @@ -0,0 +1,62 @@ +/** + * E2E smoke test for gravity_index in the freebuff.com/chat agent. + * + * Mirrors src/server/chat/agent.ts: runs base-chat through the SDK with the + * chat's DeepSeek backend model, sends a service-recommendation prompt, and + * logs the normalized events the chat UI would receive. + * + * Run from freebuff/web with env loaded: + * sh -c 'set -a; source ../../.env.local; set +a; \ + * NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3019 \ + * bun scripts/test-chat-gravity.ts "<prompt>"' + */ +import { run } from '@codebuff/sdk' + +import baseChatAgent from '../../../agents/base-chat' +import researcherWebAgent from '../../../agents/researcher/researcher-web' +import { CHAT_MODELS } from '../src/app/chat/models' + +import type { AgentDefinition } from '@codebuff/sdk' + +const prompt = + process.argv[2] ?? + 'What service should I use to send transactional emails from my Next.js app? Just need a quick recommendation.' + +const apiKey = process.env.CODEBUFF_API_KEY +if (!apiKey) throw new Error('CODEBUFF_API_KEY not set') + +const agent = { + ...baseChatAgent, + model: CHAT_MODELS[0].backendId, +} as AgentDefinition + +const result = await run({ + apiKey, + fingerprintId: 'freebuff-chat-test-script', + agent, + agentDefinitions: [researcherWebAgent as AgentDefinition], + projectFiles: {}, + knowledgeFiles: {}, + maxAgentSteps: 10, + prompt, + costMode: 'normal', + handleStreamChunk: (chunk) => { + if (typeof chunk === 'string') process.stdout.write(chunk) + }, + handleEvent: (event) => { + if (event.type === 'tool_call') { + console.log( + `\n[tool_call] ${event.toolName} agentId=${event.agentId ?? '(none)'} input=${JSON.stringify(event.input).slice(0, 300)}`, + ) + } else if (event.type === 'tool_result') { + console.log( + `\n[tool_result] ${event.toolName} output=${JSON.stringify(event.output).slice(0, 500)}`, + ) + } else if (event.type === 'subagent_start') { + console.log(`\n[subagent_start] ${event.agentType} (${event.agentId})`) + } + }, +}) + +console.log('\n\n=== run output ===') +console.log(JSON.stringify(result.output, null, 2).slice(0, 3000)) diff --git a/freebuff/web/scripts/test-chat-thinker.ts b/freebuff/web/scripts/test-chat-thinker.ts new file mode 100644 index 0000000000..b8f03f1fe3 --- /dev/null +++ b/freebuff/web/scripts/test-chat-thinker.ts @@ -0,0 +1,77 @@ +/** + * E2E smoke test for the thinker-gemini spawn path in freebuff.com/chat. + * + * Mirrors src/server/chat/agent.ts: runs base-chat with researcher-web AND + * thinker-gemini registered, and logs subagent lifecycle + reasoning chunks + * so we can see whether the thinker spawns and whether its reasoning streams. + * + * Run from freebuff/web with env loaded: + * sh -c 'set -a; source ../../.env.local; set +a; \ + * NEXT_PUBLIC_CODEBUFF_APP_URL=http://localhost:3000 \ + * bun scripts/test-chat-thinker.ts "<prompt>"' + */ +import { run } from '@codebuff/sdk' + +import baseChatAgent from '../../../agents/base-chat' +import researcherWebAgent from '../../../agents/researcher/researcher-web' +import thinkerGeminiAgent from '../../../agents/thinker/thinker-gemini' +import { CHAT_MODELS } from '../src/app/chat/models' + +import type { AgentDefinition } from '@codebuff/sdk' + +const prompt = + process.argv[2] ?? + 'Use your thinker-gemini deep-thinking agent to reason through this, then answer: in a knockout tournament with 137 players, how many matches are played?' + +const apiKey = process.env.CODEBUFF_API_KEY +if (!apiKey) throw new Error('CODEBUFF_API_KEY not set') + +const agent = { + ...baseChatAgent, + model: CHAT_MODELS[1].backendId, // Smartest / DeepSeek Pro +} as AgentDefinition + +console.log('spawnableAgents:', baseChatAgent.spawnableAgents) +console.log('thinker id:', thinkerGeminiAgent.id, 'model:', thinkerGeminiAgent.model) + +let reasoningSubagentChars = 0 + +const result = await run({ + apiKey, + fingerprintId: 'freebuff-chat-thinker-test', + agent, + agentDefinitions: [ + researcherWebAgent as AgentDefinition, + thinkerGeminiAgent as AgentDefinition, + ], + projectFiles: {}, + knowledgeFiles: {}, + maxAgentSteps: 12, + prompt, + costMode: 'normal', + handleStreamChunk: (chunk) => { + if (typeof chunk === 'object' && chunk?.type === 'reasoning_chunk') { + if (chunk.ancestorRunIds.length > 0) { + reasoningSubagentChars += chunk.chunk.length + process.stdout.write(`\x1b[2m${chunk.chunk}\x1b[0m`) + } + } + }, + handleEvent: (event) => { + if (event.type === 'subagent_start') { + console.log( + `\n[subagent_start] type=${event.agentType} name=${event.displayName} prompt=${JSON.stringify(event.prompt ?? '').slice(0, 200)}`, + ) + } else if (event.type === 'subagent_finish') { + console.log(`\n[subagent_finish] ${event.agentId}`) + } else if (event.type === 'tool_call') { + console.log( + `\n[tool_call] ${event.toolName} agentId=${event.agentId ?? '(root)'} input=${JSON.stringify(event.input).slice(0, 200)}`, + ) + } + }, +}) + +console.log('\n\n=== subagent reasoning chars:', reasoningSubagentChars, '===') +console.log('=== run output ===') +console.log(JSON.stringify(result.output, null, 2).slice(0, 1500)) diff --git a/freebuff/web/scripts/test-chat-title.ts b/freebuff/web/scripts/test-chat-title.ts new file mode 100644 index 0000000000..26c18e3632 --- /dev/null +++ b/freebuff/web/scripts/test-chat-title.ts @@ -0,0 +1,43 @@ +/** + * E2E smoke test for chat thread title generation (src/server/chat/title.ts). + * + * Confirms the title agent returns a short, clean, sensitive-info-free title + * for both the full-access (minimax-m3) and limited (deepseek-v4-flash) models. + * + * Run from freebuff/web with env loaded: + * sh -c 'set -a; source ../../.env.local; set +a; \ + * bun scripts/test-chat-title.ts' + */ +import { generateThreadTitle } from '../src/server/chat/title' + +const prompts = [ + 'How do I set up a Postgres connection pool in Next.js with Drizzle?', + "Hi! My name is Jane Doe, my email is jane.doe@example.com and my API key is sk-live-abc123. Can you help me debug why my Stripe webhook returns 400?", + 'whats the weather like, just saying hi', + 'Write a Python script to scrape product prices from a list of URLs and export to CSV', +] + +// full-access → minimax-m3, limited → deepseek-v4-flash +const models = ['minimax-m3', 'deepseek-v4-flash'] + +const controller = new AbortController() + +for (const model of models) { + console.log(`\n==================== model: ${model} ====================`) + for (const prompt of prompts) { + const start = Date.now() + const title = await generateThreadTitle({ + prompt, + model, + userId: 'test-user', + threadId: 'test-thread', + signal: controller.signal, + }) + const ms = Date.now() - start + console.log('\n────────────────────────────────────') + console.log('PROMPT:', prompt) + console.log(`TITLE (${ms}ms):`, JSON.stringify(title)) + } +} + +process.exit(0) diff --git a/freebuff/web/src/app/abuse/page.tsx b/freebuff/web/src/app/abuse/page.tsx new file mode 100644 index 0000000000..724d26b20c --- /dev/null +++ b/freebuff/web/src/app/abuse/page.tsx @@ -0,0 +1,1020 @@ +'use client' + +import { + AlertTriangle, + Ban, + ChevronDown, + ChevronRight, + Network, + RefreshCw, + ShieldCheck, +} from 'lucide-react' +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react' +import { toast } from 'sonner' + +import { Badge } from '@/vly/components/ui/badge' +import { Button } from '@/vly/components/ui/button' +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/vly/components/ui/card' +import { Checkbox } from '@/vly/components/ui/checkbox' +import { Skeleton } from '@/vly/components/ui/skeleton' +import { Toaster } from '@/vly/components/ui/sonner' + +// --- Mirrors of the server payload (kept local so the client bundle doesn't +// pull in the server-only detection module). --- + +type SuspectTier = 'high' | 'medium' +type BotSuspect = { + userId: string + email: string + name: string | null + status: string + model: string + ageDays: number + msgs24h: number + distinctHours24h: number + maxQuietGapHours24h: number | null + distinctAgents24h: number + msgsLifetime: number + githubId: string | null + githubAgeDays: number | null + flags: string[] + counterSignals: string[] + tier: SuspectTier + score: number +} +type CreationCluster = { + windowStart: string + windowEnd: string + emails: string[] +} +type SweepReport = { + generatedAt: string + totalSessions: number + activeCount: number + queuedCount: number + suspects: BotSuspect[] + creationClusters: CreationCluster[] +} + +type ApiAbuseSampleRun = { + runId: string + messages: number + clientIds: number + steps: number + status: string | null + totalSteps: number | null + durationMinutes: number | null + firstMessageAt: string + lastMessageAt: string +} +type ApiAbuseSuspect = { + userId: string + email: string | null + name: string | null + banned: boolean + userAgeDays: number | null + score: number + flags: string[] + messageCount: number + runCount: number + clientIdCount: number + missingStepMessages: number + missingStepRatio: number + maxMessagesPerRun: number + maxClientIdsPerRun: number + avgClientIdsPerRun: number + maxRunDurationMinutes: number | null + runningRunCount: number + completedRunCount: number + modelCount: number + agentCount: number + firstMessageAt: string + lastMessageAt: string + models: string[] + agents: string[] + sampleRuns: ApiAbuseSampleRun[] +} +type ApiAbuseReport = { + generatedAt: string + lookbackHours: number + minScore: number + totalScanned: number + suspects: ApiAbuseSuspect[] +} +type CombinedReport = { + apiAbuse: ApiAbuseReport | null + session: SweepReport | null +} + +type LoadState = + | { kind: 'loading' } + | { kind: 'forbidden'; status: number } + | { kind: 'error'; message: string } + | { kind: 'ready'; report: CombinedReport } + +const LOOKBACKS = [ + { label: '24h', hours: 24 }, + { label: '3d', hours: 72 }, + { label: '7d', hours: 168 }, + { label: '30d', hours: 720 }, +] + +export default function AbuseDashboardPage() { + const [state, setState] = useState<LoadState>({ kind: 'loading' }) + const [selected, setSelected] = useState<Set<string>>(new Set()) + const [banning, setBanning] = useState(false) + const [hours, setHours] = useState(168) + const [minScore, setMinScore] = useState(30) + + const load = useCallback(async () => { + setState({ kind: 'loading' }) + setSelected(new Set()) + try { + const res = await fetch( + `/api/admin/abuse?hours=${hours}&minScore=${minScore}`, + { cache: 'no-store' }, + ) + if (res.status === 401 || res.status === 403) { + setState({ kind: 'forbidden', status: res.status }) + return + } + if (!res.ok) { + const body = await res.json().catch(() => ({})) + setState({ + kind: 'error', + message: body?.error ?? `Request failed (${res.status})`, + }) + return + } + const report = (await res.json()) as CombinedReport + setState({ kind: 'ready', report }) + } catch (err) { + setState({ + kind: 'error', + message: err instanceof Error ? err.message : 'Network error', + }) + } + }, [hours, minScore]) + + useEffect(() => { + void load() + }, [load]) + + const apiSuspects = + state.kind === 'ready' ? (state.report.apiAbuse?.suspects ?? []) : [] + const sessionSuspects = + state.kind === 'ready' ? (state.report.session?.suspects ?? []) : [] + + // Email lookup across both reports — for the ban confirm preview. + const emailByUser = useMemo(() => { + const m = new Map<string, string>() + for (const s of apiSuspects) m.set(s.userId, s.email ?? s.userId) + for (const s of sessionSuspects) if (!m.has(s.userId)) m.set(s.userId, s.email) + return m + }, [apiSuspects, sessionSuspects]) + + const toggle = (userId: string) => + setSelected((prev) => { + const next = new Set(prev) + if (next.has(userId)) next.delete(userId) + else next.add(userId) + return next + }) + + const selectMany = (userIds: string[], on: boolean) => + setSelected((prev) => { + const next = new Set(prev) + for (const id of userIds) { + if (on) next.add(id) + else next.delete(id) + } + return next + }) + + const banSelected = async () => { + const userIds = [...selected] + if (userIds.length === 0) return + const emails = userIds.map((id) => emailByUser.get(id) ?? id) + const preview = emails.slice(0, 10).join('\n') + const ok = window.confirm( + `Ban ${userIds.length} user(s)? Sets banned=true and clears their free ` + + `sessions.\n\n${preview}${ + userIds.length > 10 ? `\n…and ${userIds.length - 10} more` : '' + }`, + ) + if (!ok) return + + setBanning(true) + try { + const res = await fetch('/api/admin/abuse', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userIds }), + }) + const body = await res.json().catch(() => ({})) + if (!res.ok) { + toast.error(body?.error ?? `Ban failed (${res.status})`) + return + } + toast.success( + `Banned ${body.bannedEmails?.length ?? userIds.length} user(s); ` + + `cleared ${body.freeSessionsCleared ?? 0} free session(s).`, + ) + await load() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Ban request failed') + } finally { + setBanning(false) + } + } + + if (state.kind === 'forbidden') { + return ( + <CenteredMessage + icon={<AlertTriangle className="mx-auto mb-4 h-16 w-16 text-red-500" />} + title="Access Restricted" + subtitle={ + state.status === 401 + ? 'Please sign in with an admin account.' + : 'Admin access required (@codebuff.com or allow-listed email).' + } + /> + ) + } + + const report = state.kind === 'ready' ? state.report : null + + return ( + <div className="min-h-screen bg-background"> + {/* Root layout has no Toaster (only the /web group does), so mount one. */} + <Toaster position="top-right" richColors /> + <div className="mx-auto max-w-[1400px] p-6"> + {/* Header + controls */} + <div className="mb-6 flex flex-wrap items-end justify-between gap-4"> + <div> + <h1 className="flex items-center gap-2 text-2xl font-bold text-foreground"> + <ShieldCheck className="h-6 w-6 text-foreground" /> + Freebuff Abuse Review + </h1> + <p className="mt-1 max-w-3xl text-sm text-muted-foreground"> + Request-level proxy/farm fingerprints (the strong signal for free + API reselling) plus active-session behavioral heuristics. Select + rows and ban — sets <code>banned=true</code> and clears free + sessions. + </p> + </div> + <div className="flex flex-wrap items-end gap-3"> + <div> + <label className="mb-1 block text-xs font-medium text-muted-foreground"> + Lookback + </label> + <div className="flex overflow-hidden rounded-md border border-border"> + {LOOKBACKS.map((l) => ( + <button + key={l.hours} + onClick={() => setHours(l.hours)} + className={`px-3 py-1.5 text-sm ${ + hours === l.hours + ? 'bg-primary text-primary-foreground' + : 'bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground' + }`} + > + {l.label} + </button> + ))} + </div> + </div> + <div> + <label className="mb-1 block text-xs font-medium text-muted-foreground"> + Min score + </label> + <input + type="number" + value={minScore} + min={0} + onChange={(e) => setMinScore(Number(e.target.value) || 0)} + className="w-20 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-foreground" + /> + </div> + <Button + variant="outline" + size="sm" + onClick={() => void load()} + disabled={state.kind === 'loading'} + > + <RefreshCw + className={`mr-2 h-4 w-4 ${ + state.kind === 'loading' ? 'animate-spin' : '' + }`} + /> + Refresh + </Button> + </div> + </div> + + {state.kind === 'loading' && <LoadingSkeleton />} + + {state.kind === 'error' && ( + <Card className="border-red-500/30 bg-red-500/10"> + <CardContent className="p-4 text-sm text-red-400"> + Failed to load report: {state.message} + </CardContent> + </Card> + )} + + {report && ( + <> + <SummaryRow report={report} /> + + {/* Sticky action bar */} + <div className="sticky top-0 z-20 mb-4 flex flex-wrap items-center gap-3 border-b border-border bg-background/95 py-3 backdrop-blur"> + <span className="text-sm font-medium text-foreground"> + {selected.size} selected + </span> + <Button + variant="ghost" + size="sm" + onClick={() => setSelected(new Set())} + disabled={selected.size === 0} + > + Clear + </Button> + <div className="ml-auto"> + <Button + variant="destructive" + size="sm" + onClick={() => void banSelected()} + disabled={selected.size === 0 || banning} + > + <Ban className="mr-2 h-4 w-4" /> + {banning ? 'Banning…' : `Ban selected (${selected.size})`} + </Button> + </div> + </div> + + <ApiAbuseSection + report={report.apiAbuse} + selected={selected} + onToggle={toggle} + onSelectMany={selectMany} + /> + + <SessionSection + report={report.session} + selected={selected} + onToggle={toggle} + onSelectMany={selectMany} + /> + + <p className="mt-6 text-xs text-muted-foreground"> + Signals are heuristics — review flags, counter-signals & sample + runs before banning. Established accounts with real agent steps are + dampened, not cleared. + </p> + </> + )} + </div> + </div> + ) +} + +function SummaryRow({ report }: { report: CombinedReport }) { + const api = report.apiAbuse + const session = report.session + const apiCount = api?.suspects.length ?? 0 + const strongFanout = + api?.suspects.filter((s) => s.maxClientIdsPerRun >= 10).length ?? 0 + const stats = [ + { label: 'API suspects', value: api ? apiCount : '—', danger: apiCount > 0 }, + { label: 'Proxy fanout ≥10', value: strongFanout, danger: strongFanout > 0 }, + { label: 'Scanned (window)', value: api?.totalScanned ?? '—' }, + { label: 'Live sessions', value: session?.totalSessions ?? '—' }, + { label: 'Active / queued', value: session ? `${session.activeCount} / ${session.queuedCount}` : '—' }, + { label: 'Session suspects', value: session?.suspects.length ?? '—' }, + ] + return ( + <div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> + {stats.map((s) => ( + <Card key={s.label} className="border-border"> + <CardContent className="p-4"> + <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> + {s.label} + </p> + <p + className={`mt-1 text-2xl font-bold ${ + s.danger ? 'text-red-400' : 'text-foreground' + }`} + > + {s.value} + </p> + </CardContent> + </Card> + ))} + </div> + ) +} + +// --- API / proxy abuse (primary) --- + +function ApiAbuseSection({ + report, + selected, + onToggle, + onSelectMany, +}: { + report: ApiAbuseReport | null + selected: Set<string> + onToggle: (id: string) => void + onSelectMany: (ids: string[], on: boolean) => void +}) { + const [expanded, setExpanded] = useState<Set<string>>(new Set()) + const toggleExpand = (id: string) => + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + if (!report) { + return ( + <SectionShell + title="API / proxy abuse" + subtitle="Request-level scan failed — see server logs." + > + <div className="py-6 text-center text-sm text-red-400"> + Could not load the request-level scan. + </div> + </SectionShell> + ) + } + + const suspects = report.suspects + const ids = suspects.map((s) => s.userId) + const allSelected = ids.length > 0 && ids.every((id) => selected.has(id)) + + return ( + <SectionShell + title="API / proxy abuse" + icon={<Network className="h-4 w-4 text-muted-foreground" />} + subtitle={`Held-open proxy runs & sock farms over the last ${formatHours( + report.lookbackHours, + )} · score ≥ ${report.minScore} · ${report.totalScanned} accounts scanned`} + action={ + suspects.length > 0 ? ( + <Button + variant="outline" + size="sm" + onClick={() => onSelectMany(ids, !allSelected)} + > + {allSelected ? 'Deselect all' : 'Select all'} + </Button> + ) : null + } + > + {suspects.length === 0 ? ( + <div className="py-10 text-center text-muted-foreground"> + No request-level proxy/farm suspects in this window. 🎉 + </div> + ) : ( + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground"> + <tr> + <th className="w-8 px-2 py-2" /> + <th className="w-8 px-2 py-2" /> + <th className="px-3 py-2 font-medium">Account</th> + <th className="px-3 py-2 text-right font-medium">Score</th> + <th className="px-3 py-2 text-right font-medium">Msgs</th> + <th className="px-3 py-2 text-right font-medium">Runs</th> + <th className="px-3 py-2 text-right font-medium">Clients</th> + <th + className="px-3 py-2 text-right font-medium" + title="Max distinct client_ids in a single run — proxy fanout" + > + Max c/run + </th> + <th + className="px-3 py-2 text-right font-medium" + title="Share of messages with no agent_step — proxy/farm tell" + > + No-step% + </th> + <th + className="px-3 py-2 text-right font-medium" + title="Longest single run (held-open socket)" + > + Max dur + </th> + <th className="px-3 py-2 font-medium">Flags</th> + </tr> + </thead> + <tbody> + {suspects.map((s) => { + const isSel = selected.has(s.userId) + const isOpen = expanded.has(s.userId) + return ( + <Fragment key={s.userId}> + <tr + className={`border-b border-border align-top ${ + isSel ? 'bg-red-500/10' : 'hover:bg-muted/50' + }`} + > + <td className="px-2 py-2"> + <Checkbox + checked={isSel} + onCheckedChange={() => onToggle(s.userId)} + /> + </td> + <td className="px-2 py-2"> + <button + onClick={() => toggleExpand(s.userId)} + className="text-muted-foreground hover:text-foreground" + aria-label="expand" + > + {isOpen ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + </td> + <td className="px-3 py-2"> + <div className="font-medium text-foreground"> + {s.email ?? ( + <span className="italic text-muted-foreground"> + {s.userId.slice(0, 8)}… + </span> + )} + {s.banned && ( + <Badge className="ml-2 border-border bg-muted text-muted-foreground"> + banned + </Badge> + )} + </div> + <div className="text-xs text-muted-foreground"> + {s.name || <span className="italic">no name</span>} + {s.userAgeDays !== null && + ` · acct ${s.userAgeDays.toFixed(1)}d`} + {s.runningRunCount > 0 && + ` · ${s.runningRunCount} running`} + </div> + </td> + <td className="px-3 py-2 text-right"> + <ScoreBadge score={s.score} /> + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.messageCount.toLocaleString()} + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.runCount.toLocaleString()} + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.clientIdCount.toLocaleString()} + </td> + <td + className={`px-3 py-2 text-right font-semibold ${ + s.maxClientIdsPerRun >= 10 + ? 'text-red-400' + : s.maxClientIdsPerRun >= 3 + ? 'text-amber-400' + : 'text-foreground' + }`} + > + {s.maxClientIdsPerRun} + </td> + <td + className={`px-3 py-2 text-right font-semibold ${ + s.missingStepRatio >= 0.9 + ? 'text-red-400' + : s.missingStepRatio >= 0.5 + ? 'text-amber-400' + : 'text-foreground' + }`} + > + {Math.round(s.missingStepRatio * 100)}% + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.maxRunDurationMinutes === null + ? '—' + : formatDuration(s.maxRunDurationMinutes)} + </td> + <td className="px-3 py-2"> + <FlagChips flags={s.flags} /> + </td> + </tr> + {isOpen && ( + <tr className="bg-muted/30"> + <td /> + <td /> + <td colSpan={9} className="px-3 py-3"> + <ApiAbuseDetail suspect={s} /> + </td> + </tr> + )} + </Fragment> + ) + })} + </tbody> + </table> + </div> + )} + </SectionShell> + ) +} + +function ApiAbuseDetail({ suspect }: { suspect: ApiAbuseSuspect }) { + return ( + <div className="space-y-3"> + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> + <KV label="Avg clients/run" value={suspect.avgClientIdsPerRun.toFixed(1)} /> + <KV label="Max msgs/run" value={String(suspect.maxMessagesPerRun)} /> + <KV + label="Runs (run/done)" + value={`${suspect.runningRunCount} / ${suspect.completedRunCount}`} + /> + <KV + label="Active window" + value={`${shortTime(suspect.firstMessageAt)} → ${shortTime( + suspect.lastMessageAt, + )}`} + /> + </div> + + <div className="flex flex-wrap gap-4 text-xs"> + <div> + <span className="font-medium text-muted-foreground"> + Models ({suspect.modelCount}): + </span>{' '} + <span className="text-foreground"> + {suspect.models.slice(0, 10).join(', ')} + {suspect.models.length > 10 ? ', …' : ''} + </span> + </div> + <div> + <span className="font-medium text-muted-foreground"> + Agents ({suspect.agentCount}): + </span>{' '} + <span className="text-foreground">{suspect.agents.join(', ')}</span> + </div> + </div> + + {suspect.sampleRuns.length > 0 && ( + <div> + <p className="mb-1 text-xs font-medium text-muted-foreground"> + Top runs by client fanout + </p> + <div className="overflow-x-auto rounded border border-border bg-card"> + <table className="w-full text-xs"> + <thead className="border-b border-border text-left text-muted-foreground"> + <tr> + <th className="px-2 py-1 font-medium">Run</th> + <th className="px-2 py-1 text-right font-medium">Msgs</th> + <th className="px-2 py-1 text-right font-medium">Clients</th> + <th className="px-2 py-1 text-right font-medium">Steps</th> + <th className="px-2 py-1 font-medium">Status</th> + <th className="px-2 py-1 text-right font-medium">Duration</th> + </tr> + </thead> + <tbody> + {suspect.sampleRuns.map((r) => ( + <tr key={r.runId} className="border-b border-border"> + <td className="px-2 py-1 font-mono text-muted-foreground"> + {r.runId.slice(0, 8)}… + </td> + <td className="px-2 py-1 text-right text-foreground"> + {r.messages} + </td> + <td + className={`px-2 py-1 text-right ${ + r.clientIds >= 10 + ? 'font-semibold text-red-400' + : 'text-foreground' + }`} + > + {r.clientIds} + </td> + <td className="px-2 py-1 text-right text-foreground"> + {r.steps} + {r.totalSteps !== null ? `/${r.totalSteps}` : ''} + </td> + <td className="px-2 py-1 text-foreground"> + {r.status ?? '—'} + </td> + <td className="px-2 py-1 text-right text-foreground"> + {r.durationMinutes === null + ? '—' + : formatDuration(r.durationMinutes)} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + )} + </div> + ) +} + +// --- Active-session behavioral suspects (secondary) --- + +function SessionSection({ + report, + selected, + onToggle, + onSelectMany, +}: { + report: SweepReport | null + selected: Set<string> + onToggle: (id: string) => void + onSelectMany: (ids: string[], on: boolean) => void +}) { + if (!report) return null + const suspects = report.suspects + const ids = suspects.map((s) => s.userId) + const allSelected = ids.length > 0 && ids.every((id) => selected.has(id)) + + return ( + <SectionShell + className="mt-8" + title="Active-session behavioral suspects" + subtitle="Heuristics over currently-admitted free sessions (24/7 usage, volume, region, GitHub age). Coarser than the request-level scan above." + action={ + suspects.length > 0 ? ( + <Button + variant="outline" + size="sm" + onClick={() => onSelectMany(ids, !allSelected)} + > + {allSelected ? 'Deselect all' : 'Select all'} + </Button> + ) : null + } + > + {suspects.length === 0 ? ( + <div className="py-8 text-center text-muted-foreground"> + No active-session suspects right now. + </div> + ) : ( + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead className="border-b border-border bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground"> + <tr> + <th className="w-8 px-2 py-2" /> + <th className="px-3 py-2 font-medium">Account</th> + <th className="px-3 py-2 font-medium">Tier</th> + <th className="px-3 py-2 text-right font-medium">Score</th> + <th className="px-3 py-2 text-right font-medium">Age</th> + <th className="px-3 py-2 text-right font-medium">GH age</th> + <th className="px-3 py-2 text-right font-medium">Msgs 24h</th> + <th className="px-3 py-2 text-right font-medium">Agents</th> + <th className="px-3 py-2 font-medium">Signals</th> + </tr> + </thead> + <tbody> + {suspects.map((s) => { + const isSel = selected.has(s.userId) + return ( + <tr + key={s.userId} + className={`border-b border-border align-top ${ + isSel ? 'bg-red-500/10' : 'hover:bg-muted/50' + }`} + > + <td className="px-2 py-2"> + <Checkbox + checked={isSel} + onCheckedChange={() => onToggle(s.userId)} + /> + </td> + <td className="px-3 py-2"> + <div className="font-medium text-foreground">{s.email}</div> + <div className="text-xs text-muted-foreground"> + {s.name || <span className="italic">no name</span>} ·{' '} + {s.status} · {s.model} + </div> + </td> + <td className="px-3 py-2"> + <Badge + className={ + s.tier === 'high' + ? 'border-red-500/30 bg-red-500/15 text-red-400' + : 'border-amber-500/30 bg-amber-500/15 text-amber-400' + } + > + {s.tier} + </Badge> + </td> + <td className="px-3 py-2 text-right font-semibold text-foreground"> + {s.score} + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.ageDays.toFixed(1)}d + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.githubAgeDays !== null + ? `${s.githubAgeDays.toFixed(0)}d` + : s.githubId === null + ? 'n/a' + : '?'} + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.msgs24h} + </td> + <td className="px-3 py-2 text-right text-foreground"> + {s.distinctAgents24h} + </td> + <td className="px-3 py-2"> + <FlagChips flags={s.flags} counter={s.counterSignals} /> + </td> + </tr> + ) + })} + </tbody> + </table> + </div> + )} + + <CreationClusters clusters={report.creationClusters} /> + </SectionShell> + ) +} + +// --- Shared bits --- + +function SectionShell({ + title, + subtitle, + icon, + action, + children, + className, +}: { + title: string + subtitle?: string + icon?: React.ReactNode + action?: React.ReactNode + children: React.ReactNode + className?: string +}) { + return ( + <Card className={`overflow-hidden border-border ${className ?? ''}`}> + <CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0 border-b border-border bg-card pb-3"> + <div> + <CardTitle className="flex items-center gap-2 text-base text-foreground"> + {icon} + {title} + </CardTitle> + {subtitle && ( + <p className="mt-1 max-w-3xl text-xs text-muted-foreground">{subtitle}</p> + )} + </div> + {action} + </CardHeader> + <CardContent className="p-0">{children}</CardContent> + </Card> + ) +} + +function ScoreBadge({ score }: { score: number }) { + const cls = + score >= 60 + ? 'bg-red-500/15 text-red-400 border-red-500/30' + : score >= 40 + ? 'bg-amber-500/15 text-amber-400 border-amber-500/30' + : 'bg-muted text-muted-foreground border-border' + return ( + <Badge className={`${cls} font-mono`}>{score}</Badge> + ) +} + +function FlagChips({ + flags, + counter, +}: { + flags: string[] + counter?: string[] +}) { + return ( + <div className="flex max-w-md flex-wrap gap-1"> + {flags.map((f) => ( + <span + key={f} + className="rounded bg-red-500/15 px-1.5 py-0.5 text-[11px] text-red-400" + > + {f} + </span> + ))} + {counter?.map((c) => ( + <span + key={c} + className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-400" + > + {c} + </span> + ))} + </div> + ) +} + +function KV({ label, value }: { label: string; value: string }) { + return ( + <div className="rounded border border-border bg-card px-3 py-2"> + <p className="text-[11px] uppercase tracking-wide text-muted-foreground"> + {label} + </p> + <p className="text-sm font-medium text-foreground">{value}</p> + </div> + ) +} + +function CreationClusters({ clusters }: { clusters: CreationCluster[] }) { + if (clusters.length === 0) return null + return ( + <div className="border-t border-border p-4"> + <p className="mb-2 text-sm font-medium text-foreground"> + Signup clusters ({clusters.length}) + </p> + <p className="mb-3 text-xs text-muted-foreground"> + Accounts created within 30 min of each other — possible mass signup. + </p> + <div className="space-y-3"> + {clusters.map((c, i) => ( + <div key={i} className="rounded border border-border p-3"> + <div className="mb-1 text-xs text-muted-foreground"> + {new Date(c.windowStart).toLocaleString()} —{' '} + {new Date(c.windowEnd).toLocaleString()} · n={c.emails.length} + </div> + <div className="flex flex-wrap gap-1"> + {c.emails.map((e) => ( + <span + key={e} + className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground" + > + {e} + </span> + ))} + </div> + </div> + ))} + </div> + </div> + ) +} + +function LoadingSkeleton() { + return ( + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> + {[...Array(6)].map((_, i) => ( + <Skeleton key={i} className="h-20 w-full" /> + ))} + </div> + <Skeleton className="h-72 w-full" /> + </div> + ) +} + +function CenteredMessage({ + icon, + title, + subtitle, +}: { + icon: React.ReactNode + title: string + subtitle: string +}) { + return ( + <div className="flex min-h-screen items-center justify-center bg-background"> + <div className="text-center text-foreground"> + {icon} + <h1 className="mb-2 text-3xl font-bold">{title}</h1> + <p className="text-lg text-muted-foreground">{subtitle}</p> + </div> + </div> + ) +} + +function formatHours(h: number): string { + if (h % 24 === 0) return `${h / 24}d` + return `${h}h` +} + +function formatDuration(minutes: number): string { + if (minutes < 60) return `${Math.round(minutes)}m` + const hrs = minutes / 60 + if (hrs < 48) return `${hrs.toFixed(1)}h` + return `${(hrs / 24).toFixed(1)}d` +} + +function shortTime(iso: string): string { + if (!iso) return '—' + const d = new Date(iso) + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString() +} diff --git a/freebuff/web/src/app/api/account/providers/route.ts b/freebuff/web/src/app/api/account/providers/route.ts new file mode 100644 index 0000000000..f65d79a01e --- /dev/null +++ b/freebuff/web/src/app/api/account/providers/route.ts @@ -0,0 +1,30 @@ +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { eq } from 'drizzle-orm' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' + +export const runtime = 'nodejs' + +export type LinkedProvidersData = { + /** OAuth sign-in providers linked to the signed-in user (e.g. ['github']). */ + providers: string[] +} + +export async function GET() { + const session = await getServerSession(authOptions) + const userId = session?.user?.id + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const rows = await db + .select({ provider: schema.account.provider }) + .from(schema.account) + .where(eq(schema.account.userId, userId)) + + const providers = Array.from(new Set(rows.map((r) => r.provider))) + return NextResponse.json({ providers } satisfies LinkedProvidersData) +} diff --git a/freebuff/web/src/app/api/admin/abuse/route.ts b/freebuff/web/src/app/api/admin/abuse/route.ts new file mode 100644 index 0000000000..44656ab512 --- /dev/null +++ b/freebuff/web/src/app/api/admin/abuse/route.ts @@ -0,0 +1,136 @@ +import { utils } from '@codebuff/internal' +import { + banSuspects, + identifyApiAbuseSuspects, + identifyBotSuspects, +} from '@codebuff/internal/freebuff-abuse' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +export const runtime = 'nodejs' +// Suspect identification fans out to the GitHub API and runs several +// aggregate queries; give it room beyond the default. +export const maxDuration = 120 + +/** + * Resolve the current session to an admin user, or return a NextResponse + * error. Admins are `@codebuff.com` accounts or allow-listed emails + * (see `isCodebuffAdmin`). The page itself renders for everyone; the data + * and the ban action are gated here, server-side, against the shared + * Postgres user table. + */ +async function requireAdmin(): Promise<utils.AdminUser | NextResponse> { + const session = await getServerSession(authOptions) + const adminUser = await utils.checkSessionIsAdmin(session) + if (!adminUser) { + if (session?.user?.id) { + logger.warn( + { userId: session.user.id }, + 'Non-admin hit freebuff /abuse endpoint', + ) + } + return NextResponse.json( + { error: 'Forbidden — admin access required' }, + { status: session?.user?.id ? 403 : 401 }, + ) + } + return adminUser +} + +const clampInt = ( + raw: string | null, + fallback: number, + min: number, + max: number, +): number => { + const n = Number.parseInt(raw ?? '', 10) + if (!Number.isFinite(n)) return fallback + return Math.min(max, Math.max(min, n)) +} + +/** + * Combined abuse report: + * - `apiAbuse`: the strong request-level proxy/farm scanner over a lookback + * window (`?hours=`, `?minScore=`). This is the one that catches resellers. + * - `session`: behavioral heuristics over currently-active free sessions. + * Both run in parallel; one failing doesn't sink the other. + */ +export async function GET(req: Request) { + const admin = await requireAdmin() + if (admin instanceof NextResponse) return admin + + const url = new URL(req.url) + const hours = clampInt(url.searchParams.get('hours'), 168, 1, 24 * 90) + const minScore = clampInt(url.searchParams.get('minScore'), 30, 0, 1000) + + const [apiAbuse, session] = await Promise.allSettled([ + identifyApiAbuseSuspects({ logger, hours, minScore }), + identifyBotSuspects({ logger }), + ]) + + if (apiAbuse.status === 'rejected' && session.status === 'rejected') { + logger.error( + { apiAbuse: apiAbuse.reason, session: session.reason }, + 'freebuff /abuse report failed (both)', + ) + return NextResponse.json( + { error: 'Failed to build suspect report' }, + { status: 500 }, + ) + } + if (apiAbuse.status === 'rejected') { + logger.error({ error: apiAbuse.reason }, 'freebuff /abuse api-scan failed') + } + if (session.status === 'rejected') { + logger.error({ error: session.reason }, 'freebuff /abuse session-scan failed') + } + + return NextResponse.json({ + apiAbuse: apiAbuse.status === 'fulfilled' ? apiAbuse.value : null, + session: session.status === 'fulfilled' ? session.value : null, + }) +} + +/** Ban the selected users (sets banned=true, clears their free sessions). */ +export async function POST(req: Request) { + const admin = await requireAdmin() + if (admin instanceof NextResponse) return admin + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + const userIds = (body as { userIds?: unknown })?.userIds + if ( + !Array.isArray(userIds) || + userIds.length === 0 || + !userIds.every((id) => typeof id === 'string') + ) { + return NextResponse.json( + { error: 'Body must be { userIds: string[] } with at least one id' }, + { status: 400 }, + ) + } + + try { + const result = await banSuspects({ userIds: userIds as string[], logger }) + logger.info( + { + adminEmail: admin.email, + requested: userIds.length, + banned: result.bannedEmails.length, + }, + 'freebuff /abuse ban action', + ) + return NextResponse.json({ ok: true, ...result }) + } catch (error) { + logger.error({ error }, 'freebuff /abuse ban failed') + return NextResponse.json({ error: 'Ban failed' }, { status: 500 }) + } +} diff --git a/freebuff/web/src/app/api/admin/chat-attachment-sweep/route.ts b/freebuff/web/src/app/api/admin/chat-attachment-sweep/route.ts new file mode 100644 index 0000000000..6697579440 --- /dev/null +++ b/freebuff/web/src/app/api/admin/chat-attachment-sweep/route.ts @@ -0,0 +1,79 @@ +import { timingSafeEqual } from 'crypto' + +import { env } from '@codebuff/internal/env' +import { NextResponse } from 'next/server' + +import { CHAT_DOC_RETENTION_DAYS } from '@/app/chat/models' +import { runAttachmentSweep } from '@/server/chat/attachment-sweep' +import { getBlobStore } from '@/server/chat/blob-store' +import { + clearMessageAttachments, + listExpiredAttachmentMessages, +} from '@/server/chat/store' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +export const runtime = 'nodejs' +// Deletes blobs and updates rows in batches; give it room beyond the default. +export const maxDuration = 120 + +// Messages processed per DB round-trip, and a hard cap on batches per call so a +// single invocation stays bounded (a daily cron easily drains the backlog). +const BATCH_SIZE = 500 +const MAX_BATCHES = 20 + +/** + * Chat-attachment-sweep endpoint, called on a schedule by the + * chat-attachment-sweep GitHub Action. Reclaims storage by deleting the + * extracted-text blobs of document attachments older than + * CHAT_DOC_RETENTION_DAYS, then clearing those messages' `attachments` refs so + * the sweep is idempotent and no dangling refs remain. Conversation content is + * untouched — only now-dead blob references go away, and the chat loaders + * already skip missing blobs gracefully. + * + * Auth: static bearer token from CHAT_ATTACHMENT_SWEEP_SECRET — lets CI call the + * endpoint without a NextAuth session and keeps prod DATABASE_URL out of GitHub + * secrets (mirrors /api/admin/referral-sweep). + */ +export async function POST(req: NextRequest) { + const secret = env.CHAT_ATTACHMENT_SWEEP_SECRET + if (!secret) { + return NextResponse.json( + { + error: + 'chat-attachment-sweep not configured (CHAT_ATTACHMENT_SWEEP_SECRET missing)', + }, + { status: 503 }, + ) + } + + const authHeader = req.headers.get('Authorization') ?? '' + const expected = `Bearer ${secret}` + const a = Buffer.from(authHeader) + const b = Buffer.from(expected) + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + const cutoff = new Date( + Date.now() - CHAT_DOC_RETENTION_DAYS * 24 * 60 * 60 * 1000, + ) + try { + const blobStore = getBlobStore() + const result = await runAttachmentSweep( + { + listExpired: listExpiredAttachmentMessages, + deleteBlobs: (ids) => blobStore.deleteMany(ids), + clearRefs: clearMessageAttachments, + }, + { cutoff, batchSize: BATCH_SIZE, maxBatches: MAX_BATCHES }, + ) + const payload = { ...result, retentionDays: CHAT_DOC_RETENTION_DAYS } + logger.info(payload, 'chat-attachment-sweep complete') + return NextResponse.json({ ok: true, ...payload }) + } catch (error) { + logger.error({ error }, 'chat-attachment-sweep failed') + return NextResponse.json({ error: 'sweep failed' }, { status: 500 }) + } +} diff --git a/freebuff/web/src/app/api/ads/route.ts b/freebuff/web/src/app/api/ads/route.ts new file mode 100644 index 0000000000..5aa10d3d12 --- /dev/null +++ b/freebuff/web/src/app/api/ads/route.ts @@ -0,0 +1,202 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { z } from 'zod' + +import { gravityAds } from '@gravity-ai/api' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' +import type { + GravityContext, + MessageObject, + PlacementObject, +} from '@gravity-ai/api' + +const messageSchema = z.object({ + role: z.string(), + content: z.string(), +}) + +const bodySchema = z.object({ + messages: z.array(messageSchema).optional().default([]), + sessionId: z.string().optional(), + gravity_context: z.record(z.string(), z.unknown()).optional(), + surface: z + .enum(['freebuff_web_chat', 'chat_assistant', 'chat_assistant_sr']) + .optional(), + placementId: z.string().optional(), +}) + +function getPlacements(data: z.infer<typeof bodySchema>): PlacementObject[] { + if (data.placementId === 'Above-iFrame') { + return [{ placement: 'top_page', placement_id: 'Above-iFrame' }] + } + + if (data.placementId === 'Web-Inside-Input-Box') { + return [ + { + placement: 'inline_response', + placement_id: 'Web-Inside-Input-Box', + }, + ] + } + + if (data.surface === 'chat_assistant') { + return [ + { + placement: 'inline_response', + placement_id: 'Chat-Assistant-Above-Input', + }, + ] + } + + // Server-rendered-ads experiment arm (see chat/_components/ad-experiment.ts): + // same slot above the composer, but a distinct placement id so Gravity can + // attach a `renderer_spec` to responses and dashboard breakdowns separate + // the arms. Any `renderer_spec` on the ad passes through normalizeGravityAd + // untouched. + if (data.surface === 'chat_assistant_sr') { + return [ + { + placement: 'inline_response', + placement_id: 'Chat-Assistant-Above-Input-SR', + }, + ] + } + + if (data.surface === 'freebuff_web_chat') { + return [ + { + placement: 'inline_response', + placement_id: 'Web-Chat-After-User-Message', + }, + { + placement: 'inline_response', + placement_id: 'Web-Chat-After-Assistant-Message', + }, + ] + } + + return [ + { + placement: 'below_response', + placement_id: data.placementId ?? 'Single-Ad-Unit-1', + }, + ] +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function withTrustedUserContext( + data: z.infer<typeof bodySchema>, + userId: string, +): GravityContext { + const context = isRecord(data.gravity_context) ? data.gravity_context : {} + const user = isRecord(context.user) ? context.user : {} + + return { + ...context, + sessionId: + typeof context.sessionId === 'string' + ? context.sessionId + : data.sessionId ?? userId, + user: { + ...user, + id: userId, + userId, + }, + device: isRecord(context.device) ? context.device : {}, + } as GravityContext +} + +function normalizeGravityAd(ad: Record<string, unknown>) { + return { + ...ad, + placementId: + typeof ad.placementId === 'string' + ? ad.placementId + : typeof ad.placement_id === 'string' + ? ad.placement_id + : undefined, + } +} + +function toGravitySdkMessages( + messages: z.infer<typeof bodySchema>['messages'], +): MessageObject[] { + return messages.flatMap((message) => { + if (message.role !== 'user' && message.role !== 'assistant') return [] + return [{ role: message.role, content: message.content }] + }) +} + +export async function POST(request: NextRequest) { + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // The Gravity SDK reads `process.env.GRAVITY_API_KEY` implicitly and + // silently returns zero ads (no error) when it's missing. Read it + // explicitly so we (a) pass it to the SDK regardless of how Next.js + // exposes env at runtime and (b) emit a loud warning if it isn't set, + // which is otherwise an invisible "no ads ever" failure mode. + const apiKey = process.env.GRAVITY_API_KEY + if (!apiKey) { + logger.warn( + '[ads] GRAVITY_API_KEY is not configured; returning no ads. Set it in the Freebuff Web deployment environment.', + ) + return NextResponse.json({ ads: [], provider: 'gravity' }) + } + + const messages = toGravitySdkMessages(parsed.data.messages) + const body = { + messages, + sessionId: parsed.data.sessionId, + gravity_context: withTrustedUserContext(parsed.data, session.user.id), + } + + const result = await gravityAds( + { + body, + headers: Object.fromEntries(request.headers.entries()), + }, + messages, + getPlacements(parsed.data), + { + apiKey, + production: true, + relevancy: 0, + }, + ) + + if (result.error) { + logger.warn( + { + status: result.status, + elapsed: result.elapsed, + error: result.error, + }, + 'Gravity SDK ad request failed', + ) + } + + return NextResponse.json({ + ads: result.ads.map((ad) => + normalizeGravityAd(ad as unknown as Record<string, unknown>), + ), + provider: 'gravity', + }) +} diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts index 53a0d05aea..3e87bb3b7d 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/auth-options.ts @@ -1,189 +1,123 @@ -// TODO: Extract shared auth config to packages/auth to avoid duplication with web/src/app/api/auth/[...nextauth]/auth-options.ts -import { DrizzleAdapter } from '@auth/drizzle-adapter' +import { createAuthOptions } from '@codebuff/auth' import { trackEvent } from '@codebuff/common/analytics' import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { SESSION_MAX_AGE_SECONDS } from '@codebuff/common/old-constants' -import { loops } from '@codebuff/internal' -import db from '@codebuff/internal/db' -import * as schema from '@codebuff/internal/db/schema' import { env } from '@codebuff/internal/env' -import { stripeServer } from '@codebuff/internal/util/stripe' -import { logSyncFailure } from '@codebuff/internal/util/sync-failure' -import { eq } from 'drizzle-orm' -import GitHubProvider from 'next-auth/providers/github' import type { NextAuthOptions } from 'next-auth' -import type { Adapter } from 'next-auth/adapters' +import { Resend } from 'resend' import { getCliAuthCodeHashPrefix, getCliAuthOnboardSearchParams, isCliAuthCodeCandidate, } from '@/app/onboard/_helpers' +import { getFreebuffNextAuthUrl } from '../../../../lib/freebuff-server-env' import { logger } from '@/util/logger' -async function createAndLinkStripeCustomer(params: { +const useJwtOnlyAuth = + env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev' && + process.env.FREEBUFF_DEV_AUTH_WITHOUT_DB === 'true' + +const FREEBUFF_FROM_EMAIL = 'James from Freebuff <james@mail.freebuff.app>' +const FREEBUFF_REPLY_TO_EMAIL = 'support@codebuff.com' +const DISCORD_INVITE_URL = 'https://discord.gg/yXG3w7wxfs' + +function firstNameFromDisplayName(name?: string | null): string { + const trimmed = (name ?? '').trim() + if (!trimmed) { + return 'there' + } + return trimmed.split(/\s+/)[0] ?? 'there' +} + +async function sendFreebuffWelcomeEmail(params: { userId: string email: string | null name: string | null -}): Promise<string | null> { - const { userId, email, name } = params - - if (!email || !name) { +}) { + if (!params.email) { logger.warn( - { userId }, - 'User email or name missing, cannot create Stripe customer.', + { userId: params.userId }, + 'User email missing, cannot send welcome email.', ) - return null + return } - try { - const customer = await stripeServer.customers.create({ - email, - name, - metadata: { - user_id: userId, - }, - }) - - await db - .update(schema.user) - .set({ - stripe_customer_id: customer.id, - }) - .where(eq(schema.user.id, userId)) - logger.info( - { userId, customerId: customer.id }, - 'Stripe customer created and linked to user.', + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) { + logger.warn( + { userId: params.userId }, + 'RESEND_API_KEY is not configured; skipping welcome email.', ) - return customer.id - } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Unknown error creating Stripe customer' + return + } + + const firstName = firstNameFromDisplayName(params.name) + const subject = 'Welcome to Freebuff' + const text = [ + `Hi ${firstName},`, + '', + `Welcome to Freebuff. My name is James, and I’ll be your point of contact here.`, + '', + `You can email me any time at ${FREEBUFF_REPLY_TO_EMAIL}.`, + '', + `You can also get live support from our team in our Discord: ${DISCORD_INVITE_URL}`, + '', + `Freebuff is the free coding agent from Codebuff. You can use it to build, fix, and ship projects without worrying about credits.`, + '', + 'If you get stuck, want help, or have feedback, just reply to this email.', + '', + 'Excited to see what you build with us,', + 'James', + ].join('\n') + + const html = ` + <div style="font-family: Arial, sans-serif; line-height: 1.6; color: #111;"> + <p>Hi ${firstName},</p> + <p>Welcome to Freebuff. My name is James, and I’ll be your point of contact here.</p> + <p>You can email me any time at <a href="mailto:${FREEBUFF_REPLY_TO_EMAIL}">${FREEBUFF_REPLY_TO_EMAIL}</a>.</p> + <p>You can also get live support from our team in our <a href="${DISCORD_INVITE_URL}">Discord</a>.</p> + <p>Freebuff is the free coding agent from Codebuff. You can use it to build, fix, and ship projects without worrying about credits.</p> + <p>If you get stuck, want help, or have feedback, just reply to this email.</p> + <p>Excited to see what you build with us,</p> + <p>James</p> + </div> + ` + + const resend = new Resend(apiKey) + const { error } = await resend.emails.send({ + from: FREEBUFF_FROM_EMAIL, + replyTo: FREEBUFF_REPLY_TO_EMAIL, + to: [params.email], + subject, + text, + html, + }) + + if (error) { logger.error( - { userId, error }, - 'Failed to create Stripe customer or update user record.', + { error, userId: params.userId, email: params.email }, + 'Failed to send Freebuff welcome email.', ) - await logSyncFailure({ - id: userId, - errorMessage, - provider: 'stripe', - logger, - }) - return null } } -export const authOptions: NextAuthOptions = { - adapter: DrizzleAdapter(db, { - usersTable: schema.user, - accountsTable: schema.account, - sessionsTable: schema.session, - verificationTokensTable: schema.verificationToken, - }) as Adapter, - providers: [ - GitHubProvider({ - clientId: env.FREEBUFF_GITHUB_ID ?? env.CODEBUFF_GITHUB_ID, - clientSecret: env.FREEBUFF_GITHUB_SECRET ?? env.CODEBUFF_GITHUB_SECRET, - }), - ], - session: { - strategy: 'database', - maxAge: SESSION_MAX_AGE_SECONDS, - }, - callbacks: { - async session({ session, user }) { - if (session.user) { - session.user.id = user.id - session.user.image = user.image - session.user.name = user.name - session.user.email = user.email - session.user.stripe_customer_id = user.stripe_customer_id - } - return session - }, - async redirect({ url, baseUrl }) { - const potentialRedirectUrl = new URL(url, baseUrl) - const authCode = potentialRedirectUrl.searchParams.get('auth_code') - - if (authCode) { - if (!isCliAuthCodeCandidate(authCode)) { - const searchParamKeys = Array.from( - potentialRedirectUrl.searchParams.keys(), - ).sort() - logger.warn( - { - authCodeLength: authCode.length, - authCodeTrimmedLength: authCode.trim().length, - authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), - authCodeParamCount: - potentialRedirectUrl.searchParams.getAll('auth_code').length, - searchParamKeys, - searchParamCount: searchParamKeys.length, - hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), - hasCodeParam: searchParamKeys.includes('code'), - hasRedirectParam: searchParamKeys.includes('redirect'), - dotCount: authCode.match(/\./g)?.length ?? 0, - hyphenCount: authCode.match(/-/g)?.length ?? 0, - redirectUrlOrigin: potentialRedirectUrl.origin, - baseUrl, - }, - 'Freebuff auth redirect received non-CLI-shaped auth_code', - ) - return baseUrl - } - - const onboardUrl = new URL(`${baseUrl}/onboard`) - onboardUrl.search = getCliAuthOnboardSearchParams( - potentialRedirectUrl.searchParams, - authCode, - ).toString() - return onboardUrl.toString() - } - - if (url.startsWith('/') || potentialRedirectUrl.origin === baseUrl) { - return potentialRedirectUrl.toString() - } - - return baseUrl - }, +export const authOptions: NextAuthOptions = createAuthOptions({ + jwtOnly: useJwtOnlyAuth, + credentials: { + githubId: env.FREEBUFF_GITHUB_ID ?? env.CODEBUFF_GITHUB_ID, + githubSecret: env.FREEBUFF_GITHUB_SECRET ?? env.CODEBUFF_GITHUB_SECRET, + googleId: env.FREEBUFF_GOOGLE_ID ?? env.CODEBUFF_GOOGLE_ID, + googleSecret: env.FREEBUFF_GOOGLE_SECRET ?? env.CODEBUFF_GOOGLE_SECRET, }, - events: { - createUser: async ({ user }) => { - logger.info( - { userId: user.id, email: user.email }, - 'createUser event triggered', - ) - - const userData = await db.query.user.findFirst({ - where: eq(schema.user.id, user.id), - columns: { - id: true, - email: true, - name: true, - next_quota_reset: true, - }, - }) - - if (!userData) { - logger.error({ userId: user.id }, 'User data not found after creation') - return - } - - await createAndLinkStripeCustomer({ - ...userData, - userId: userData.id, - }) - + hooks: { + logger, + onCreateUser: async (userData) => { // Freebuff is free - new accounts do not receive any credit grant. - - await loops.sendSignupEventToLoops({ - ...userData, + await sendFreebuffWelcomeEmail({ userId: userData.id, - logger, - signupSource: 'freebuff', + email: userData.email, + name: userData.name, }) trackEvent({ @@ -191,8 +125,51 @@ export const authOptions: NextAuthOptions = { userId: userData.id, logger, }) - - logger.info({ user }, 'createUser event processing finished.') }, }, -} + redirect: async ({ url, baseUrl }) => { + const potentialRedirectUrl = new URL(url, baseUrl) + const authCode = potentialRedirectUrl.searchParams.get('auth_code') + + if (authCode) { + if (!isCliAuthCodeCandidate(authCode)) { + const searchParamKeys = Array.from( + potentialRedirectUrl.searchParams.keys(), + ).sort() + logger.warn( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + authCodeParamCount: + potentialRedirectUrl.searchParams.getAll('auth_code').length, + searchParamKeys, + searchParamCount: searchParamKeys.length, + hasCallbackUrlParam: searchParamKeys.includes('callbackUrl'), + hasCodeParam: searchParamKeys.includes('code'), + hasRedirectParam: searchParamKeys.includes('redirect'), + dotCount: authCode.match(/\./g)?.length ?? 0, + hyphenCount: authCode.match(/-/g)?.length ?? 0, + redirectUrlOrigin: potentialRedirectUrl.origin, + baseUrl, + }, + 'Freebuff auth redirect received non-CLI-shaped auth_code', + ) + return baseUrl + } + + const onboardUrl = new URL('/onboard', getFreebuffNextAuthUrl()) + onboardUrl.search = getCliAuthOnboardSearchParams( + potentialRedirectUrl.searchParams, + authCode, + ).toString() + return onboardUrl.toString() + } + + if (url.startsWith('/') || potentialRedirectUrl.origin === baseUrl) { + return potentialRedirectUrl.toString() + } + + return baseUrl + }, +}) diff --git a/freebuff/web/src/app/api/auth/[...nextauth]/route.ts b/freebuff/web/src/app/api/auth/[...nextauth]/route.ts index 5ea370065d..59cc488e0c 100644 --- a/freebuff/web/src/app/api/auth/[...nextauth]/route.ts +++ b/freebuff/web/src/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,11 @@ import NextAuth from 'next-auth' import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { getFreebuffNextAuthUrl } from '../../../../lib/freebuff-server-env' +// NextAuth v4 reads this OS env var internally when building OAuth callback +// URLs. Keep that integration point fed from the typed Freebuff env wrapper. +process.env.NEXTAUTH_URL = getFreebuffNextAuthUrl() const handler = NextAuth(authOptions) export { handler as GET, handler as POST } diff --git a/freebuff/web/src/app/api/auth/cli/code/route.ts b/freebuff/web/src/app/api/auth/cli/code/route.ts index 734d5e4e01..e65b0969b6 100644 --- a/freebuff/web/src/app/api/auth/cli/code/route.ts +++ b/freebuff/web/src/app/api/auth/cli/code/route.ts @@ -10,9 +10,11 @@ import { z } from 'zod/v4' import { buildCliAuthCode, + CLI_AUTH_CODE_TOKEN_BYTES, getCliAuthCodeHashPrefix, getCliAuthCodeTokenIdentifier, } from '@/app/onboard/_helpers' +import { getFreebuffServerAppUrl } from '../../../../../lib/freebuff-server-env' import { logger } from '@/util/logger' import { getLoginUrlOrigin } from './_origin' @@ -67,7 +69,8 @@ export async function POST(req: Request) { expiresAt.toString(), fingerprintHash, ) - const loginToken = randomBytes(32).toString('base64url') + const loginToken = + randomBytes(CLI_AUTH_CODE_TOKEN_BYTES).toString('base64url') await db.insert(schema.verificationToken).values({ identifier: getCliAuthCodeTokenIdentifier(loginToken), @@ -79,7 +82,7 @@ export async function POST(req: Request) { '/login', getLoginUrlOrigin( req, - env.NEXT_PUBLIC_CODEBUFF_APP_URL, + getFreebuffServerAppUrl(), 'https://freebuff.com', env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', ), @@ -99,7 +102,7 @@ export async function POST(req: Request) { forwardedHost: req.headers.get('x-forwarded-host'), forwardedProto: req.headers.get('x-forwarded-proto'), originHeader: req.headers.get('origin'), - configuredAppUrl: env.NEXT_PUBLIC_CODEBUFF_APP_URL, + configuredAppUrl: getFreebuffServerAppUrl(), environment: env.NEXT_PUBLIC_CB_ENVIRONMENT, }, 'Issued Freebuff CLI auth code token', diff --git a/freebuff/web/src/app/api/chat/stream/route.ts b/freebuff/web/src/app/api/chat/stream/route.ts new file mode 100644 index 0000000000..70c19602d7 --- /dev/null +++ b/freebuff/web/src/app/api/chat/stream/route.ts @@ -0,0 +1,427 @@ +import { recordReferralV2Activation } from '@codebuff/billing' +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +import { BlockTreeBuilder } from '@/app/chat/blocks' +import { + CHAT_DOC_MAX_COUNT, + CHAT_DOC_THREAD_SEARCH_MAX_FILES, + CHAT_IMAGE_ALLOWED_TYPES_SET, + CHAT_IMAGE_MAX_COUNT, + CHAT_MESSAGE_MAX_CHARS, + chatModelForAccessTier, + deriveThreadTitle, +} from '@/app/chat/models' +import { getChatAccessTier } from '@/server/chat/access' +import { runChatAgent } from '@/server/chat/agent' +import { getChatUserId } from '@/server/chat/auth' +import { CHAT_DISABLED, chatDisabledResponse } from '@/server/chat/disabled' +import { consumeChatRateLimit } from '@/server/chat/limits' +import { + claimThreadRun, + createThread, + getThread, + insertMessage, + isUserBanned, + listThreadDocumentRefs, + releaseThreadRun, + renameThread, + touchThread, +} from '@/server/chat/store' +import { generateThreadTitle } from '@/server/chat/title' +import { trackServerEvent } from '@/util/analytics' +import { logger } from '@/util/logger' + +import type { ChatDocumentRef, ChatImageRef } from '@/server/chat/store' + +/** + * Validates the client-supplied image refs into trusted ChatImageRefs. The + * client only sends opaque storageIds (returned by /api/chat/upload) plus media + * types — never URLs — so the server always resolves the serving URL itself + * from the blob store (no SSRF surface). Returns at most CHAT_IMAGE_MAX_COUNT + * entries; anything malformed is dropped. + */ +function parseImageRefs(raw: unknown): ChatImageRef[] { + if (!Array.isArray(raw)) return [] + const refs: ChatImageRef[] = [] + for (const item of raw) { + if (refs.length >= CHAT_IMAGE_MAX_COUNT) break + if (!item || typeof item !== 'object') continue + const { storageId, mediaType } = item as Record<string, unknown> + if ( + typeof storageId !== 'string' || + typeof mediaType !== 'string' || + !CHAT_IMAGE_ALLOWED_TYPES_SET.has(mediaType) + ) { + continue + } + refs.push({ storageId, mediaType }) + } + return refs +} + +/** + * Validates the client-supplied document refs into trusted ChatDocumentRefs. + * Like images, the client sends only opaque storageIds (from /api/chat/upload) + * plus metadata the server already produced; the extracted text is re-fetched + * server-side from the blob store. Returns at most CHAT_DOC_MAX_COUNT entries; + * anything malformed is dropped. + */ +function parseDocumentRefs(raw: unknown): ChatDocumentRef[] { + if (!Array.isArray(raw)) return [] + const refs: ChatDocumentRef[] = [] + for (const item of raw) { + if (refs.length >= CHAT_DOC_MAX_COUNT) break + if (!item || typeof item !== 'object') continue + const { storageId, mediaType, name, chars, truncated } = + item as Record<string, unknown> + if ( + typeof storageId !== 'string' || + typeof mediaType !== 'string' || + typeof name !== 'string' + ) { + continue + } + refs.push({ + storageId, + mediaType, + name, + chars: typeof chars === 'number' ? chars : 0, + truncated: truncated === true, + }) + } + return refs +} + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +// How long a thread stays claimed by one response run. Longer than +// maxDuration so a live run is never stolen; short enough that a crashed +// server doesn't lock the thread for long. +const RUN_CLAIM_MS = 6 * 60 * 1000 + +// Generous bound for a 32k-char message wrapped in JSON; rejects oversized +// bodies from Content-Length before parsing them. +const MAX_BODY_BYTES = 256 * 1024 + +const sseTextEncoder = new TextEncoder() + +function sseEncode(payload: unknown): Uint8Array { + return sseTextEncoder.encode(`data: ${JSON.stringify(payload)}\n\n`) +} + +export async function POST(request: NextRequest) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json( + { error: 'unauthorized', message: 'Please sign in to chat.' }, + { status: 401 }, + ) + } + + const contentLength = Number(request.headers.get('content-length') ?? 0) + if (contentLength > MAX_BODY_BYTES) { + return NextResponse.json( + { + error: 'message_too_long', + message: `Messages are limited to ${CHAT_MESSAGE_MAX_CHARS.toLocaleString()} characters.`, + }, + { status: 413 }, + ) + } + + const body = await request.json().catch(() => null) + const content = typeof body?.content === 'string' ? body.content.trim() : '' + const threadIdInput = + typeof body?.threadId === 'string' ? body.threadId : null + let images = parseImageRefs(body?.images) + let attachments = parseDocumentRefs(body?.attachments) + + if (!content && images.length === 0 && attachments.length === 0) { + return NextResponse.json( + { error: 'empty_message', message: 'Message cannot be empty.' }, + { status: 400 }, + ) + } + if (content.length > CHAT_MESSAGE_MAX_CHARS) { + return NextResponse.json( + { + error: 'message_too_long', + message: `Messages are limited to ${CHAT_MESSAGE_MAX_CHARS.toLocaleString()} characters.`, + }, + { status: 400 }, + ) + } + + // Claim the thread (one in-flight response per thread — concurrent runs + // would clobber each other's run_state) in parallel with the tier check + // (usually a cached DB read) and the ban check. The chat runs under the + // service account, so the platform's own ban enforcement never sees the + // real user — check it here. + const claimUntil = new Date(Date.now() + RUN_CLAIM_MS) + const [accessTier, claimedThread, banned] = await Promise.all([ + getChatAccessTier(userId, request), + threadIdInput + ? claimThreadRun({ userId, threadId: threadIdInput, claimUntil }) + : null, + isUserBanned(userId), + ]) + + if (banned) { + if (claimedThread) { + await releaseThreadRun(claimedThread.id) + } + return NextResponse.json( + { error: 'forbidden', message: 'Your account has been suspended.' }, + { status: 403 }, + ) + } + + if (threadIdInput && !claimedThread) { + const exists = await getThread(userId, threadIdInput) + if (!exists) { + return NextResponse.json( + { error: 'thread_not_found', message: 'Chat not found.' }, + { status: 404 }, + ) + } + return NextResponse.json( + { + error: 'response_in_progress', + message: 'A response is already being generated in this chat.', + }, + { status: 409 }, + ) + } + + // Consume quota only after the claim succeeded, so busy/missing threads + // don't burn it. The ledger is append-only (insert-then-count), so the + // limit holds under concurrency and survives thread deletion. + const rateLimit = await consumeChatRateLimit(userId) + if (!rateLimit.allowed) { + if (claimedThread) { + await releaseThreadRun(claimedThread.id) + } + return NextResponse.json( + { error: 'rate_limited', message: rateLimit.message }, + { status: 429 }, + ) + } + + // Upload is full-access only; drop any attachments a limited user smuggled + // in (they're pinned to a text-only model). + if (accessTier !== 'full') { + images = [] + attachments = [] + } + + // The server picks the model from the access tier (full → MiniMax M3, + // limited → DeepSeek Flash); it never trusts a client-supplied model. + const model = chatModelForAccessTier(accessTier) + + const thread = + claimedThread ?? + (await createThread({ + userId, + title: deriveThreadTitle(content), + model, + runClaimedUntil: claimUntil, + })) + const threadId = thread.id + const previousRunState = thread.run_state + + // Files uploaded earlier in this thread stay searchable on follow-up turns. + // Gather them BEFORE inserting the current message so it isn't double-counted; + // a brand-new thread has none. + const priorAttachments = claimedThread + ? await listThreadDocumentRefs(threadId, CHAT_DOC_THREAD_SEARCH_MAX_FILES) + : [] + + await insertMessage({ + threadId, + userId, + role: 'user', + content, + images, + attachments, + }) + + // An accepted chat message is a product use: mark the sender's unified + // referral as activated at their verified chat tier (docs/referrals.md) — + // the only activation hook on the chat-only path; without it web referrals + // of chat-only users never count. The tier is the same IP/geo/privacy + // verification the free-session admits use, so a VPN/datacenter/unsupported + // -country sender activates at 'limited' (web-ladder credit, no GLM) while + // a verified-clean sender activates at 'full'. Forensics: the tier check + // above already persisted this user's client_ip_hash + privacy signals to + // free_mode_country_access_cache. Fire-and-forget, mirroring the + // free-session admit call sites: idempotent, a no-op for non-referred + // users, never delays the stream. + void recordReferralV2Activation({ + referredId: userId, + accessTier, + }).catch((error) => { + logger.warn({ error, userId }, 'Failed to record referral_v2 activation (chat)') + }) + + // DAU signal: one event per user-submitted chat message, emitted to both + // PostHog and Axiom. userId is the canonical codebuff Postgres user id + // (next-auth session.user.id), matching the cli and web surfaces so + // combined DAU is a single unique-users query. + trackServerEvent({ + event: AnalyticsEvent.MESSAGE_SENT, + userId, + properties: { + surface: 'chat', + model, + accessTier, + isNewThread: !claimedThread, + contentLength: content.length, + }, + }) + + const stream = new ReadableStream<Uint8Array>({ + async start(controller) { + controller.enqueue( + sseEncode({ type: 'meta', threadId, title: thread.title, model }), + ) + + // For a brand-new thread, generate a short topic title from the first + // message in parallel with the response run. The thread already has a + // prompt-prefix placeholder title; when the model returns, persist the + // nicer one and push a `title` event so the sidebar swaps it live. Best- + // effort — failures keep the placeholder. Awaited before close so the + // rename lands before the client's end-of-stream thread refresh. + const titleTask = claimedThread + ? null + : (async () => { + try { + const title = await generateThreadTitle({ + prompt: content, + model, + userId, + threadId, + signal: request.signal, + }) + if (!title || title === thread.title) return + await renameThread(userId, threadId, title) + try { + controller.enqueue(sseEncode({ type: 'title', threadId, title })) + } catch { + // Stream already closed; the title is persisted for next load. + } + } catch (error) { + logger.warn( + { error, userId, threadId }, + 'Chat thread title task failed', + ) + } + })() + + let runState: unknown + // Mirrors the client's tree so subagent activity survives reloads + // (and client disconnects) via chat_message.blocks. + const blockTree = new BlockTreeBuilder() + const enqueueGenericError = () => { + try { + controller.enqueue( + sseEncode({ + type: 'error', + message: + 'Something went wrong generating a response. Please try again.', + }), + ) + } catch { + // Stream already closed. + } + } + try { + const result = await runChatAgent({ + prompt: content, + model, + images, + attachments, + priorAttachments, + previousRunState, + userId, + threadId, + signal: request.signal, + onEvent: (event) => { + blockTree.apply(event) + try { + controller.enqueue(sseEncode(event)) + } catch { + // Client disconnected; keep accumulating so we can persist. + } + }, + }) + runState = result.runState ?? undefined + if (result.errorMessage) { + enqueueGenericError() + } + } catch (error) { + const aborted = error instanceof Error && error.name === 'AbortError' + if (!aborted) { + // logger.* (not console.*) so this reaches Axiom with structured + // context; a bare console.error here is invisible to log queries. + logger.error( + { + error, + userId, + threadId, + model, + imageCount: images.length, + attachmentCount: attachments.length, + }, + 'Chat stream agent run threw', + ) + enqueueGenericError() + } + } + const assistantText = blockTree.rootText + if (assistantText.length > 0 || blockTree.hasActivityBlocks) { + // Note: on an errored/aborted run, runState stays at the previous + // turn, so this partial text is visible in the transcript but absent + // from the agent's replayed history. chat_message is the UI's source + // of truth; run_state is the agent's. + blockTree.finalize() + await insertMessage({ + threadId, + userId, + role: 'assistant', + content: assistantText, + // Plain-text turns skip the column; the UI falls back to content. + blocks: blockTree.hasActivityBlocks ? blockTree.blocks : undefined, + model, + }) + } + // Also releases the thread's run claim. + await touchThread({ threadId, model, runState }) + // Make sure the title rename (and its `title` event) lands before the + // stream closes — the title run is usually quick and already done. + if (titleTask) await titleTask + try { + controller.enqueue(sseEncode({ type: 'done' })) + controller.close() + } catch { + // Stream already closed (client disconnected). + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/freebuff/web/src/app/api/chat/threads/[threadId]/route.ts b/freebuff/web/src/app/api/chat/threads/[threadId]/route.ts new file mode 100644 index 0000000000..fccd679b20 --- /dev/null +++ b/freebuff/web/src/app/api/chat/threads/[threadId]/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +import { getChatUserId } from '@/server/chat/auth' +import { getBlobStore, hydrateMessageImages } from '@/server/chat/blob-store' +import { CHAT_DISABLED, chatDisabledResponse } from '@/server/chat/disabled' +import { + deleteThread, + getThread, + listMessages, + listThreadBlobStorageIds, + renameThread, +} from '@/server/chat/store' +import { logger } from '@/util/logger' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +type Params = { params: Promise<{ threadId: string }> } + +export async function GET(_request: NextRequest, { params }: Params) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { threadId } = await params + const thread = await getThread(userId, threadId) + if (!thread) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + const messages = await hydrateMessageImages(await listMessages(threadId)) + return NextResponse.json({ thread, messages }) +} + +export async function PATCH(request: NextRequest, { params }: Params) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { threadId } = await params + const body = await request.json().catch(() => null) + const title = typeof body?.title === 'string' ? body.title.trim() : '' + if (!title || title.length > 200) { + return NextResponse.json({ error: 'Invalid title' }, { status: 400 }) + } + const thread = await renameThread(userId, threadId, title) + if (!thread) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + return NextResponse.json({ thread }) +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const { threadId } = await params + // Gather the thread's image blobs before deleting the rows (which cascades + // the messages away). Only delete the blobs once we've confirmed the thread + // was actually the user's and is now gone. + const storageIds = await listThreadBlobStorageIds(threadId) + const deleted = await deleteThread(userId, threadId) + if (!deleted) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + if (storageIds.length > 0) { + try { + await getBlobStore().deleteMany(storageIds) + } catch (error) { + // Best-effort: the thread is gone either way. Orphaned blobs can be + // swept later; don't fail the delete on a storage hiccup. + logger.error( + { error, threadId }, + 'Chat image blob cleanup failed after thread delete', + ) + } + } + return NextResponse.json({ ok: true }) +} diff --git a/freebuff/web/src/app/api/chat/threads/route.ts b/freebuff/web/src/app/api/chat/threads/route.ts new file mode 100644 index 0000000000..59907e3773 --- /dev/null +++ b/freebuff/web/src/app/api/chat/threads/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +import { getChatAccessTier } from '@/server/chat/access' +import { getChatUserId } from '@/server/chat/auth' +import { CHAT_DISABLED, chatDisabledResponse } from '@/server/chat/disabled' +import { listThreads } from '@/server/chat/store' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +export async function GET(request: NextRequest) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const [threads, accessTier] = await Promise.all([ + listThreads(userId), + getChatAccessTier(userId, request), + ]) + // Only full-access users can upload images (limited users — unsupported + // countries, VPN/proxy — are restricted to text). + return NextResponse.json({ threads, canUploadImages: accessTier === 'full' }) +} diff --git a/freebuff/web/src/app/api/chat/upload/route.ts b/freebuff/web/src/app/api/chat/upload/route.ts new file mode 100644 index 0000000000..814e02e52e --- /dev/null +++ b/freebuff/web/src/app/api/chat/upload/route.ts @@ -0,0 +1,256 @@ +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +import { + CHAT_DOC_MAX_BYTES, + CHAT_IMAGE_ALLOWED_TYPES_SET, + CHAT_IMAGE_MAX_BYTES, + classifyAttachment, + fileExtension, +} from '@/app/chat/models' +import { getChatAccessTier } from '@/server/chat/access' +import { getChatUserId } from '@/server/chat/auth' +import { getBlobStore } from '@/server/chat/blob-store' +import { CHAT_DISABLED, chatDisabledResponse } from '@/server/chat/disabled' +import { + EmptyDocumentError, + extractText, + UnsupportedDocumentError, +} from '@/server/chat/extract' +import { isUserBanned } from '@/server/chat/store' +import { logger } from '@/util/logger' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +// Slightly above the largest per-file limit to allow for multipart overhead; +// the real per-file limit is enforced on the parsed file below. +const MAX_BODY_BYTES = CHAT_IMAGE_MAX_BYTES + 1024 * 1024 + +export async function POST(request: NextRequest) { + if (CHAT_DISABLED) { + return chatDisabledResponse() + } + const userId = await getChatUserId() + if (!userId) { + return NextResponse.json( + { error: 'unauthorized', message: 'Please sign in to chat.' }, + { status: 401 }, + ) + } + + const contentLength = Number(request.headers.get('content-length') ?? 0) + if (contentLength > MAX_BODY_BYTES) { + return NextResponse.json( + { error: 'file_too_large', message: 'File is too large.' }, + { status: 413 }, + ) + } + + // Upload is a full-access-only feature: limited users (unsupported + // countries, VPN/proxy) are pinned to a text-only model, so don't accept + // their uploads either. + const [accessTier, banned] = await Promise.all([ + getChatAccessTier(userId, request), + isUserBanned(userId), + ]) + if (banned) { + return NextResponse.json( + { error: 'forbidden', message: 'Your account has been suspended.' }, + { status: 403 }, + ) + } + if (accessTier !== 'full') { + return NextResponse.json( + { error: 'forbidden', message: 'File upload is not available.' }, + { status: 403 }, + ) + } + + let file: File | null = null + try { + const form = await request.formData() + const value = form.get('file') + if (value instanceof File) { + file = value + } + } catch { + return NextResponse.json( + { error: 'invalid_request', message: 'Could not read the upload.' }, + { status: 400 }, + ) + } + if (!file) { + return NextResponse.json( + { error: 'invalid_request', message: 'No file provided.' }, + { status: 400 }, + ) + } + + const kind = classifyAttachment(file.name, file.type) + if (kind === 'image') { + return handleImageUpload(file, userId) + } + if (kind === 'document') { + return handleDocumentUpload(file, userId) + } + return NextResponse.json( + { + error: 'unsupported_type', + message: + 'Unsupported file type. Upload an image, or a text, code, CSV, JSON, Markdown, PDF, or Word file.', + }, + { status: 400 }, + ) +} + +/** Stores an image's raw bytes and returns a ref + an immediate preview URL. */ +async function handleImageUpload(file: File, userId: string) { + const mediaType = file.type + if (!CHAT_IMAGE_ALLOWED_TYPES_SET.has(mediaType)) { + return NextResponse.json( + { + error: 'unsupported_type', + message: 'Only JPEG, PNG, WebP, and GIF images are supported.', + }, + { status: 400 }, + ) + } + if (file.size > CHAT_IMAGE_MAX_BYTES) { + return NextResponse.json( + { error: 'file_too_large', message: 'Image is too large (max 10 MB).' }, + { status: 413 }, + ) + } + + try { + const blobStore = getBlobStore() + const storageId = await blobStore.upload(await file.arrayBuffer(), mediaType) + // Resolve a URL for the client's immediate preview; the message itself + // persists only the storageId and re-resolves on load. + const urls = await blobStore.getUrls([storageId]) + const url = urls[storageId] + if (!url) { + throw new Error('Could not resolve uploaded image URL') + } + return NextResponse.json({ + kind: 'image', + storageId, + url, + mediaType, + name: file.name, + }) + } catch (error) { + logger.error({ error, userId }, 'Chat image upload failed') + return NextResponse.json( + { + error: 'upload_failed', + message: 'Could not upload the image. Please try again.', + }, + { status: 500 }, + ) + } +} + +/** + * Converts a document to text (the hard part of file upload) and stores the + * EXTRACTED text — not the original bytes — in the blob store, so the chat + * agent can read or search it later. Returns a ref carrying the size metadata + * the agent uses to decide inline-vs-search. + */ +async function handleDocumentUpload(file: File, userId: string) { + if (file.size > CHAT_DOC_MAX_BYTES) { + return NextResponse.json( + { error: 'file_too_large', message: 'File is too large (max 5 MB).' }, + { status: 413 }, + ) + } + + // Metric fields (no filename / content — PII-lean): file kind + size, and on + // success the extracted size + truncation. Queryable in Axiom by `metric`. + const ext = fileExtension(file.name) + const byteSize = file.size + + let text: string + let truncated: boolean + try { + const result = await extractText({ + bytes: await file.arrayBuffer(), + mediaType: file.type, + fileName: file.name, + }) + text = result.text + truncated = result.truncated + } catch (error) { + const reason = + error instanceof UnsupportedDocumentError + ? 'unsupported' + : error instanceof EmptyDocumentError + ? 'empty' + : 'error' + logger.info( + { metric: 'chat_doc_extracted', ext, bytes: byteSize, ok: false, reason }, + 'chat document extraction failed', + ) + if (reason !== 'error') { + return NextResponse.json( + { error: 'extract_failed', message: (error as Error).message }, + { status: 400 }, + ) + } + logger.error( + { error, userId, name: file.name, type: file.type }, + 'Chat document extraction failed', + ) + return NextResponse.json( + { + error: 'extract_failed', + message: 'Could not read this file. Please try a different file.', + }, + { status: 422 }, + ) + } + + try { + const bytes = new TextEncoder().encode(text) + const storageId = await getBlobStore().upload( + // Copy into a standalone ArrayBuffer (TextEncoder may return a view over + // a larger pooled buffer). + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + 'text/plain; charset=utf-8', + ) + logger.info( + { + metric: 'chat_doc_extracted', + ext, + bytes: byteSize, + chars: text.length, + truncated, + ok: true, + }, + 'chat document extracted', + ) + return NextResponse.json({ + kind: 'document', + storageId, + mediaType: file.type || 'text/plain', + name: file.name, + chars: text.length, + truncated, + }) + } catch (error) { + logger.error({ error, userId, name: file.name }, 'Chat document upload failed') + return NextResponse.json( + { + error: 'upload_failed', + message: 'Could not upload the file. Please try again.', + }, + { status: 500 }, + ) + } +} diff --git a/freebuff/web/src/app/api/desktop/download/[platform]/route.ts b/freebuff/web/src/app/api/desktop/download/[platform]/route.ts new file mode 100644 index 0000000000..eb03574d14 --- /dev/null +++ b/freebuff/web/src/app/api/desktop/download/[platform]/route.ts @@ -0,0 +1,152 @@ +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +/** + * Redirect to the correct Freebuff Desktop installer for the requested + * platform, resolved from the latest `freebuff-desktop-v*` release on the + * public community repo. Keeps the download links on /desktop stable across + * releases (they never need to hardcode a version). + * + * Why not just list releases and take the newest? That repo also hosts the + * (very frequent) CLI releases, and — because the desktop releases are + * cross-repo tags pointing at the community repo's default branch — every + * release shares the same `created_at`, so GitHub's release list sort buries + * the desktop release far down the list. Instead we resolve the latest desktop + * *tag* via the git matching-refs API (which only returns our prefix), pick the + * highest semver, then fetch that release directly for its assets. + * + * Both GitHub calls are cached (`next.revalidate`) so a burst of downloads + * collapses into one pair of requests and we stay well under GitHub's + * unauthenticated rate limit (60/hr/IP). + */ + +const RELEASES_REPO = 'CodebuffAI/codebuff-community' +const DESKTOP_TAG_PREFIX = 'freebuff-desktop-v' +const UPSTREAM_TTL_SECONDS = 300 + +// Mirror the authenticated GitHub request pattern from +// src/app/api/github/stars/route.ts: a token lifts us off the 60/hr +// unauthenticated rate limit (shared across all users behind our server IP), +// and pinning the API version avoids surprise response-shape changes. +const GITHUB_HEADERS: Record<string, string> = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'freebuff-web', +} +const githubToken = process.env.GITHUB_TOKEN ?? process.env.CODEBUFF_GITHUB_TOKEN +if (githubToken) { + GITHUB_HEADERS.Authorization = `Bearer ${githubToken}` +} + +type Platform = 'mac-arm64' | 'mac-intel' | 'windows' | 'linux' + +// The supported platform keys (also the canonical URL path values) and how to +// pick each one's installer from a release's assets. Note the Linux AppImage is +// named `...-linux-x86_64.AppImage` (electron-builder emits the `x86_64` arch +// string, not the `x64` from our artifactName template), so match on extension. +const ASSET_MATCHERS: Record<Platform, (name: string) => boolean> = { + 'mac-arm64': (n) => n.includes('mac-arm64') && n.endsWith('.dmg'), + 'mac-intel': (n) => n.includes('mac-x64') && n.endsWith('.dmg'), + windows: (n) => n.endsWith('.exe'), + linux: (n) => n.endsWith('.AppImage'), +} + +type GitRef = { ref: string } +type GithubAsset = { name: string; browser_download_url: string } +type GithubRelease = { tag_name: string; assets: GithubAsset[] } + +// Accept X.Y.Z with an optional pre-release suffix (e.g. `1.2.0-rc.1`) so a +// pre-release desktop tag isn't silently dropped — important since this is +// explicitly a beta product that may ship RC builds. +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/ + +/** Compare two `VERSION_RE`-valid version strings by semver precedence. */ +function compareSemver(a: string, b: string): number { + const ma = VERSION_RE.exec(a)! + const mb = VERSION_RE.exec(b)! + for (let i = 1; i <= 3; i++) { + const diff = Number(ma[i]) - Number(mb[i]) + if (diff !== 0) return diff + } + // Equal core: a release (no suffix) outranks a pre-release; between two + // pre-releases, compare the suffix lexically. + const preA = ma[4] + const preB = mb[4] + if (!preA && !preB) return 0 + if (!preA) return 1 + if (!preB) return -1 + return preA < preB ? -1 : preA > preB ? 1 : 0 +} + +/** Highest-semver `freebuff-desktop-v*` tag on the community repo, or null. */ +async function resolveLatestVersion(): Promise<string | null> { + const res = await fetch( + `https://api.github.com/repos/${RELEASES_REPO}/git/matching-refs/tags/${DESKTOP_TAG_PREFIX}`, + { headers: GITHUB_HEADERS, next: { revalidate: UPSTREAM_TTL_SECONDS } }, + ) + if (!res.ok) throw new Error(`GitHub matching-refs ${res.status}`) + const body = await res.json() + // matching-refs returns an array; guard so an unexpected shape doesn't throw + // (which would surface as a misleading 502 despite a 200 from GitHub). + const refs = Array.isArray(body) ? (body as GitRef[]) : [] + const versions = refs + .map((r) => r.ref.replace(`refs/tags/${DESKTOP_TAG_PREFIX}`, '')) + .filter((v) => VERSION_RE.test(v)) + .sort(compareSemver) + return versions.at(-1) ?? null +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ platform: string }> }, +) { + const { platform: raw } = await params + const matchAsset = ASSET_MATCHERS[raw?.toLowerCase() as Platform] + if (!matchAsset) { + return NextResponse.json( + { + error: `Unknown platform "${raw}". Use mac-arm64, mac-intel, windows, or linux.`, + }, + { status: 400 }, + ) + } + + let release: GithubRelease + try { + const version = await resolveLatestVersion() + if (!version) { + return NextResponse.json( + { error: 'No Freebuff Desktop release found yet.' }, + { status: 404 }, + ) + } + const res = await fetch( + `https://api.github.com/repos/${RELEASES_REPO}/releases/tags/${DESKTOP_TAG_PREFIX}${version}`, + { headers: GITHUB_HEADERS, next: { revalidate: UPSTREAM_TTL_SECONDS } }, + ) + if (!res.ok) throw new Error(`GitHub release-by-tag ${res.status}`) + release = (await res.json()) as GithubRelease + } catch { + return NextResponse.json( + { error: 'Could not reach the release server. Try again shortly.' }, + { status: 502 }, + ) + } + + const asset = release.assets.find((a) => matchAsset(a.name)) + if (!asset) { + return NextResponse.json( + { error: `No ${raw} installer in ${release.tag_name}.` }, + { status: 404 }, + ) + } + + return NextResponse.redirect(asset.browser_download_url, { + status: 302, + headers: { + 'Cache-Control': + 'public, max-age=0, s-maxage=300, stale-while-revalidate=60', + }, + }) +} diff --git a/freebuff/web/src/app/api/github/stars/route.ts b/freebuff/web/src/app/api/github/stars/route.ts new file mode 100644 index 0000000000..ec6619518a --- /dev/null +++ b/freebuff/web/src/app/api/github/stars/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server' + +import { GITHUB_REPO, GITHUB_REPO_URL } from '@/components/landing/nav-links' + +export const runtime = 'nodejs' +export const revalidate = 3600 + +type GitHubRepoResponse = { + stargazers_count?: number + html_url?: string +} + +type ShieldsStarsResponse = { + message?: string +} + +function parseShieldsStarCount(message: string): number | null { + const trimmed = message.trim() + if (!trimmed) return null + + const normalized = trimmed.toLowerCase() + if (normalized.endsWith('k')) { + const value = Number.parseFloat(normalized.slice(0, -1)) + return Number.isFinite(value) ? Math.round(value * 1000) : null + } + if (normalized.endsWith('m')) { + const value = Number.parseFloat(normalized.slice(0, -1)) + return Number.isFinite(value) ? Math.round(value * 1_000_000) : null + } + + const digits = Number.parseInt(trimmed.replace(/,/g, ''), 10) + return Number.isFinite(digits) ? digits : null +} + +async function fetchStarsFromGitHub(): Promise<number | null> { + const headers: HeadersInit = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Freebuff-Web', + } + const token = process.env.GITHUB_TOKEN ?? process.env.CODEBUFF_GITHUB_TOKEN + if (token) { + headers.Authorization = `Bearer ${token}` + } + + const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`, { + headers, + next: { revalidate: 3600 }, + }) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as GitHubRepoResponse + return typeof data.stargazers_count === 'number' ? data.stargazers_count : null +} + +async function fetchStarsFromShields(): Promise<number | null> { + const response = await fetch( + `https://img.shields.io/github/stars/${GITHUB_REPO}.json`, + { next: { revalidate: 3600 } }, + ) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as ShieldsStarsResponse + if (typeof data.message !== 'string') { + return null + } + + return parseShieldsStarCount(data.message) +} + +export async function GET() { + try { + const stars = + (await fetchStarsFromGitHub()) ?? (await fetchStarsFromShields()) + + return NextResponse.json({ + stars, + url: GITHUB_REPO_URL, + }) + } catch { + return NextResponse.json({ + stars: null, + url: GITHUB_REPO_URL, + }) + } +} diff --git a/freebuff/web/src/app/api/latency/route.ts b/freebuff/web/src/app/api/latency/route.ts new file mode 100644 index 0000000000..d4766291d6 --- /dev/null +++ b/freebuff/web/src/app/api/latency/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from 'next/server' + +import { getFreebuffLatencyStats } from '@/server/latency-stats' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export async function GET() { + const stats = await getFreebuffLatencyStats() + return NextResponse.json(stats, { + headers: { + 'Cache-Control': + 'public, max-age=0, s-maxage=60, stale-while-revalidate=30', + }, + }) +} diff --git a/freebuff/web/src/app/api/logs/route.ts b/freebuff/web/src/app/api/logs/route.ts new file mode 100644 index 0000000000..5ef27da4b5 --- /dev/null +++ b/freebuff/web/src/app/api/logs/route.ts @@ -0,0 +1,64 @@ +import { env } from '@codebuff/common/env' +import { isLogBodyTooLarge, logIngestSchema } from '@codebuff/common/schemas/logs' +import { buildLogRows } from '@codebuff/common/util/log-ingest' +import { + createFixedWindowRateLimiter, + extractClientIp, +} from '@codebuff/common/util/rate-limit' +import { enqueueLogRow } from '@codebuff/logging' +import { NextResponse } from 'next/server' + +import type { NextRequest } from 'next/server' + +/** + * POST /api/logs (freebuff-web) — same-origin, unauthenticated browser + * ingest. Mirrors PostHog browser events into the Axiom logs dataset. + * + * Browser events are anonymous (no API key), so this endpoint applies a + * lightweight per-IP rate limit to bound abuse/cost. Rows land with + * source='browser', user_id=null (identity lives in the payload's PostHog + * distinct id / $session_id). Requires AXIOM_API_TOKEN on this service; when + * absent the sink disables gracefully. See docs/logging.md. + */ + +// Per-instance fixed-window limiter (best-effort; not a distributed guarantee). +const rateLimiter = createFixedWindowRateLimiter({ + windowMs: 60_000, + max: 60, +}) + +export async function POST(req: NextRequest) { + const now = Date.now() + + // Reject oversized bodies before reading/parsing them (primary DoS guard for + // this unauthenticated endpoint; the rate limiter below is best-effort). + if (isLogBodyTooLarge(req.headers.get('content-length'))) { + return NextResponse.json({ error: 'Payload too large' }, { status: 413 }) + } + + if (rateLimiter.limited(extractClientIp(req.headers), now)) { + return NextResponse.json({ error: 'Rate limited' }, { status: 429 }) + } + + const parsed = logIngestSchema.safeParse(await req.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request body', details: parsed.error.format() }, + { status: 400 }, + ) + } + + const rows = buildLogRows({ + records: parsed.data.records, + source: 'browser', + service: 'freebuff-web', + env: env.NEXT_PUBLIC_CB_ENVIRONMENT, + userId: null, + now: new Date(now), + }) + for (const row of rows) { + enqueueLogRow(row) + } + + return NextResponse.json({ accepted: rows.length }) +} diff --git a/freebuff/web/src/app/api/web/.well-known/jwks.json/route.ts b/freebuff/web/src/app/api/web/.well-known/jwks.json/route.ts new file mode 100644 index 0000000000..b56efd2899 --- /dev/null +++ b/freebuff/web/src/app/api/web/.well-known/jwks.json/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' + +import { getVlyConvexJwks } from '@/lib/vly-convex-jwt' + +export const runtime = 'nodejs' + +export async function GET() { + return NextResponse.json(await getVlyConvexJwks(), { + headers: { + 'Cache-Control': 'public, max-age=300, stale-while-revalidate=300', + }, + }) +} diff --git a/freebuff/web/src/app/api/web/convex-token/route.ts b/freebuff/web/src/app/api/web/convex-token/route.ts new file mode 100644 index 0000000000..c7f75d4554 --- /dev/null +++ b/freebuff/web/src/app/api/web/convex-token/route.ts @@ -0,0 +1,69 @@ +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { signVlyConvexToken } from '@/lib/vly-convex-jwt' +import { resolveFreebuffWebGeoAccess } from '@/server/geo' +import { syncWebReferralState } from '@/server/web-referrals' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +export const runtime = 'nodejs' + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions) + const user = session?.user + + if (!user?.id || !user.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Geographic access tier rides along as a JWT claim: this route is the + // only place in the browser -> Convex path where request headers (country, + // IP, client hints) are visible, and the token refreshes every <=10 min so + // the tier tracks IP changes without extra requests. Resolved before the + // referral sync (not in parallel) because the sync activates the user's own + // referral at this verified tier — the usual case is a warm cache hit, so + // the added latency is one fast read. + const geoAccess = await resolveFreebuffWebGeoAccess({ + userId: user.id, + headers: req.headers, + }) + + // Web referrals live in the shared Postgres referral ledger; the score + // rides along as a JWT claim so Convex can size tier-scaled limits + // without its own referral bookkeeping. This also redeems the referral + // attribution cookie, ages in any pending referral, and — since this route + // only fires while the signed-in web app is open (the web surface's + // product-use signal) — activates the user's own referral at the request's + // verified tier. Hard-blocked requests never activate. + const webReferralScore = await syncWebReferralState({ + userId: user.id, + // The geo pipeline's own hash (not re-derived from headers) so the value + // is byte-identical to the free_mode_country_access_cache row it must + // join against, including paths where the pipeline nulls it. + clientIpHash: geoAccess.clientIpHash, + ...(geoAccess.accessTier === 'blocked' + ? {} + : { activation: { accessTier: geoAccess.accessTier } }), + }).catch((error) => { + logger.warn( + { error, userId: user.id }, + 'Failed to sync web referral state; defaulting score to 0', + ) + return 0 + }) + + const token = await signVlyConvexToken({ + userId: user.id, + email: user.email, + name: user.name, + image: user.image, + accessTier: geoAccess.accessTier, + countryCode: geoAccess.countryCode, + webReferralScore, + }) + + return NextResponse.json({ token }) +} diff --git a/freebuff/web/src/app/api/web/gravity-index/route.ts b/freebuff/web/src/app/api/web/gravity-index/route.ts new file mode 100644 index 0000000000..d37438e3ab --- /dev/null +++ b/freebuff/web/src/app/api/web/gravity-index/route.ts @@ -0,0 +1,158 @@ +import { env } from '@codebuff/internal/env' +import { getCachedFreebuffWebServiceAccountApiKey } from '@codebuff/internal/freebuff/web-service-account' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' +import { z } from 'zod' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { logger } from '@/util/logger' + +import type { NextRequest } from 'next/server' + +export const runtime = 'nodejs' + +const UPSTREAM_TIMEOUT_MS = 30_000 + +/** + * Read-only actions the integration catalog UI may perform. Deliberately + * excludes `report_integration`, which is reserved for the agent path (it + * affects conversion accounting and needs a real search_id). + */ +const catalogRequestSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('search'), + query: z.string().min(1).max(1000), + }), + z.object({ + action: z.literal('browse'), + category: z.string().min(1).max(200).optional(), + q: z.string().min(1).max(200).optional(), + }), + z.object({ action: z.literal('list_categories') }), + z.object({ + action: z.literal('get_service'), + slug: z.string().min(1).max(200), + }), +]) + +/** Same credential resolution as the chat agent: explicit CODEBUFF_API_KEY + * (local dev) overrides the shared service-account PAT from the DB. */ +async function getGravityProxyApiKey(): Promise<string | null> { + return ( + process.env.CODEBUFF_API_KEY ?? + (await getCachedFreebuffWebServiceAccountApiKey()) ?? + null + ) +} + +// Light per-user rate limit. Per-instance only (resets on redeploy/scale), +// which is fine: the upstream proxy is the real guard, this just stops a +// single user hammering browse/search from the catalog UI. +const RATE_LIMIT_WINDOW_MS = 60_000 +const RATE_LIMIT_MAX_REQUESTS = 30 +const requestCounts = new Map<string, { count: number; windowStart: number }>() + +function isRateLimited(userId: string): boolean { + const now = Date.now() + const entry = requestCounts.get(userId) + if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) { + requestCounts.set(userId, { count: 1, windowStart: now }) + return false + } + entry.count += 1 + return entry.count > RATE_LIMIT_MAX_REQUESTS +} + +/** + * Session-authed proxy for the Freebuff Web integration catalog. Forwards + * read-only Gravity Index actions to the main app's /api/v1/gravity-index + * (which holds the Gravity API key and injects user attribution) under the + * Freebuff Web service account. + */ +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + const userId = session?.user?.id + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = catalogRequestSchema.safeParse( + await req.json().catch(() => null), + ) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid Gravity Index request' }, + { status: 400 }, + ) + } + + if (isRateLimited(userId)) { + return NextResponse.json( + { error: 'Too many requests. Please slow down.' }, + { status: 429 }, + ) + } + + const apiKey = await getGravityProxyApiKey() + if (!apiKey) { + logger.error('Gravity catalog proxy: no service-account API key available') + return NextResponse.json( + { error: 'Integration catalog is not configured' }, + { status: 503 }, + ) + } + + const input = parsed.data + const body: Record<string, unknown> = { ...input } + if (input.action === 'search') { + // The upstream schema only accepts attribution fields on search. + body.metadata = { surface: 'freebuff_web_library' } + body.external_session_id = userId + } + + const url = new URL('/api/v1/gravity-index', env.NEXT_PUBLIC_CODEBUFF_APP_URL) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS) + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + const json = await response.json().catch(() => null) + if (!response.ok || !json || typeof json !== 'object') { + logger.warn( + { status: response.status, action: input.action }, + 'Gravity catalog proxy upstream request failed', + ) + return NextResponse.json( + { error: 'Failed to load the integration catalog' }, + { status: 502 }, + ) + } + + return NextResponse.json(json) + } catch (error) { + const timedOut = error instanceof Error && error.name === 'AbortError' + logger.error( + { error, action: input.action, timedOut }, + 'Gravity catalog proxy request failed', + ) + return NextResponse.json( + { + error: timedOut + ? 'Integration catalog request timed out' + : 'Failed to load the integration catalog', + }, + { status: 502 }, + ) + } finally { + clearTimeout(timeout) + } +} diff --git a/freebuff/web/src/app/api/web/proxy-screenshot/route.ts b/freebuff/web/src/app/api/web/proxy-screenshot/route.ts new file mode 100644 index 0000000000..419a1a2cac --- /dev/null +++ b/freebuff/web/src/app/api/web/proxy-screenshot/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' + +const IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|webp|gif)$/i + +function isAllowedScreenshotUrl(url: URL): boolean { + if (url.protocol !== 'https:') return false + + const hostname = url.hostname + const r2Domain = process.env.R2_PUBLIC_DOMAIN + + if (r2Domain && hostname === r2Domain) return true + if (hostname === 'screenshots.vly.ai') return true + if (hostname.includes('screenshots')) return true + if (IMAGE_EXTENSION_PATTERN.test(url.pathname)) return true + + return false +} + +export async function GET(request: NextRequest) { + const rawUrl = request.nextUrl.searchParams.get('url') + if (!rawUrl) { + return new NextResponse('Missing url', { status: 400 }) + } + + let parsed: URL + try { + parsed = new URL(rawUrl) + } catch { + return new NextResponse('Invalid url', { status: 400 }) + } + + if (!isAllowedScreenshotUrl(parsed)) { + return new NextResponse('Forbidden', { status: 403 }) + } + + const upstream = await fetch(parsed.toString(), { + next: { revalidate: 3600 }, + }) + + if (!upstream.ok) { + return new NextResponse('Upstream error', { status: upstream.status }) + } + + const buffer = await upstream.arrayBuffer() + + return new NextResponse(buffer, { + headers: { + 'Content-Type': upstream.headers.get('Content-Type') ?? 'image/png', + 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400', + }, + }) +} diff --git a/freebuff/web/src/app/api/web/referral-eligibility/route.ts b/freebuff/web/src/app/api/web/referral-eligibility/route.ts new file mode 100644 index 0000000000..1711065872 --- /dev/null +++ b/freebuff/web/src/app/api/web/referral-eligibility/route.ts @@ -0,0 +1,102 @@ +import { + MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + isGithubAccountOldEnoughForReferral, +} from '@codebuff/common/constants/freebuff-referral-tiers' +import { getReferralQualification } from '@codebuff/billing' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { clientIpHashFromHeaders } from '@/server/geo' +import { syncWebReferralState } from '@/server/web-referrals' +import { logger } from '@/util/logger' + +export const runtime = 'nodejs' + +/** + * Live referral eligibility for the signed-in visitor of /get-started, so the + * onboarding card can tell them whether the invite they're claiming will count. + * + * The bright line for the headline reward (GLM 5.2) is a linked GitHub account + * that is at least MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL months old (the + * unified bar). `qualifies` only reflects this age check — the referral still + * requires the friend to ACTIVATE by using a product, which can't have happened + * yet on /get-started, so the card frames it as "use Freebuff to unlock", not + * "unlocked". + */ +export type ReferralEligibilityData = { + /** Whether the visitor is signed in at all. */ + signedIn: boolean + /** Whether a GitHub account is linked to the signed-in user. */ + githubLinked: boolean + /** True once we know the GitHub account passes the age bar (still needs use). */ + qualifies: boolean + /** False when we couldn't read the account age (e.g. GitHub API hiccup). */ + accountAgeKnown: boolean + /** The age bar, surfaced so the copy stays in sync with the constant. */ + minMonths: number +} + +export async function GET(req: Request) { + const session = await getServerSession(authOptions) + const userId = session?.user?.id + + if (!userId) { + return NextResponse.json({ + signedIn: false, + githubLinked: false, + qualifies: false, + accountAgeKnown: false, + minMonths: MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + } satisfies ReferralEligibilityData) + } + + // Redeem the referral attribution cookie now that the invited friend is + // signed in on /get-started — the earliest authed hop in the CLI referral + // funnel. This credits the referral (Postgres only; no web app / Convex) + // the moment they sign in via the CLI invite link, before they even install + // the CLI. Unlike the /onboard Server Component, this is a Route Handler, so + // clearing the cookie after redemption works normally. Best-effort: a + // referral hiccup must never break the eligibility check the page renders. + await syncWebReferralState({ + userId, + clientIpHash: clientIpHashFromHeaders(req.headers), + }).catch((error) => { + logger.warn( + { userId, error }, + 'referral redemption on /get-started eligibility check failed', + ) + }) + + try { + const qualification = await getReferralQualification({ userId, logger }) + const githubLinked = qualification.reason !== 'no_github_account' + const accountCreatedAt = qualification.accountCreatedAt + const qualifies = accountCreatedAt + ? isGithubAccountOldEnoughForReferral( + accountCreatedAt.getTime(), + Date.now(), + MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + ) + : false + + return NextResponse.json({ + signedIn: true, + githubLinked, + qualifies, + accountAgeKnown: accountCreatedAt != null, + minMonths: MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + } satisfies ReferralEligibilityData) + } catch (error) { + // Don't fail the page on a GitHub/DB hiccup: report "signed in, age + // unknown" so the card still shows the install step. + logger.warn({ userId, error }, 'referral-eligibility check failed') + return NextResponse.json({ + signedIn: true, + githubLinked: true, + qualifies: false, + accountAgeKnown: false, + minMonths: MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + } satisfies ReferralEligibilityData) + } +} diff --git a/freebuff/web/src/app/api/web/referrals/leaderboard/route.ts b/freebuff/web/src/app/api/web/referrals/leaderboard/route.ts new file mode 100644 index 0000000000..089309fcec --- /dev/null +++ b/freebuff/web/src/app/api/web/referrals/leaderboard/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server' + +import { getReferralLeaderboard } from '@/server/referral-leaderboard' + +export const runtime = 'nodejs' + +export async function GET(request: Request) { + const url = new URL(request.url) + const limit = Number(url.searchParams.get('limit') ?? 10) + const leaderboard = await getReferralLeaderboard(limit) + + return NextResponse.json({ leaderboard }) +} diff --git a/freebuff/web/src/app/api/web/referrals/route.ts b/freebuff/web/src/app/api/web/referrals/route.ts new file mode 100644 index 0000000000..fb5c9b619b --- /dev/null +++ b/freebuff/web/src/app/api/web/referrals/route.ts @@ -0,0 +1,133 @@ +import { + FREEBUFF_REFERRAL_TIERS, + MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + getNextReferralTier, + getReferralTier, +} from '@codebuff/common/constants/freebuff-referral-tiers' +import { + getReferralFunnelStats, + getWebReferralScore, + referredGithubIdSql, +} from '@codebuff/billing' +import db from '@codebuff/internal/db' +import * as schema from '@codebuff/internal/db/schema' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { getServerSession } from 'next-auth' +import { NextResponse } from 'next/server' + +import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' +import { getReferralLeaderboard } from '@/server/referral-leaderboard' + +export const runtime = 'nodejs' + +/** + * Everything the /web/referrals page needs, read from the shared Postgres + * referral ledger: the user's share code (`user.referral_code`, the same + * token the CLI program uses), web referral score, current/next tier, the + * tier table, and recent attributed signups. + */ +export async function GET() { + const session = await getServerSession(authOptions) + const userId = session?.user?.id + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const [[user], score, funnel, recentReferrals, leaderboard] = + await Promise.all([ + db + .select({ referralCode: schema.user.referral_code }) + .from(schema.user) + .where(eq(schema.user.id, userId)) + .limit(1), + getWebReferralScore({ userId }), + // Top of the funnel: unique link clicks + all attributed signups. The + // "valid" figure is `score` (activated + GitHub-age qualified) below. + getReferralFunnelStats({ referrerId: userId }), + // Each signup attributed to this referrer (unified referral_v2 model: one + // row per referred friend), with just enough GitHub + activation state + // joined in to explain WHY a row hasn't counted yet — never their identity + // (see the response mapping below). Revoked referrals are hidden. + db + .select({ + activatedAt: schema.referralV2.activated_at, + createdAt: schema.referralV2.created_at, + // The referred friend's resolved GitHub id (shared with getReferralStats + // so the list never disagrees with the score). Null only when the friend + // has no GitHub linked, OR when their GitHub is already burned by another + // referral (the resolver's NOT EXISTS guard) — both correctly "uncounted". + githubUserId: referredGithubIdSql, + // GitHub server-set account-creation date, for the derived age check. + githubAccountCreatedAt: + schema.referralQualification.github_account_created_at, + }) + .from(schema.referralV2) + .leftJoin( + schema.referralQualification, + sql`${schema.referralQualification.github_user_id} = ${referredGithubIdSql}`, + ) + .where( + and( + eq(schema.referralV2.referrer_id, userId), + isNull(schema.referralV2.revoked_at), + ), + ) + .orderBy(desc(schema.referralV2.created_at)) + .limit(25), + getReferralLeaderboard(10), + ]) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + // A referral COUNTS only when the friend's GitHub account is old enough AND + // they've activated by using a product (referral_v2.activated_at). Derive the + // age bar the same way the score does, so the list never shows "Qualified" + // for a friend who hasn't actually used Freebuff. + const ageThreshold = new Date() + ageThreshold.setMonth( + ageThreshold.getMonth() - MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + ) + + return NextResponse.json({ + code: user.referralCode, + qualifiedReferralCount: score, + // Referrer funnel: unique clicks -> total signups -> valid (qualified) + // signups. `validSignups` mirrors `qualifiedReferralCount` so the funnel + // and the tier progress never disagree. + clickCount: funnel.clicks, + totalSignups: funnel.totalSignups, + validSignups: score, + currentTier: getReferralTier(score), + nextTier: getNextReferralTier(score), + tiers: FREEBUFF_REFERRAL_TIERS, + minGithubAccountAgeMonths: MIN_GITHUB_ACCOUNT_AGE_MONTHS_REFERRAL, + recentReferrals: recentReferrals.map((referral) => { + const hasGithub = !!referral.githubUserId + const githubOldEnough = + !!referral.githubAccountCreatedAt && + referral.githubAccountCreatedAt <= ageThreshold + const activated = !!referral.activatedAt + const qualified = githubOldEnough && activated + return { + status: qualified ? ('completed' as const) : ('pending' as const), + createdAt: referral.createdAt.getTime(), + // Why a signup hasn't counted yet — the FIRST unmet requirement, derived + // for the referrer without revealing who the friend is. Null when + // qualified, or when we simply can't read the GitHub age yet (awaiting + // their next sign-in). + blockedReason: qualified + ? null + : !hasGithub + ? 'no_github' + : !githubOldEnough + ? referral.githubAccountCreatedAt + ? 'account_too_new' + : null + : 'needs_activation', + } + }), + leaderboard, + }) +} diff --git a/freebuff/web/src/app/api/webcontainer/template/route.ts b/freebuff/web/src/app/api/webcontainer/template/route.ts new file mode 100644 index 0000000000..086dbbb111 --- /dev/null +++ b/freebuff/web/src/app/api/webcontainer/template/route.ts @@ -0,0 +1,63 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { NextResponse } from "next/server"; + +import { buildFileSystemTree } from "@/vly/lib/webcontainer/fileSystemTree"; + +export const dynamic = "force-dynamic"; + +// Directories/files that should never be mounted into the WebContainer +// (build output, package manager metadata, OS cruft, generated Convex codegen +// that gets regenerated fresh once `convex dev` runs against the user's own +// deployment). +const IGNORED_NAMES = new Set([ + "node_modules", + "dist", + ".git", + ".DS_Store", + "_generated", +]); + +const TEMPLATE_ROOT = path.join(process.cwd(), "webcontainer-template"); + +async function collectFiles( + dir: string, + baseDir: string, + out: Record<string, string>, +): Promise<void> { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (IGNORED_NAMES.has(entry.name)) continue; + + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await collectFiles(absolutePath, baseDir, out); + } else if (entry.isFile()) { + const relativePath = path.relative(baseDir, absolutePath).split(path.sep).join("/"); + out[relativePath] = await readFile(absolutePath, "utf-8"); + } + } +} + +/** + * Serves the default vly Vite + React + Convex starter as a WebContainer + * `FileSystemTree`, ready to be passed straight to `container.mount()`. The + * template source is checked into `freebuff/web/webcontainer-template/`. + */ +export async function GET() { + const files: Record<string, string> = {}; + await collectFiles(TEMPLATE_ROOT, TEMPLATE_ROOT, files); + + const tree = buildFileSystemTree(files); + + return NextResponse.json(tree, { + headers: { + // Static template source — safe to cache aggressively; a deploy bump + // changes the asset's URL hash via Next's build, but this route is + // path-stable, so keep the cache window short-ish and revalidate. + "Cache-Control": "public, max-age=60, stale-while-revalidate=3600", + }, + }); +} diff --git a/freebuff/web/src/app/blog/[slug]/page.tsx b/freebuff/web/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000..90b8b2c853 --- /dev/null +++ b/freebuff/web/src/app/blog/[slug]/page.tsx @@ -0,0 +1,175 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' + +import { AuthorAvatar } from '@/components/blog/author-avatar' +import { + ArticleJsonLd, + BreadcrumbJsonLd, + OrganizationJsonLd, +} from '@/components/blog/json-ld' +import { PostBody } from '@/components/blog/post-body' +import { PostCard } from '@/components/blog/post-card' +import { PostHero } from '@/components/blog/post-hero' +import { getAuthor } from '@/lib/blog/authors' +import { blogConfig } from '@/lib/blog/config' +import { + getAllPosts, + getPostBySlug, + getRelatedPosts, +} from '@/lib/blog/registry' +import { siteConfig } from '@/lib/constant' + +import type { Metadata } from 'next' + +export const dynamic = 'force-static' +export const revalidate = 3600 +export const dynamicParams = false + +export async function generateStaticParams() { + return getAllPosts().map((p) => ({ slug: p.slug })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ slug: string }> +}): Promise<Metadata> { + const { slug } = await params + const post = getPostBySlug(slug) + if (!post) return {} + + const siteUrl = siteConfig.url() + const canonical = post.canonical ?? `${siteUrl}${blogConfig.basePath}/${post.slug}` + const image = post.ogImage + ? `${siteUrl}${post.ogImage}` + : `${siteUrl}${blogConfig.defaultOgImage}` + const author = getAuthor(post.authorId) + const allAuthors = [author, ...(post.coAuthorIds ?? []).map(getAuthor)] + + return { + title: post.title, + description: post.description, + keywords: post.keywords, + authors: allAuthors.map((a) => ({ name: a.name })), + alternates: { canonical }, + openGraph: { + title: post.title, + description: post.description, + url: canonical, + type: 'article', + siteName: blogConfig.brand, + locale: blogConfig.locale, + publishedTime: post.publishedAt, + modifiedTime: post.updatedAt ?? post.publishedAt, + authors: allAuthors.map((a) => a.name), + tags: post.keywords, + images: [{ url: image, alt: post.title }], + }, + twitter: { + card: 'summary_large_image', + title: post.title, + description: post.description, + site: `@${blogConfig.twitterHandle}`, + images: [image], + }, + } +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ slug: string }> +}) { + const { slug } = await params + const post = getPostBySlug(slug) + if (!post) notFound() + + const siteUrl = siteConfig.url() + const related = getRelatedPosts(post.slug, 3) + const author = getAuthor(post.authorId) + + return ( + <> + <OrganizationJsonLd siteUrl={siteUrl} /> + <ArticleJsonLd siteUrl={siteUrl} post={post} /> + <BreadcrumbJsonLd + siteUrl={siteUrl} + trail={[ + { name: 'Home', href: '/' }, + { name: 'Blog', href: blogConfig.basePath }, + { name: post.title, href: `${blogConfig.basePath}/${post.slug}` }, + ]} + /> + + <article className="container mx-auto max-w-3xl px-4 pt-28 pb-16 md:pt-36"> + <PostHero post={post} /> + <div className="mt-10" data-speakable="true"> + <PostBody blocks={post.body} /> + </div> + + <footer className="mt-16 space-y-8 border-t border-white/10 pt-8"> + {author.bio && ( + <div className="rounded-2xl border border-white/10 bg-white/[0.02] p-6"> + <p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> + About the author + </p> + <div className="mt-3 flex items-start gap-4"> + <AuthorAvatar author={author} size="lg" /> + <div className="flex-1"> + <p className="text-base font-semibold text-white"> + {author.name} + {author.role && ( + <span className="ml-2 font-normal text-zinc-500"> + · {author.role} + </span> + )} + </p> + <p className="mt-2 leading-relaxed text-zinc-400"> + {author.bio} + </p> + {author.twitter && ( + <Link + href={`https://twitter.com/${author.twitter}`} + target="_blank" + rel="noopener noreferrer" + className="mt-3 inline-block text-sm text-acid-matrix hover:underline" + > + @{author.twitter} + </Link> + )} + </div> + </div> + </div> + )} + + {post.keywords.length > 0 && ( + <div className="flex flex-wrap items-center gap-2 text-xs"> + <span className="text-zinc-600">Topics:</span> + {post.keywords.map((k) => ( + <span + key={k} + className="rounded-full border border-white/10 bg-white/[0.03] px-2.5 py-1 text-zinc-400" + > + {k} + </span> + ))} + </div> + )} + </footer> + </article> + + {related.length > 0 && ( + <section className="container mx-auto max-w-6xl px-4 pb-24"> + <h2 className="mb-6 text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> + Keep reading + </h2> + <div className="grid grid-cols-1 gap-5 md:grid-cols-3"> + {related.map((p) => ( + <PostCard key={p.slug} post={p} /> + ))} + </div> + </section> + )} + </> + ) +} diff --git a/freebuff/web/src/app/blog/layout.tsx b/freebuff/web/src/app/blog/layout.tsx new file mode 100644 index 0000000000..fab38be15d --- /dev/null +++ b/freebuff/web/src/app/blog/layout.tsx @@ -0,0 +1,9 @@ +import { BlogChrome } from '@/components/blog/blog-chrome' + +export default function BlogLayout({ + children, +}: { + children: React.ReactNode +}) { + return <BlogChrome>{children}</BlogChrome> +} diff --git a/freebuff/web/src/app/blog/page.tsx b/freebuff/web/src/app/blog/page.tsx new file mode 100644 index 0000000000..2bfe65bfb8 --- /dev/null +++ b/freebuff/web/src/app/blog/page.tsx @@ -0,0 +1,149 @@ +import { Rss } from 'lucide-react' +import Link from 'next/link' + +import { FilterableGrid } from '@/components/blog/category-pills' +import { + BlogJsonLd, + BreadcrumbJsonLd, + OrganizationJsonLd, + WebSiteJsonLd, +} from '@/components/blog/json-ld' +import { PostCard } from '@/components/blog/post-card' +import { blogCategories, blogConfig } from '@/lib/blog/config' +import { + getAllCategoriesInUse, + getAllPosts, + getFeaturedPosts, +} from '@/lib/blog/registry' +import { siteConfig } from '@/lib/constant' + +import type { Metadata } from 'next' + +export const dynamic = 'force-static' +export const revalidate = 3600 + +export async function generateMetadata(): Promise<Metadata> { + const siteUrl = siteConfig.url() + const title = `${blogConfig.brand} Blog — free coding agent guides, comparisons, and launches` + const description = + 'The Freebuff blog: launches, deep dives, and head-to-head guides for the free alternative to Claude Code, Cursor, Codex, Lovable, Replit, Bolt, Windsurf, and every paid coding agent.' + + return { + title, + description, + keywords: [ + 'free coding agent', + 'free cli coding agent', + 'free claude code', + 'free cursor', + 'free codex', + 'free lovable', + 'free replit', + 'free bolt.new', + 'free windsurf', + 'free emergent', + 'free devin', + 'ai coding blog', + 'freebuff blog', + ], + alternates: { canonical: `${siteUrl}${blogConfig.basePath}` }, + openGraph: { + title, + description, + url: `${siteUrl}${blogConfig.basePath}`, + type: 'website', + siteName: blogConfig.brand, + locale: blogConfig.locale, + images: [{ url: `${siteUrl}${blogConfig.defaultOgImage}` }], + }, + twitter: { + card: 'summary_large_image', + title, + description, + site: `@${blogConfig.twitterHandle}`, + }, + } +} + +function renderHeadline(headline: string) { + const parts = headline.split(/\*(.+?)\*/g) + return parts.map((part, i) => + i % 2 === 0 ? ( + <span key={i}>{part}</span> + ) : ( + <span key={i} className="text-acid-matrix"> + {part} + </span> + ), + ) +} + +export default function BlogIndexPage() { + const siteUrl = siteConfig.url() + const allPosts = getAllPosts() + const featured = getFeaturedPosts(blogConfig.featuredCount) + const inUseCategories = new Set(getAllCategoriesInUse()) + const visibleCategories = blogCategories.filter( + (c) => c === 'All' || inUseCategories.has(c), + ) + + return ( + <> + <OrganizationJsonLd siteUrl={siteUrl} /> + <WebSiteJsonLd siteUrl={siteUrl} /> + <BlogJsonLd siteUrl={siteUrl} posts={allPosts} /> + <BreadcrumbJsonLd + siteUrl={siteUrl} + trail={[ + { name: 'Home', href: '/' }, + { name: 'Blog', href: blogConfig.basePath }, + ]} + /> + + <section className="container mx-auto max-w-6xl px-4 pt-28 pb-12 md:pt-36"> + <h1 className="font-serif text-4xl font-medium leading-[1.05] text-white md:text-6xl lg:text-7xl"> + {renderHeadline(blogConfig.indexHeadline)} + </h1> + <p className="mt-6 max-w-2xl text-lg leading-relaxed text-zinc-400"> + {blogConfig.indexSubhead} + </p> + <div className="mt-6"> + <Link + href={`${blogConfig.basePath}/rss.xml`} + className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.02] px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:border-acid-matrix/40 hover:text-acid-matrix" + > + <Rss className="size-3.5" /> + Subscribe via RSS + </Link> + </div> + </section> + + {featured.length > 0 && ( + <section className="container mx-auto max-w-6xl px-4 pb-16"> + <div className="mb-6 flex items-baseline justify-between"> + <h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> + Editor's picks + </h2> + </div> + <div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3"> + {featured.map((p) => ( + <PostCard key={p.slug} post={p} variant="featured" /> + ))} + </div> + </section> + )} + + <section className="container mx-auto max-w-6xl px-4 pb-24"> + <div className="mb-6 flex items-baseline justify-between"> + <h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> + All posts + </h2> + <span className="text-xs text-zinc-600"> + {allPosts.length} {allPosts.length === 1 ? 'post' : 'posts'} + </span> + </div> + <FilterableGrid posts={allPosts} categories={visibleCategories} /> + </section> + </> + ) +} diff --git a/freebuff/web/src/app/blog/rss.xml/route.ts b/freebuff/web/src/app/blog/rss.xml/route.ts new file mode 100644 index 0000000000..2e3bbca6fc --- /dev/null +++ b/freebuff/web/src/app/blog/rss.xml/route.ts @@ -0,0 +1,59 @@ +import { getAuthor } from '@/lib/blog/authors' +import { blogConfig } from '@/lib/blog/config' +import { getAllPosts } from '@/lib/blog/registry' +import { siteConfig } from '@/lib/constant' + +export const dynamic = 'force-static' +export const revalidate = 3600 + +function escapeXml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export async function GET(): Promise<Response> { + const siteUrl = siteConfig.url() + const posts = getAllPosts() + const buildDate = new Date().toUTCString() + + const items = posts + .map((p) => { + const author = getAuthor(p.authorId) + const url = `${siteUrl}${blogConfig.basePath}/${p.slug}` + const pubDate = new Date(p.publishedAt + 'T12:00:00Z').toUTCString() + return ` + <item> + <title>${escapeXml(p.title)} + ${url} + ${url} + ${pubDate} + ${escapeXml(p.description)} + ${escapeXml(p.category)} + noreply@freebuff.com (${escapeXml(author.name)}) + ` + }) + .join('') + + const xml = ` + + + ${escapeXml(`${blogConfig.brand} Blog`)} + ${siteUrl}${blogConfig.basePath} + + ${escapeXml(blogConfig.defaultDescription)} + en-us + ${buildDate}${items} + +` + + return new Response(xml, { + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': 'public, max-age=3600, s-maxage=3600', + }, + }) +} diff --git a/freebuff/web/src/app/chat/__tests__/ad-experiment.test.ts b/freebuff/web/src/app/chat/__tests__/ad-experiment.test.ts new file mode 100644 index 0000000000..e9928d63f5 --- /dev/null +++ b/freebuff/web/src/app/chat/__tests__/ad-experiment.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'bun:test' + +import { chatAdVariantForUser } from '../_components/ad-experiment' + +describe('chatAdVariantForUser', () => { + it('is deterministic for a given user id', () => { + for (const id of ['user-a', 'user-b', 'a1b2c3d4', '42']) { + expect(chatAdVariantForUser(id)).toBe(chatAdVariantForUser(id)) + } + }) + + it('assigns signed-out sessions to control', () => { + expect(chatAdVariantForUser(undefined)).toBe('control') + expect(chatAdVariantForUser(null)).toBe('control') + expect(chatAdVariantForUser('')).toBe('control') + }) + + it('splits a population roughly 50/50', () => { + const total = 10_000 + let serverRendered = 0 + for (let i = 0; i < total; i++) { + if (chatAdVariantForUser(`synthetic-user-${i}`) === 'server_rendered') { + serverRendered += 1 + } + } + expect(serverRendered).toBeGreaterThan(total * 0.45) + expect(serverRendered).toBeLessThan(total * 0.55) + }) +}) diff --git a/freebuff/web/src/app/chat/__tests__/blocks.test.ts b/freebuff/web/src/app/chat/__tests__/blocks.test.ts new file mode 100644 index 0000000000..2c2ce40ca8 --- /dev/null +++ b/freebuff/web/src/app/chat/__tests__/blocks.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'bun:test' + +import { BlockTreeBuilder, isChatBlockArray, toolCallDisplay } from '../blocks' + +import type { AgentBlock } from '../blocks' + +describe('BlockTreeBuilder', () => { + it('builds a nested tree from a typical research turn', () => { + const tree = new BlockTreeBuilder() + tree.apply({ type: 'delta', text: 'Let me look that up.' }) + tree.apply({ + type: 'agent_start', + agentId: 'a1', + parentAgentId: 'main-agent', + name: 'Weeb', + agentType: 'researcher-web', + prompt: 'What is the latest version?', + }) + tree.apply({ + type: 'agent_tool', + agentId: 'a1', + toolCallId: 't1', + toolName: 'web_search', + label: 'latest version', + }) + tree.apply({ type: 'agent_tool_done', toolCallId: 't1' }) + tree.apply({ type: 'agent_delta', agentId: 'a1', text: 'Found ' }) + tree.apply({ type: 'agent_delta', agentId: 'a1', text: 'it.' }) + tree.apply({ type: 'agent_finish', agentId: 'a1' }) + tree.apply({ type: 'delta', text: ' Here is the answer.' }) + + expect(tree.hasActivityBlocks).toBe(true) + expect(tree.blocks).toEqual([ + { type: 'text', text: 'Let me look that up.' }, + { + type: 'agent', + agentId: 'a1', + name: 'Weeb', + agentType: 'researcher-web', + prompt: 'What is the latest version?', + status: 'done', + blocks: [ + { + type: 'tool', + toolCallId: 't1', + toolName: 'web_search', + label: 'latest version', + status: 'done', + }, + { type: 'text', text: 'Found it.' }, + ], + }, + { type: 'text', text: ' Here is the answer.' }, + ]) + expect(isChatBlockArray(tree.snapshot())).toBe(true) + }) + + it('nests agents under their parent and drops events for unknown agents', () => { + const tree = new BlockTreeBuilder() + tree.apply({ + type: 'agent_start', + agentId: 'parent', + name: 'Parent', + agentType: 'p', + }) + tree.apply({ + type: 'agent_start', + agentId: 'child', + parentAgentId: 'parent', + name: 'Child', + agentType: 'c', + }) + tree.apply({ type: 'agent_delta', agentId: 'child', text: 'deep' }) + // Tool calls attributed to an agentId that never had an agent_start must + // not show up anywhere. + tree.apply({ + type: 'agent_tool', + agentId: 'never-started', + toolCallId: 'tx', + toolName: 'spawn_agents', + label: '', + }) + + expect(tree.blocks).toHaveLength(1) + const parent = tree.blocks[0] as AgentBlock + const child = parent.blocks[0] as AgentBlock + expect(child.name).toBe('Child') + expect(child.blocks).toEqual([{ type: 'text', text: 'deep' }]) + }) + + it('groups reasoning into thinking blocks closed by other content', () => { + const tree = new BlockTreeBuilder() + tree.apply({ type: 'reasoning_delta', text: 'Hmm, ' }) + tree.apply({ type: 'reasoning_delta', text: 'let me think.' }) + tree.apply({ type: 'delta', text: 'Answer.' }) + // Reasoning after text starts a fresh thinking block. + tree.apply({ type: 'reasoning_delta', text: 'More thinking.' }) + + expect(tree.hasActivityBlocks).toBe(true) + expect(tree.blocks).toEqual([ + { type: 'thinking', text: 'Hmm, let me think.', status: 'done' }, + { type: 'text', text: 'Answer.' }, + { type: 'thinking', text: 'More thinking.', status: 'running' }, + ]) + // Thinking is excluded from the persisted plain-text content. + expect(tree.rootText).toBe('Answer.') + expect(isChatBlockArray(tree.snapshot())).toBe(true) + }) + + it('routes subagent reasoning into the agent block, closed on finish', () => { + const tree = new BlockTreeBuilder() + tree.apply({ type: 'agent_start', agentId: 'a1', name: 'A', agentType: 'a' }) + tree.apply({ type: 'agent_reasoning_delta', agentId: 'a1', text: 'why' }) + // Unknown agents are dropped. + tree.apply({ type: 'agent_reasoning_delta', agentId: 'nope', text: 'x' }) + tree.apply({ type: 'agent_finish', agentId: 'a1' }) + + const agent = tree.blocks[0] as AgentBlock + expect(agent.blocks).toEqual([ + { type: 'thinking', text: 'why', status: 'done' }, + ]) + expect(tree.blocks).toHaveLength(1) + }) + + it('renders the root agent tool calls as top-level rows', () => { + const tree = new BlockTreeBuilder() + tree.apply({ type: 'delta', text: 'Let me check the catalog.' }) + tree.apply({ + type: 'agent_tool', + toolCallId: 'g1', + toolName: 'gravity_index', + label: 'transactional email for Next.js', + verbs: { running: 'Finding services', done: 'Found services' }, + }) + tree.apply({ type: 'agent_tool_done', toolCallId: 'g1' }) + tree.apply({ type: 'delta', text: ' I recommend Resend.' }) + + expect(tree.hasActivityBlocks).toBe(true) + expect(tree.blocks).toEqual([ + { type: 'text', text: 'Let me check the catalog.' }, + { + type: 'tool', + toolCallId: 'g1', + toolName: 'gravity_index', + label: 'transactional email for Next.js', + verbs: { running: 'Finding services', done: 'Found services' }, + status: 'done', + }, + { type: 'text', text: ' I recommend Resend.' }, + ]) + // Tool rows never leak into the persisted message content. + expect(tree.rootText).toBe('Let me check the catalog. I recommend Resend.') + expect(isChatBlockArray(tree.snapshot())).toBe(true) + }) + + it('finalize marks everything done', () => { + const tree = new BlockTreeBuilder() + tree.apply({ type: 'agent_start', agentId: 'a1', name: 'A', agentType: 'a' }) + tree.apply({ + type: 'agent_tool', + agentId: 'a1', + toolCallId: 't1', + toolName: 'read_url', + label: 'https://example.com', + }) + tree.finalize() + const agent = tree.blocks[0] as AgentBlock + expect(agent.status).toBe('done') + expect(agent.blocks[0]).toMatchObject({ type: 'tool', status: 'done' }) + }) +}) + +describe('toolCallDisplay', () => { + it('summarizes web_search and read_url by their main argument', () => { + expect(toolCallDisplay('web_search', { query: 'bun 1.3' })).toEqual({ + label: 'bun 1.3', + }) + expect(toolCallDisplay('read_url', { url: 'https://a.dev' })).toEqual({ + label: 'https://a.dev', + }) + }) + + it('gives gravity_index per-action verbs and labels', () => { + expect( + toolCallDisplay('gravity_index', { + action: 'search', + query: 'postgres hosting', + }), + ).toEqual({ + label: 'postgres hosting', + verbs: { running: 'Finding services', done: 'Found services' }, + }) + expect( + toolCallDisplay('gravity_index', { + action: 'browse', + category: 'Email', + q: 'send', + }), + ).toEqual({ + label: 'Email · send', + verbs: { running: 'Browsing services', done: 'Browsed services' }, + }) + expect( + toolCallDisplay('gravity_index', { action: 'get_service', slug: 'resend' }), + ).toEqual({ + label: 'resend', + verbs: { running: 'Fetching service', done: 'Fetched service' }, + }) + }) + + it('falls back to an empty label for unknown tools', () => { + expect(toolCallDisplay('mystery_tool', { x: 1 })).toEqual({ label: '' }) + }) +}) diff --git a/freebuff/web/src/app/chat/__tests__/gravity-ad-spec.test.ts b/freebuff/web/src/app/chat/__tests__/gravity-ad-spec.test.ts new file mode 100644 index 0000000000..ebd0c5eef8 --- /dev/null +++ b/freebuff/web/src/app/chat/__tests__/gravity-ad-spec.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'bun:test' + +import { + BUNDLED_FALLBACK_SPEC, + interpolateText, + parseRendererSpec, + sanitizeUrl, +} from '../_components/gravity-ad-spec' + +const validSpec = { + version: 1, + name: 'text-line', + root: { + type: 'link', + style: { display: 'inline', fontSize: '14px' }, + children: [ + { type: 'text', text: '↗ ', style: { fontWeight: 600 } }, + { type: 'text', bind: 'title', style: { fontWeight: 500 } }, + { type: 'text', bind: 'cta' }, + ], + }, +} + +describe('parseRendererSpec', () => { + it('accepts the documented text-line spec', () => { + const spec = parseRendererSpec(validSpec) + expect(spec).not.toBeNull() + expect(spec!.name).toBe('text-line') + expect(spec!.root.type).toBe('link') + expect(spec!.root.children).toHaveLength(3) + expect(spec!.root.children![1].bind).toBe('title') + }) + + it('rejects non-objects and missing versions', () => { + expect(parseRendererSpec(null)).toBeNull() + expect(parseRendererSpec('spec')).toBeNull() + expect(parseRendererSpec({ root: { type: 'box' } })).toBeNull() + }) + + it('rejects a spec containing an unknown node type', () => { + expect( + parseRendererSpec({ + version: 1, + root: { + type: 'box', + children: [{ type: 'script', text: 'alert(1)' }], + }, + }), + ).toBeNull() + }) + + it('drops binds outside the allowed ad fields', () => { + const spec = parseRendererSpec({ + version: 1, + root: { type: 'text', bind: 'clickUrl' }, + }) + expect(spec).not.toBeNull() + expect(spec!.root.bind).toBeUndefined() + }) + + it('strips dangerous style values but keeps safe ones', () => { + const spec = parseRendererSpec({ + version: 1, + root: { + type: 'box', + style: { + color: 'hsl(var(--primary))', + background: 'url(https://evil.example/pixel)', + padding: 16, + }, + }, + }) + expect(spec).not.toBeNull() + expect(spec!.root.style).toEqual({ + color: 'hsl(var(--primary))', + padding: 16, + }) + }) + + it('rejects specs beyond the depth cap', () => { + let node: Record = { type: 'text', text: 'deep' } + for (let i = 0; i < 10; i++) { + node = { type: 'box', children: [node] } + } + expect(parseRendererSpec({ version: 1, root: node })).toBeNull() + }) + + it('rejects specs beyond the node budget', () => { + const children = Array.from({ length: 100 }, () => ({ + type: 'text', + text: 'x', + })) + expect( + parseRendererSpec({ version: 1, root: { type: 'box', children } }), + ).toBeNull() + }) + + it('the bundled fallback passes its own validation', () => { + expect(parseRendererSpec(BUNDLED_FALLBACK_SPEC)).not.toBeNull() + }) +}) + +describe('sanitizeUrl', () => { + it('allows http(s) URLs only', () => { + expect(sanitizeUrl('https://example.com/ad')).toBe('https://example.com/ad') + expect(sanitizeUrl('http://example.com')).toBe('http://example.com/') + expect(sanitizeUrl('javascript:alert(1)')).toBeNull() + expect(sanitizeUrl('data:text/html,x')).toBeNull() + expect(sanitizeUrl('not a url')).toBeNull() + expect(sanitizeUrl(undefined)).toBeNull() + }) +}) + +describe('interpolateText', () => { + it('substitutes allowed ad fields and blanks missing ones', () => { + const ad = { title: 'Great Tool', cta: 'Try it' } + expect(interpolateText('{title} — {cta}', ad)).toBe('Great Tool — Try it') + expect(interpolateText('{brandName}!', ad)).toBe('!') + }) + + it('leaves unknown placeholders untouched', () => { + expect(interpolateText('{impUrl}', { title: 't' })).toBe('{impUrl}') + }) +}) diff --git a/freebuff/web/src/app/chat/__tests__/models.test.ts b/freebuff/web/src/app/chat/__tests__/models.test.ts new file mode 100644 index 0000000000..c1e107f4e6 --- /dev/null +++ b/freebuff/web/src/app/chat/__tests__/models.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'bun:test' + +import { + CHAT_MODELS, + DEFAULT_CHAT_MODEL_ID, + LIMITED_CHAT_MODEL_ID, + chatModelForAccessTier, + deriveThreadTitle, +} from '../models' + +describe('chat models config', () => { + it('tier models are configured models', () => { + const ids = CHAT_MODELS.map((m) => m.id) + expect(ids).toContain(DEFAULT_CHAT_MODEL_ID) + expect(ids).toContain(LIMITED_CHAT_MODEL_ID) + }) + + it('every model has a unique, non-empty backend id', () => { + const backendIds = CHAT_MODELS.map((m) => m.backendId) + for (const id of backendIds) { + expect(id.length).toBeGreaterThan(0) + } + expect(new Set(backendIds).size).toBe(backendIds.length) + }) +}) + +describe('chatModelForAccessTier', () => { + it('gives full-access users MiniMax M3', () => { + expect(chatModelForAccessTier('full')).toBe(DEFAULT_CHAT_MODEL_ID) + }) + + it('pins limited-access users to DeepSeek Flash', () => { + expect(chatModelForAccessTier('limited')).toBe(LIMITED_CHAT_MODEL_ID) + }) +}) + +describe('deriveThreadTitle', () => { + it('uses only the first line', () => { + expect(deriveThreadTitle('hello\nworld')).toBe('hello') + }) + + it('falls back for empty input', () => { + expect(deriveThreadTitle(' \n\n')).toBe('Attachment') + }) + + it('truncates long titles with an ellipsis', () => { + const title = deriveThreadTitle('x'.repeat(100)) + expect(title.endsWith('…')).toBe(true) + expect([...title].length).toBe(61) + }) + + it('does not split surrogate pairs at the cut point', () => { + const title = deriveThreadTitle('😀'.repeat(100)) + expect(title).not.toContain('�') + // Strip the ellipsis, then confirm we still have whole emoji. + const body = title.slice(0, -1) + expect([...body].every((cp) => cp === '😀')).toBe(true) + }) + + it('keeps short titles intact', () => { + expect(deriveThreadTitle('Plan my trip to Kyoto')).toBe( + 'Plan my trip to Kyoto', + ) + }) +}) diff --git a/freebuff/web/src/app/chat/_components/ad-experiment.ts b/freebuff/web/src/app/chat/_components/ad-experiment.ts new file mode 100644 index 0000000000..2591dfbf7f --- /dev/null +++ b/freebuff/web/src/app/chat/_components/ad-experiment.ts @@ -0,0 +1,36 @@ +/** + * Randomized experiment for the /chat sponsored slot: Gravity server-rendered + * ads (layout delivered as a `renderer_spec` from the ad server, see + * https://docs.trygravity.ai/sdks/server-rendered-ads) vs the existing + * `@gravity-ai/react` inline unit. + * + * Bucketing is a deterministic hash of the signed-in user id, salted with the + * experiment key, so a user lands in the same arm on every device and session + * and future experiments re-randomize independently. + */ + +export const CHAT_AD_EXPERIMENT = 'chat_ads_server_rendered_2026_07' + +export type ChatAdVariant = 'server_rendered' | 'control' + +/** FNV-1a 32-bit: tiny, dependency-free, stable across runtimes. */ +function fnv1a(input: string): number { + let hash = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return hash >>> 0 +} + +export function chatAdVariantForUser( + userId: string | null | undefined, +): ChatAdVariant { + // /api/ads rejects unauthenticated requests, so with no user id the slot + // never fills; keep those sessions in control so they stay out of the + // server-rendered arm's metrics. + if (!userId) return 'control' + return fnv1a(`${CHAT_AD_EXPERIMENT}:${userId}`) % 2 === 0 + ? 'server_rendered' + : 'control' +} diff --git a/freebuff/web/src/app/chat/_components/agent-blocks.tsx b/freebuff/web/src/app/chat/_components/agent-blocks.tsx new file mode 100644 index 0000000000..4cca4aa337 --- /dev/null +++ b/freebuff/web/src/app/chat/_components/agent-blocks.tsx @@ -0,0 +1,180 @@ +'use client' + +import { + ChevronRight, + Compass, + Globe, + Loader2, + Search, + Wrench, +} from 'lucide-react' +import { useState } from 'react' + +import type { AgentBlock, ChatBlock, ToolBlock } from '@/app/chat/blocks' +import type { LucideIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { Markdown } from './markdown' +import { SuggestedFollowups } from './suggested-followups' +import { ThinkingRow } from './thinking-block' + +/** Renders a block tree: markdown text, thinking rows, tool-call rows, and + * nested collapsible agent boxes (mirrors the CLI's agent branches). */ +export function BlockList(props: { + blocks: ChatBlock[] + nested?: boolean + /** This tree belongs to the latest assistant turn — thinking rows show + * their tail preview instead of collapsing. */ + latest?: boolean + /** Render thinking rows expanded by default (the deep-thinking agent's + * reasoning is the point, so it stays visible). */ + expandThinking?: boolean + /** Sends a suggested followup's prompt as the next user message. Only wired + * at the top level (suggestions never nest inside an agent box), so a + * suggestions block renders its interactive cards only when this is set and + * this is the latest turn. */ + onSendSuggestion?: (prompt: string) => void +}) { + return ( +
+ {props.blocks.map((block, i) => { + if (block.type === 'text') { + return block.text.trim() ? ( + + ) : null + } + if (block.type === 'thinking') { + return ( + + ) + } + if (block.type === 'tool') { + return + } + if (block.type === 'suggestions') { + // Only the latest assistant turn shows interactive followups. + if (!props.latest || !props.onSendSuggestion) return null + return ( + + ) + } + return ( + + ) + })} +
+ ) +} + +function AgentBox({ agent, latest }: { agent: AgentBlock; latest?: boolean }) { + const running = agent.status === 'running' + // The deep-thinking agent (thinker-gemini) exists to show its reasoning, so + // open it by default. Everything else stays collapsed until the user opens it. + const isThinker = agent.agentType.includes('thinker') + const [open, setOpen] = useState(isThinker) + + return ( +
+ + {open && ( +
+ {agent.prompt && ( +

+ {agent.prompt} +

+ )} + + {running && agent.blocks.length === 0 && ( +

+ working… +

+ )} +
+ )} +
+ ) +} + +// Per-tool icon and fallback verbs. Tools whose verbs depend on the input +// (gravity_index) carry them on the block instead, so only the icon is set +// here. +const TOOL_META: Record< + string, + { icon: LucideIcon; running?: string; done?: string } +> = { + web_search: { icon: Search, running: 'Searching', done: 'Searched' }, + read_url: { icon: Globe, running: 'Reading', done: 'Read' }, + gravity_index: { icon: Compass }, +} + +function ToolRow({ tool }: { tool: ToolBlock }) { + const meta = TOOL_META[tool.toolName] ?? { icon: Wrench } + const Icon = meta.icon + const verb = + (tool.status === 'running' + ? (tool.verbs?.running ?? meta.running) + : (tool.verbs?.done ?? meta.done)) ?? tool.toolName + return ( +
+ {tool.status === 'running' ? ( + + ) : ( + + )} + {verb} + {tool.label && ( + + {tool.label} + + )} +
+ ) +} diff --git a/freebuff/web/src/app/chat/_components/chat-ads.tsx b/freebuff/web/src/app/chat/_components/chat-ads.tsx new file mode 100644 index 0000000000..eb67d36eb5 --- /dev/null +++ b/freebuff/web/src/app/chat/_components/chat-ads.tsx @@ -0,0 +1,239 @@ +'use client' + +import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { gravityContext, hashPii } from '@gravity-ai/api' +import { GravityAd as GravityReactAd } from '@gravity-ai/react' +import { useSession } from 'next-auth/react' +import posthog from 'posthog-js' +import { memo, useEffect, useRef, useState } from 'react' +import { z } from 'zod' + +import { CHAT_AD_EXPERIMENT, chatAdVariantForUser } from './ad-experiment' +import { GravityServerAd } from './gravity-server-ad' + +import type { GravityContext } from '@gravity-ai/api' + +import { trackRedditGravityAdClick } from '@/lib/reddit-funnel' + +const ROTATE_INTERVAL_MS = 60_000 +const FETCH_TIMEOUT_MS = 5_000 +/** Auctions per cycle; the slot pauses after this many until the next send. */ +const MAX_AD_FETCHES = 1 + +const adSchema = z.object({ + adText: z.string(), + title: z.string().catch(''), + cta: z.string().catch(''), + brandName: z.string().catch(''), + url: z.string().catch(''), + favicon: z.string().optional().catch(undefined), + impUrl: z.string(), + clickUrl: z.string(), + // Server-rendered layout tree, when Gravity attaches one; validated in + // gravity-ad-spec.ts before rendering. + renderer_spec: z.unknown().optional(), +}) + +type ChatAd = z.infer + +function parseAds(data: unknown): ChatAd[] { + const parsed = z.object({ ads: z.array(z.unknown()) }).safeParse(data) + if (!parsed.success) return [] + return parsed.data.ads.flatMap((raw) => { + const ad = adSchema.safeParse(raw) + return ad.success ? [ad.data] : [] + }) +} + +async function buildChatGravityContext(params: { + sessionId: string + userId?: string | null + email?: string | null +}): Promise { + const hashedIdentity = await hashPii({ email: params.email }).catch(() => ({})) + return gravityContext({ + sessionId: params.sessionId, + user: { + userId: params.userId ?? params.sessionId, + ...hashedIdentity, + }, + }) +} + +export type ChatAdSeed = { + /** Increments per send so a repeated identical message still restarts. */ + seq: number + content: string +} + +/** + * Rotating sponsored slot shown above the chat composer. Renders nothing + * until the user sends a message. Each message (re)starts a rotation cycle: + * an immediate auction targeted by that message, pausing until the next + * message. + */ +export const ChatAds = memo(function ChatAds({ + seed, +}: { + seed: ChatAdSeed | null +}) { + const [sessionId] = useState( + () => + globalThis.crypto?.randomUUID?.() ?? + `chat-${Math.random().toString(36).slice(2)}`, + ) + const [ad, setAd] = useState(null) + const { data: session } = useSession() + // Keyed by user id (not a boolean) so a session swap without a remount + // (e.g. account switch in another tab) still records the new user's + // exposure under their own variant. + const exposureTrackedFor = useRef(null) + + // Randomized experiment: server-rendered Gravity ads vs the existing + // inline unit, split 50/50 on the signed-in user id (stable across + // sessions and devices). See ad-experiment.ts. + const variant = chatAdVariantForUser(session?.user?.id) + + const handleAdClick = () => + trackRedditGravityAdClick('chat', { + experiment: CHAT_AD_EXPERIMENT, + variant, + }) + + useEffect(() => { + if (!seed) return + let fetchCount = 0 + let controller: AbortController | null = null + + const fetchNextAd = async () => { + fetchCount += 1 + const thisController = new AbortController() + controller = thisController + const timeout = setTimeout(() => thisController.abort(), FETCH_TIMEOUT_MS) + + // Only signed-in users get ads (/api/ads rejects the rest), so the + // bucketing input is stable by the time we get here. One exposure + // event per user per pageload keeps the PostHog denominators clean. + if (session?.user?.id && exposureTrackedFor.current !== session.user.id) { + exposureTrackedFor.current = session.user.id + posthog.capture(AnalyticsEvent.FREEBUFF_CHAT_ADS_EXPERIMENT_EXPOSED, { + experiment: CHAT_AD_EXPERIMENT, + variant, + }) + } + + try { + const gravityContextPayload = await buildChatGravityContext({ + sessionId, + userId: session?.user?.id, + email: session?.user?.email, + }) + const res = await fetch('/api/ads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages: [{ role: 'user', content: seed.content }], + sessionId, + gravity_context: gravityContextPayload, + surface: + variant === 'server_rendered' + ? 'chat_assistant_sr' + : 'chat_assistant', + }), + signal: thisController.signal, + }) + const next = res.ok ? parseAds(await res.json())[0] : undefined + if (next) { + setAd(next) + posthog.capture(AnalyticsEvent.FREEBUFF_CHAT_ADS_AD_SHOWN, { + experiment: CHAT_AD_EXPERIMENT, + variant, + brand_name: next.brandName, + title: next.title, + has_renderer_spec: next.renderer_spec != null, + }) + } + } catch { + } finally { + clearTimeout(timeout) + } + } + + void fetchNextAd() + if (MAX_AD_FETCHES <= 1) { + return () => { + controller?.abort() + } + } + + const interval = setInterval(() => { + if (document.hidden) return + if (fetchCount >= MAX_AD_FETCHES) { + clearInterval(interval) + return + } + void fetchNextAd() + }, ROTATE_INTERVAL_MS) + + return () => { + clearInterval(interval) + controller?.abort() + } + }, [seed, sessionId, session?.user?.id, session?.user?.email, variant]) + + if (!ad) return null + + if (variant === 'server_rendered') { + return ( + + ) + } + + return ( + + ) +}) diff --git a/freebuff/web/src/app/chat/_components/chat-app.tsx b/freebuff/web/src/app/chat/_components/chat-app.tsx new file mode 100644 index 0000000000..d1c482496c --- /dev/null +++ b/freebuff/web/src/app/chat/_components/chat-app.tsx @@ -0,0 +1,716 @@ +'use client' + +import { Check, Menu, Pencil, Plus, Search, Trash2, X } from 'lucide-react' +import Link from 'next/link' +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { + ChatDocument, + ChatImage, + ChatMessage, + PendingAttachment, + QueuedMessage, + ThreadSummary, +} from './types' + +// NB: `@/components/*` is aliased to vly in this package, so import relatively. +import { UnifiedNavbar } from '../../../components/landing/UnifiedNavbar' +import { + BlockTreeBuilder, + isChatBlockArray, + isChatStreamEvent, +} from '@/app/chat/blocks' +import { cn } from '@/lib/utils' +import { trackRedditFirstPromptOnce } from '@/lib/reddit-funnel' +import { ChatAds } from './chat-ads' +import { ChatBackdrop } from './chat-backdrop' +import { Composer } from './composer' +import { MessageList } from './message-list' + +function readThreadIdFromUrl(): string | null { + if (typeof window === 'undefined') return null + return new URLSearchParams(window.location.search).get('t') +} + +function writeThreadIdToUrl(threadId: string | null) { + const url = threadId ? `/chat?t=${threadId}` : '/chat' + window.history.replaceState(null, '', url) +} + +// Unsent composer text is kept per-thread in localStorage so switching +// threads (or reloading the page) never loses what you typed. +const DRAFT_STORAGE_PREFIX = 'freebuff_chat_draft:' + +function draftStorageKey(threadId: string | null) { + return `${DRAFT_STORAGE_PREFIX}${threadId ?? 'new'}` +} + +function readDraft(threadId: string | null): string { + try { + return localStorage.getItem(draftStorageKey(threadId)) ?? '' + } catch { + return '' + } +} + +function writeDraft(threadId: string | null, text: string) { + try { + if (text) { + localStorage.setItem(draftStorageKey(threadId), text) + } else { + localStorage.removeItem(draftStorageKey(threadId)) + } + } catch { + // Storage may be unavailable (private mode, quota); drafts just won't persist. + } +} + +export function ChatApp() { + const [threads, setThreads] = useState([]) + const [activeThreadId, setActiveThreadId] = useState(null) + const [messages, setMessages] = useState([]) + // The thread `messages` belongs to. Unlike activeThreadId (which flips as + // soon as a sidebar item is clicked), this updates together with setMessages + // so MessageList can restore the right scroll position for the content. + const [viewThreadId, setViewThreadId] = useState(null) + const [streaming, setStreaming] = useState(false) + // Messages the user submitted while a run was in flight. Buffered here and + // auto-sent one at a time as each run finishes — the server only allows one + // run per thread (409 response_in_progress), so we serialize on the client. + const [queue, setQueue] = useState([]) + // Full-access flag: gates image upload (limited users are restricted). Named + // for what it controls now that there's a single model and no picker. + const [canUploadImages, setCanUploadImages] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(false) + const [renamingId, setRenamingId] = useState(null) + const [confirmDeleteId, setConfirmDeleteId] = useState(null) + // Free-text filter over the sidebar thread list (title substring match). + const [threadQuery, setThreadQuery] = useState('') + // Last message the user sent; each send restarts the ad rotation cycle. + // seq distinguishes repeat sends of identical text. + const [adSeed, setAdSeed] = useState<{ + seq: number + content: string + } | null>(null) + const [draft, setDraft] = useState('') + // Composer attachments (images + documents), lifted here so they reset on + // thread switch (the Composer stays mounted across threads). + const [pendingAttachments, setPendingAttachments] = useState< + PendingAttachment[] + >([]) + const abortRef = useRef(null) + // The thread whose messages are on screen; guards against stale loads. + const viewedThreadRef = useRef(null) + // Which thread the current draft belongs to. Tracked separately from + // activeThreadId so that when a new chat gets its server-assigned id + // mid-stream, in-progress typing keeps saving under the right key. + const draftThreadRef = useRef(null) + + const changeDraft = useCallback((text: string) => { + setDraft(text) + writeDraft(draftThreadRef.current, text) + }, []) + + const refreshThreads = useCallback(async () => { + const res = await fetch('/api/chat/threads') + if (!res.ok) return + const data = await res.json() + setThreads(data.threads) + setCanUploadImages(Boolean(data.canUploadImages)) + }, []) + + const clearPendingAttachments = useCallback(() => { + setPendingAttachments((prev) => { + prev.forEach((a) => { + if (a.previewUrl) URL.revokeObjectURL(a.previewUrl) + }) + return [] + }) + }, []) + + const openThread = useCallback( + async (threadId: string | null) => { + abortRef.current?.abort() + setStreaming(false) + // Queued messages belong to the thread being left; drop them so they don't + // drain onto the newly-opened thread. + setQueue([]) + clearPendingAttachments() + setActiveThreadId(threadId) + setSidebarOpen(false) + viewedThreadRef.current = threadId + draftThreadRef.current = threadId + setDraft(readDraft(threadId)) + writeThreadIdToUrl(threadId) + if (!threadId) { + setMessages([]) + setViewThreadId(null) + return + } + const res = await fetch(`/api/chat/threads/${threadId}`) + if (viewedThreadRef.current !== threadId) return + if (!res.ok) { + setMessages([]) + setViewThreadId(threadId) + return + } + const data = await res.json() + if (viewedThreadRef.current !== threadId) return + setViewThreadId(threadId) + setMessages( + data.messages.map((m: any) => ({ + id: m.id, + role: m.role, + content: m.content, + blocks: isChatBlockArray(m.blocks) ? m.blocks : undefined, + images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined, + documents: Array.isArray(m.attachments) + ? (m.attachments as ChatDocument[]) + : undefined, + })), + ) + }, + [clearPendingAttachments], + ) + + useEffect(() => { + refreshThreads() + const initial = readThreadIdFromUrl() + if (initial) { + openThread(initial) + } else { + setDraft(readDraft(null)) + } + }, [refreshThreads, openThread]) + + useEffect(() => { + if (!sidebarOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setSidebarOpen(false) + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [sidebarOpen]) + + // Reflect the open thread's title in the browser tab so multiple chat tabs + // are distinguishable and history/bookmarks are meaningful. Falls back to the + // generic title on the new-chat screen (and on unmount). + useEffect(() => { + const DEFAULT_TITLE = 'Chat | Freebuff' + const openThreadTitle = viewThreadId + ? threads.find((t) => t.id === viewThreadId)?.title + : null + document.title = openThreadTitle + ? `${openThreadTitle} | Freebuff` + : DEFAULT_TITLE + return () => { + document.title = DEFAULT_TITLE + } + }, [viewThreadId, threads]) + + const send = useCallback( + async ( + content: string, + sentImages: ChatImage[] = [], + sentDocuments: ChatDocument[] = [], + ) => { + const threadIdAtSend = activeThreadId + const userMessage: ChatMessage = { + id: `local-user-${Date.now()}`, + role: 'user', + content, + images: sentImages.length > 0 ? sentImages : undefined, + documents: sentDocuments.length > 0 ? sentDocuments : undefined, + } + const assistantMessage: ChatMessage = { + id: `local-assistant-${Date.now()}`, + role: 'assistant', + content: '', + streaming: true, + } + setMessages((prev) => [...prev, userMessage, assistantMessage]) + setDraft('') + writeDraft(draftThreadRef.current, '') + setStreaming(true) + setAdSeed((prev) => ({ seq: (prev?.seq ?? 0) + 1, content })) + trackRedditFirstPromptOnce() + + const controller = new AbortController() + abortRef.current = controller + + const setAssistant = (update: Partial) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMessage.id ? { ...m, ...update } : m, + ), + ) + } + + try { + const res = await fetch('/api/chat/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + threadId: threadIdAtSend, + content, + // Send only opaque refs; the server resolves URLs / re-fetches the + // extracted text itself. + images: sentImages.map((img) => ({ + storageId: img.storageId, + mediaType: img.mediaType, + })), + attachments: sentDocuments.map((doc) => ({ + storageId: doc.storageId, + mediaType: doc.mediaType, + name: doc.name, + chars: doc.chars, + truncated: doc.truncated, + })), + }), + signal: controller.signal, + }) + + if (!res.ok || !res.body) { + const data = await res.json().catch(() => null) + setAssistant({ + streaming: false, + error: + data?.message ?? + 'Something went wrong sending your message. Please try again.', + }) + return + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let sawTerminalEvent = false + // Folds delta/agent_* events into the renderable tree; only used for + // rendering once a subagent or tool call appears, otherwise plain + // text wins. + const blockTree = new BlockTreeBuilder() + const assistantView = () => ({ + content: blockTree.rootText, + blocks: blockTree.hasActivityBlocks ? blockTree.snapshot() : undefined, + }) + + // Coalesce stream flushes to one per frame; re-parsing the full + // markdown on every token is the hot path on long answers. + let flushScheduled = false + const flushText = () => { + flushScheduled = false + setAssistant(assistantView()) + } + const scheduleFlush = () => { + if (flushScheduled) return + flushScheduled = true + requestAnimationFrame(flushText) + } + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.startsWith('data:')) continue + let event: any + try { + event = JSON.parse(line.slice(5)) + } catch { + continue + } + if (event.type === 'meta' && !threadIdAtSend) { + setActiveThreadId(event.threadId) + setViewThreadId(event.threadId) + viewedThreadRef.current = event.threadId + writeThreadIdToUrl(event.threadId) + // Show the new thread in the sidebar immediately with the + // prompt-prefix placeholder title; a later `title` event swaps in + // the model-generated one, and refreshThreads() reconciles at the + // end. + setThreads((prev) => + prev.some((t) => t.id === event.threadId) + ? prev + : [ + { + id: event.threadId, + title: event.title, + model: event.model ?? '', + updated_at: new Date().toISOString(), + }, + ...prev, + ], + ) + // Re-home any follow-up typing from the "new chat" draft slot + // to the thread the server just created. + if (draftThreadRef.current === null) { + const pending = readDraft(null) + draftThreadRef.current = event.threadId + writeDraft(null, '') + if (pending) writeDraft(event.threadId, pending) + } + } else if (event.type === 'title') { + // Server finished generating the short thread name; swap it into + // the sidebar in place of the placeholder. + setThreads((prev) => + prev.map((t) => + t.id === event.threadId ? { ...t, title: event.title } : t, + ), + ) + } else if (isChatStreamEvent(event)) { + blockTree.apply(event) + scheduleFlush() + } else if (event.type === 'error') { + sawTerminalEvent = true + blockTree.finalize() + setAssistant({ + ...assistantView(), + streaming: false, + error: event.message, + }) + } else if (event.type === 'done') { + sawTerminalEvent = true + } + } + } + blockTree.finalize() + setAssistant({ + ...assistantView(), + streaming: false, + // EOF without a terminal event = the connection dropped mid-answer. + ...(sawTerminalEvent + ? {} + : { + error: + 'Connection interrupted — this response may be incomplete.', + }), + }) + } catch (error) { + const aborted = error instanceof Error && error.name === 'AbortError' + setAssistant( + aborted + ? { streaming: false } + : { + streaming: false, + error: 'Connection lost. Please try again.', + }, + ) + } finally { + setStreaming(false) + abortRef.current = null + refreshThreads() + } + }, + [activeThreadId, refreshThreads], + ) + + const stop = useCallback(() => { + abortRef.current?.abort() + }, []) + + // The composer always calls this. If a run is in flight, buffer the message + // instead of firing a concurrent request; otherwise send immediately. + const enqueueOrSend = useCallback( + ( + content: string, + images: ChatImage[] = [], + documents: ChatDocument[] = [], + ) => { + if (!streaming) { + send(content, images, documents) + return + } + setQueue((q) => [...q, { content, images, documents }]) + // The composer clears its own attachments; clear the draft we own here. + changeDraft('') + }, + [streaming, send, changeDraft], + ) + + const removeQueued = useCallback((index: number) => { + setQueue((q) => q.filter((_, i) => i !== index)) + }, []) + + // Drain one queued message whenever the run finishes. Done in an effect + // (not send's finally block) so it picks up the latest send closure — its + // activeThreadId is only correct after the first run's `meta` event assigns + // the server-generated thread id. + useEffect(() => { + if (streaming || queue.length === 0) return + const [next, ...rest] = queue + setQueue(rest) + send(next.content, next.images, next.documents) + }, [streaming, queue, send]) + + const removeThread = useCallback( + async (threadId: string) => { + setThreads((prev) => prev.filter((t) => t.id !== threadId)) + setConfirmDeleteId(null) + writeDraft(threadId, '') + if (activeThreadId === threadId) { + openThread(null) + } + const res = await fetch(`/api/chat/threads/${threadId}`, { + method: 'DELETE', + }).catch(() => null) + if (!res?.ok) { + // Roll back the optimistic removal. + refreshThreads() + } + }, + [activeThreadId, openThread, refreshThreads], + ) + + const saveRename = useCallback( + async (threadId: string, title: string) => { + const trimmed = title.trim() + setRenamingId(null) + if (!trimmed) return + setThreads((prev) => + prev.map((t) => (t.id === threadId ? { ...t, title: trimmed } : t)), + ) + const res = await fetch(`/api/chat/threads/${threadId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: trimmed }), + }).catch(() => null) + if (!res?.ok) { + // Roll back the optimistic rename. + refreshThreads() + } + }, + [refreshThreads], + ) + + const isEmptyChat = messages.length === 0 + + const composer = ( + + ) + + const normalizedThreadQuery = threadQuery.trim().toLowerCase() + const visibleThreads = normalizedThreadQuery + ? threads.filter((t) => + t.title.toLowerCase().includes(normalizedThreadQuery), + ) + : threads + + const sidebar = ( +
+ {/* pt-16 clears the fixed, overlaying nav bar above so "New chat" and + the brand logo never collide. */} +
+ +
+ {/* Search appears once there's enough history to be worth filtering. + Also stays visible while a query is active, so deleting threads down + to the threshold can't strand a filter with no way to clear it. */} + {(threads.length > 5 || threadQuery !== '') && ( +
+
+ + setThreadQuery(e.target.value)} + placeholder="Search chats" + aria-label="Search chats" + className="w-full rounded-lg bg-white/5 py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-white/20" + /> +
+
+ )} + +
+

+ By Freebuff ·{' '} + + Try the coding agent → + +

+
+
+ ) + + return ( +
+ + setSidebarOpen(true)} + className="flex h-9 w-9 items-center justify-center rounded-lg text-white/80 transition-colors hover:bg-white/10 hover:text-white md:hidden" + > + + + } + /> + + {/* Extends all the way to the top, behind the fixed nav above, so the + sidebar/composer content runs edge-to-edge and the nav overlays on + top of it (transparent at rest, fading in its own gradient mask on + scroll) instead of leaving a reserved gap. */} +
+ {/* Desktop sidebar */} + + + {/* Mobile sidebar */} + {sidebarOpen && ( +
+
setSidebarOpen(false)} + /> + +
+ )} + +
+ {isEmptyChat ? ( +
+

+ What can I help with? +

+
{composer}
+
+ ) : ( + <> + enqueueOrSend(prompt)} + /> +
+
+ + {composer} +
+
+ + )} +
+
+
+ ) +} diff --git a/freebuff/web/src/app/chat/_components/chat-backdrop.tsx b/freebuff/web/src/app/chat/_components/chat-backdrop.tsx new file mode 100644 index 0000000000..60625ec01b --- /dev/null +++ b/freebuff/web/src/app/chat/_components/chat-backdrop.tsx @@ -0,0 +1,33 @@ +'use client' + +import { memo } from 'react' + +// NB: `@/components/*` is aliased to vly in this package, so import relatively. +import { Starfield } from '../../../components/landing/Starfield' + +/** + * Ambient backdrop for the chat surface so it matches freebuff.com: the same + * dark night-sky gradient and twinkling + shooting stars as the landing hero, + * warmed with a soft forest-green glow up top to echo the brand palette. Sits + * behind all chat chrome (sidebar/main are translucent so it shows through); + * purely decorative and non-interactive. + */ +export const ChatBackdrop = memo(function ChatBackdrop() { + return ( +
+ {/* Vertical night-sky gradient (near-flat, only a whisper of green). */} +
+ + {/* Very soft forest-green glow biased to the top, like the landing scene. */} +
+ + {/* Twinkling stars + shooting stars, kept to the open upper sky. */} +
+ +
+
+ ) +}) diff --git a/freebuff/web/src/app/chat/_components/composer.tsx b/freebuff/web/src/app/chat/_components/composer.tsx new file mode 100644 index 0000000000..571cfb58e3 --- /dev/null +++ b/freebuff/web/src/app/chat/_components/composer.tsx @@ -0,0 +1,441 @@ +'use client' + +// IMPORTANT: the /chat route (this Composer's only consumer) is NOT wrapped in +// a Convex provider — it's a next-auth + HTTP-API surface (see app/chat/layout.tsx, +// ConvexClientProvider only lives under app/web and app/cloud). Do not import +// components that call Convex hooks (useQuery/useAction/useConvexAuth) here, or +// the whole page throws "Could not find ConvexProviderWithAuth". + +import { ArrowUp, FileText, Mic, Paperclip, Square, X } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +import type { ChatDocument, ChatImage, PendingAttachment } from './types' + +import { + CHAT_DOC_ALLOWED_EXTENSIONS, + CHAT_DOC_MAX_BYTES, + CHAT_DOC_MAX_COUNT, + CHAT_IMAGE_ALLOWED_TYPES, + CHAT_IMAGE_MAX_BYTES, + CHAT_IMAGE_MAX_COUNT, + CHAT_MESSAGE_MAX_CHARS, + classifyAttachment, +} from '@/app/chat/models' +import { cn } from '@/lib/utils' + +/** Whether a drag event carries files (vs. text/other), used to gate the + * drop-to-attach affordance. */ +function dragHasFiles(e: React.DragEvent): boolean { + return Array.from(e.dataTransfer.types).includes('Files') +} + +function makeLocalId(): string { + return `att-${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +/** Comma-separated accept list: image MIME types + document extensions. */ +const ACCEPT_ATTR = [ + ...CHAT_IMAGE_ALLOWED_TYPES, + ...CHAT_DOC_ALLOWED_EXTENSIONS, +].join(',') + +/** Human-readable size for a document chip (e.g. "12K chars"). */ +function formatChars(chars: number): string { + if (chars < 1000) return `${chars} chars` + if (chars < 1_000_000) return `${Math.round(chars / 1000)}K chars` + return `${(chars / 1_000_000).toFixed(1)}M chars` +} + +export function Composer(props: { + value: string + onChange: (value: string) => void + onSend: ( + content: string, + images: ChatImage[], + documents: ChatDocument[], + ) => void + onStop: () => void + streaming: boolean + /** Full-access only; limited users (unsupported countries, VPN/proxy) don't + * get the attach button or accept drops/pastes. */ + canUpload: boolean + attachments: PendingAttachment[] + setAttachments: React.Dispatch> + autoFocus?: boolean +}) { + const { value, onChange, attachments, setAttachments } = props + const [dragging, setDragging] = useState(false) + const textareaRef = useRef(null) + const fileInputRef = useRef(null) + // Depth counter so nested dragenter/leave events don't flicker the overlay. + const dragDepth = useRef(0) + + const allowUpload = props.canUpload + + useEffect(() => { + if (props.autoFocus) { + textareaRef.current?.focus() + } + }, [props.autoFocus]) + + const resize = () => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 200)}px` + } + + // Covers typing and external value changes (draft restored, cleared on send). + useEffect(() => { + resize() + }, [value]) + + const removeAttachment = (id: string) => { + setAttachments((prev) => { + const target = prev.find((a) => a.id === id) + if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl) + return prev.filter((a) => a.id !== id) + }) + } + + const uploadOne = async (localId: string, file: File) => { + try { + const form = new FormData() + form.append('file', file) + const res = await fetch('/api/chat/upload', { + method: 'POST', + body: form, + }) + const data = await res.json().catch(() => null) + if (!res.ok || !data?.storageId) { + throw new Error(data?.message ?? 'Upload failed') + } + setAttachments((prev) => + prev.map((a) => + a.id === localId + ? { + ...a, + status: 'done', + storageId: data.storageId, + // Image fields (url) / document fields (chars…) as returned. + url: data.url, + chars: data.chars, + truncated: data.truncated, + } + : a, + ), + ) + } catch (error) { + setAttachments((prev) => + prev.map((a) => + a.id === localId + ? { + ...a, + status: 'error', + error: + error instanceof Error ? error.message : 'Upload failed', + } + : a, + ), + ) + } + } + + const addFiles = (files: File[]) => { + if (!allowUpload || files.length === 0) return + setAttachments((prev) => { + let images = prev.filter((a) => a.kind === 'image').length + let docs = prev.filter((a) => a.kind === 'document').length + const additions: PendingAttachment[] = [] + for (const file of files) { + const kind = classifyAttachment(file.name, file.type) + if (kind === 'image') { + if (images >= CHAT_IMAGE_MAX_COUNT) continue + if (file.size > CHAT_IMAGE_MAX_BYTES) continue + images++ + } else if (kind === 'document') { + if (docs >= CHAT_DOC_MAX_COUNT) continue + if (file.size > CHAT_DOC_MAX_BYTES) continue + docs++ + } else { + continue + } + const id = makeLocalId() + // Kick off the upload; state updates land via setAttachments above. + void uploadOne(id, file) + additions.push({ + id, + kind, + name: file.name, + mediaType: file.type, + status: 'uploading', + ...(kind === 'image' + ? { previewUrl: URL.createObjectURL(file) } + : {}), + }) + } + return [...prev, ...additions] + }) + } + + const onPickFiles = (e: React.ChangeEvent) => { + if (e.target.files) addFiles(Array.from(e.target.files)) + e.target.value = '' + } + + const onPaste = (e: React.ClipboardEvent) => { + if (!allowUpload) return + // Paste handles images (screenshots); documents come via picker/drop. + const files = Array.from(e.clipboardData.files).filter((f) => + f.type.startsWith('image/'), + ) + if (files.length > 0) { + e.preventDefault() + addFiles(files) + } + } + + const onDragEnter = (e: React.DragEvent) => { + if (!allowUpload || !dragHasFiles(e)) return + dragDepth.current += 1 + setDragging(true) + } + const onDragLeave = (e: React.DragEvent) => { + if (!allowUpload || !dragHasFiles(e)) return + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setDragging(false) + } + const onDrop = (e: React.DragEvent) => { + if (!allowUpload || !dragHasFiles(e)) return + e.preventDefault() + dragDepth.current = 0 + setDragging(false) + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) addFiles(files) + } + + const uploading = attachments.some((a) => a.status === 'uploading') + const doneAttachments = attachments.filter((a) => a.status === 'done') + const imageCount = attachments.filter((a) => a.kind === 'image').length + const docCount = attachments.filter((a) => a.kind === 'document').length + const atCapacity = + imageCount >= CHAT_IMAGE_MAX_COUNT && docCount >= CHAT_DOC_MAX_COUNT + // Submitting while a run streams is allowed — the message is queued by the + // parent and auto-sent when the run finishes. + const canSend = + !uploading && (value.trim().length > 0 || doneAttachments.length > 0) + + const submit = () => { + if (!canSend) return + const images: ChatImage[] = doneAttachments + .filter((a) => a.kind === 'image') + .map((a) => ({ + storageId: a.storageId!, + url: a.url!, + mediaType: a.mediaType, + name: a.name, + })) + const documents: ChatDocument[] = doneAttachments + .filter((a) => a.kind === 'document') + .map((a) => ({ + storageId: a.storageId!, + mediaType: a.mediaType, + name: a.name, + chars: a.chars ?? 0, + truncated: a.truncated ?? false, + })) + props.onSend(value.trim(), images, documents) + // Free the local preview URLs; the transcript renders the resolved URLs. + attachments.forEach((a) => { + if (a.previewUrl) URL.revokeObjectURL(a.previewUrl) + }) + setAttachments([]) + } + + return ( +
{ + if (allowUpload && dragHasFiles(e)) { + e.preventDefault() + } + }} + onDragLeave={onDragLeave} + onDrop={onDrop} + className={cn( + 'relative rounded-2xl border bg-white/[0.04] transition-colors shadow-lg shadow-black/20', + dragging + ? 'border-white/40 bg-white/[0.08]' + : 'border-white/10 focus-within:border-white/20', + )} + > + {dragging && ( +
+ Drop files to attach +
+ )} + + {/* Willow voice dictation link (top-right corner) */} + + + + + + {attachments.length > 0 && ( +
+ {attachments.map((att) => + att.kind === 'image' ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {att.name} + {att.status === 'uploading' && ( +
+ +
+ )} + {att.status === 'error' && ( +
+ Failed +
+ )} + +
+ ) : ( +
+ + {att.status === 'uploading' ? ( + + ) : ( + + )} + +
+
+ {att.name} +
+
+ {att.status === 'error' + ? 'Failed' + : att.status === 'uploading' + ? 'Reading…' + : att.chars != null + ? `${formatChars(att.chars)}${att.truncated ? ' · truncated' : ''}` + : 'Ready'} +
+
+ +
+ ), + )} +
+ )} + +