diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..318c388 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "packx402-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.env.example b/.env.example index b15a8f5..67ca854 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Pack402 environment configuration +# PackX402 environment configuration # Copy to .env.local for local dev. NEVER commit a file containing real values. # Every secret below must be provisioned per-environment (local/staging/prod) — do not share across environments. @@ -21,7 +21,7 @@ FIELD_ENCRYPTION_KEY= # --------------------------------------------------------------------------- # Database (Postgres via Drizzle ORM) # --------------------------------------------------------------------------- -DATABASE_URL=postgres://pack402:pack402@localhost:5432/pack402 +DATABASE_URL=postgres://packx402:packx402@localhost:5432/packx402 # --------------------------------------------------------------------------- # Redis-compatible cache / rate limiting / queues @@ -37,14 +37,14 @@ SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= -EMAIL_FROM=Pack402 +EMAIL_FROM=PackX402 # --------------------------------------------------------------------------- # S3-compatible object storage (profile images, showcase covers) # --------------------------------------------------------------------------- S3_ENDPOINT= S3_REGION=auto -S3_BUCKET=pack402-uploads +S3_BUCKET=packx402-uploads S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_PUBLIC_BASE_URL= @@ -97,7 +97,7 @@ CARDTRADER_ACCOUNT_ID= # --------------------------------------------------------------------------- # Feature flags / safety switches # --------------------------------------------------------------------------- -FEATURE_HIGH_VALUE_PACKS_ENABLED=false # server-side gate for packs > $100 +FEATURE_HIGH_VALUE_PACKS_ENABLED=false # server-side gate for packs > $250 (bankroll-limited during beta) FEATURE_LOYALTY_ENABLED=true FEATURE_LOYALTY_KILL_SWITCH=false FEATURE_AFFILIATE_PROGRAM_ENABLED=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..41ee1dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug in PackX402 +title: "[Bug] " +labels: bug +--- + +## Description + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +## Actual behavior + +## Environment + +- Branch/commit: +- `NODE_ENV` / `APP_ENV`: +- Browser (if UI bug): + +## Security-sensitive? + +If this is a security vulnerability, **do not file a public issue** — see +[SECURITY.md](../../SECURITY.md) for the private reporting process. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6747f9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Propose a new feature or change for PackX402 +title: "[Feature] " +labels: enhancement +--- + +## Problem + +What's missing or painful today? + +## Proposed solution + +## Relevant spec section(s) + + + +## Alternatives considered + +## Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a55f341 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Summary + + + +## Website areas / spec sections touched + + + +## Test evidence + +- [ ] `npm run typecheck` passes +- [ ] `npm run lint` passes +- [ ] `npm run test` passes (paste the summary line) +- [ ] `npm run build` passes +- [ ] If schema changed: `npm run db:generate` was run and the migration is committed +- [ ] Manually verified against a local database (`docker compose up -d && npm run +db:migrate && npm run dev`), if the change touches a DB-backed route or page + +## Security checklist + +- [ ] No secrets committed +- [ ] No new client-trusted value used for price/eligibility/limits without server + re-derivation +- [ ] No new unencrypted sensitive field +- [ ] `PROJECT_STATUS.md` updated if this moves something from unverified/not-started to + implemented-and-tested, or introduces a new gap + +## Known limitations / follow-ups + + + +## Screenshots (if UI) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a30d4e4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7dfe46f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,195 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + install: + name: Install dependencies (locked) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + + format: + name: Format check + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run format:check + + lint: + name: ESLint + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + + typecheck: + name: Strict TypeScript + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run typecheck + + unit-tests: + name: Unit & integration tests + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run test + + build: + name: Production build + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run build + env: + SESSION_SECRET: ci-placeholder-session-secret-32-chars-min + FIELD_ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000 + DATABASE_URL: postgres://ci:ci@localhost:5432/ci + REDIS_URL: redis://localhost:6379 + + db-schema-check: + name: Database schema validation + runs-on: ubuntu-latest + needs: install + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: packx402 + POSTGRES_PASSWORD: packx402 + POSTGRES_DB: packx402 + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U packx402 -d packx402" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Fail if the checked-in schema has ungenerated migrations + run: npx drizzle-kit generate --name ci-schema-check-should-be-empty + env: + DATABASE_URL: postgres://packx402:packx402@localhost:5432/packx402 + - name: Verify no new migration file was produced + run: | + if [ -n "$(git status --porcelain drizzle/)" ]; then + echo "Schema changed without a committed migration. Run 'npm run db:generate' and commit the result." + git status --porcelain drizzle/ + exit 1 + fi + + secret-scan: + name: Secret scan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dependency-audit: + name: Dependency audit + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm audit --audit-level=critical + + sbom: + name: Software bill of materials + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json + - uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.json + + e2e: + name: Playwright end-to-end tests + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: Check for Playwright tests + id: check + run: echo "has_tests=$(find tests -name '*.spec.ts' 2>/dev/null | wc -l)" >> "$GITHUB_OUTPUT" + - name: Install Playwright browsers + if: steps.check.outputs.has_tests != '0' + run: npx playwright install --with-deps + - name: Run Playwright tests + if: steps.check.outputs.has_tests != '0' + run: npm run test:e2e + - name: No Playwright tests yet + if: steps.check.outputs.has_tests == '0' + run: echo "No Playwright specs found yet — see PROJECT_STATUS.md. Skipping without failing the build." diff --git a/.gitignore b/.gitignore index a43eb5d..63eb53b 100644 --- a/.gitignore +++ b/.gitignore @@ -46,7 +46,7 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# --- Pack402 secrets & sensitive data (never commit) --- +# --- PackX402 secrets & sensitive data (never commit) --- *.pem *.key *.p12 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..8e60c47 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.next +drizzle +coverage +playwright-report +test-results +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..18b9dc5 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..8921129 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,85 @@ + # This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +# PackX402 — Agent Instructions + +## Start every session by reading, in order + +1. [PROJECT_STATUS.md](PROJECT_STATUS.md) — what's actually implemented vs. tested vs. + stubbed vs. not started. This is the source of truth, not this file's memory of a past + session. +2. `git log --oneline -20` and `git status` — what changed since PROJECT_STATUS.md was + last updated. +3. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for module layout. + +## Current project phase + +Beta scaffold: core business logic (fairness, payments, supplier adapter, auth +primitives, responsible purchasing, loyalty) is implemented and unit-tested; most UI and +several orchestration layers (auth routes, admin, social, affiliate, the supplier-purchase +worker) are not yet built. See PROJECT_STATUS.md for the exact split — do not assume a +feature exists because its database schema does. + +## Non-negotiable security requirements + +- Never store wallet private keys or seed phrases, anywhere, for any reason. +- Never use `Math.random()` for pack selection — only `src/server/fairness/engine.ts`'s + deterministic, committed algorithm. +- Never trust a client-supplied price, tier availability, or purchase-limit value — always + re-derive from server/DB state (`createPackOffer()` is the reference pattern). +- Never enable `FEATURE_HIGH_VALUE_PACKS_ENABLED=true` or `ALGORAND_MAINNET_ENABLED=true` + without the corresponding review in `docs/LEGAL_REVIEW_REQUIRED.md` being complete. +- Never set `CARDTRADER_MODE=live` or `X402_FACILITATOR_MODE=live` without a verified + integration pass against real credentials — both are currently unverified (see + PROJECT_STATUS.md). +- All money values are integer USDC base units (`src/shared/money.ts`) — never floats. + +## Commands that must pass before committing + +```bash +npm run typecheck +npm run lint +npm run test +npm run build +``` + +If you change `src/server/db/schema/`, also run `npm run db:generate` and commit the +resulting migration file under `drizzle/`. + +## Prohibited without explicit instruction + +- Committing secrets, `.env`/`.env.local`, or any file matching the sensitive patterns in + `.gitignore`. +- Enabling MainNet or high-value packs (see above). +- Force-pushing, rewriting history on `main`, or merging your own draft PR. +- Adding a new third-party payment or supplier integration without first checking its + package actually exists and matches the documented API (see `docs/DECISIONS.md` for the + verification approach used for x402/CardTrader). + +## Required after any change + +Update `PROJECT_STATUS.md` if you've moved something from 🟡/⬜ to ✅, or discovered a new +gap. Update `docs/ROADMAP.md` if the phase sequencing changes. Do not leave +`PROJECT_STATUS.md` stale relative to what you actually built. + +## Three-pass review procedure (spec section 52) + +Before considering a change complete: + +1. **Correctness**: format, lint, strict typecheck, unit tests, build all pass. +2. **Security**: re-read the threat model (`docs/THREAT_MODEL.md`) against your change — + does it introduce a new client-trust boundary, a new unencrypted sensitive field, a new + unauthenticated mutating endpoint? +3. **Product**: mobile + desktop layout, keyboard operability, reduced-motion support, + empty/loading/error states. + +## Pull-request and review process + +Work happens on `beta/initial-packx402-build` (or a similarly named feature branch) against +`main`. Open a **draft** PR; do not self-merge. The PR description must summarize what was +implemented, what tests were run, and what remains — see the actual open PR for the +current template this repo uses. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..23e3199 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to this project are documented here. Format loosely follows +[Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] — 2026-08-01 + +### Added + +- Initial project scaffold: Next.js 16 (App Router, Turbopack), strict TypeScript, + Tailwind CSS 4, Drizzle ORM + PostgreSQL, Docker Compose (Postgres + Redis). +- Full 59-table data model covering every entity in the product spec. +- Configuration-driven pack tier catalog (Spark–Genesis) with server-side network/value + gating. +- Deterministic provably-fair selection engine with a published, hand-verified test + vector. +- x402 Algorand/Solana/EVM payment adapters (mock-mode default; live-mode direct + chain-verification paths implemented but unverified against real credentials). +- CardTrader supplier adapter (mock-mode default; live-mode implemented but unverified). +- Auth primitives: opaque hashed sessions, SIWE-style wallet-signature messages, and real + per-chain signature verification (Algorand/Solana/EVM), each tested against a real + generated keypair. +- Server-side age-gate/eligibility policy and responsible-purchasing limit evaluation. +- Loyalty-level calculation with a beta reward cap. +- Landing page, pack marketplace, pack detail, provably-fair verifier, and odds-library + pages. +- Global security headers (CSP/HSTS/etc.) + CSRF double-submit cookie + Redis rate-limit + helper. +- 77 passing Vitest unit/integration tests; clean `npm run build`. +- Full documentation set (`docs/`), GitHub Actions CI, issue/PR templates. + +### Known gaps + +See [PROJECT_STATUS.md](PROJECT_STATUS.md) — most UI, auth API routes, the +supplier-purchase worker, admin dashboard, and social/affiliate/referral logic are not +yet built. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..d8ea6d2 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,10 @@ +# Default owner for everything in the repo. +* @The-Daly + +# Security-sensitive areas — require extra scrutiny. +/src/server/fairness/ @The-Daly +/src/server/payments/ @The-Daly +/src/server/auth/ @The-Daly +/src/server/crypto/ @The-Daly +/src/server/db/schema/ @The-Daly +/.github/workflows/ @The-Daly diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a1b9cf5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +## Our pledge + +We as contributors and maintainers pledge to make participation in the PackX402 project a +harassment-free experience for everyone, regardless of age, body size, disability, +ethnicity, gender identity and expression, level of experience, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- Harassment, insulting or derogatory comments, and personal or political attacks +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional + setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported via +the contact listed in the repository's Security/About section. All complaints will be +reviewed and investigated. + +## Attribution + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version +2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8858806 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,42 @@ +# Contributing + +## Getting started + +See the [README](README.md) quick-start. Read [PROJECT_STATUS.md](PROJECT_STATUS.md) +before starting work — it's the authoritative list of what exists. + +## Workflow + +1. Branch from `main`. +2. Make focused commits. +3. Before opening a PR, run: + ```bash + npm run typecheck + npm run lint + npm run test + npm run build + ``` +4. If you changed `src/server/db/schema/`, run `npm run db:generate` and commit the + resulting migration. +5. Open a PR against `main`. Fill out the PR template completely, including test + evidence. +6. Update `PROJECT_STATUS.md` if your change moves something from unverified/not-started + to implemented-and-tested, or if it introduces a new gap. + +## Code style + +- Strict TypeScript; no `any` without a comment explaining why it's unavoidable. +- Prefer pure functions for anything safety-critical (fairness, purchase limits, + eligibility) so it can be unit-tested without a database. +- Integer USDC base units for all money — never floating point. +- No comments explaining _what_ code does (names should do that); comments are for _why_ + — a non-obvious constraint, a workaround, or a trade-off. + +## Security + +Do not open a public issue for a security vulnerability — see [SECURITY.md](SECURITY.md). + +## Commit messages + +Short, present-tense, why-focused. No AI attribution requirements beyond what your tooling +adds automatically. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..f0727c9 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,98 @@ +# PackX402 — Handoff Prompt + +Paste this at the start of a new session to resume work with full context. + +--- + +## Copy-paste handoff prompt + +``` +I'm continuing work on PackX402 (repo: The-Daly/packx402, branch +beta/initial-packx402-build, draft PR #1) — a wallet-native trading-card +pack-opening platform on Algorand using the x402 payment protocol. + +Start by reading, in order: +1. PROJECT_STATUS.md — the real source of truth for what's implemented vs. + tested vs. stubbed vs. not started. Don't assume a feature exists just + because its DB schema does. +2. `git log --oneline -20` and `git status` — what's changed since + PROJECT_STATUS.md was last touched. +3. docs/ARCHITECTURE.md for module layout. +4. AGENTS.md for the non-negotiable rules (never Math.random() for card + selection, never trust client-supplied price/tier data, integer USDC + base units only, no MainNet/high-value packs without legal review, run + typecheck/lint/test/build before every commit). + +Current state as of the last session: +- Real provably-fair engine (commit-reveal + sha256 roll) is implemented + and unit-tested, including a REAL 4% bonus-flip mechanic (second card on + a hit, not cosmetic) and a six-rarity odds/price-band structure + (common/uncommon/rare/epic/legendary/grail) applied proportionally + across all 14 pack tiers. +- Real card images (Pokemon TCG API + YGOPRODeck API) are wired into both + the spin animation and the final reveal, through a domain-allowlisted + resolver (src/server/card-images/resolver.ts). +- Pack-opening history is user-scoped (/api/packs/openings, + /app/collection) — the fairness *verify* endpoint stays public by + ripId on purpose, per explicit user decision. +- Rip gesture works from anywhere on the pack (not just a handle), with a + jagged interlocking clip-path so the torn piece and remaining pack don't + show a duplicated top. +- Pack selection UI is a price-ordered, uniform-size horizontal shelf + (PackShelf component) — inspired by a competitor reference app but + deliberately not a copy. +- Apple Pay / PayPal show as visible-but-disabled payment options, clearly + marked unverified in PROJECT_STATUS.md / docs/LEGAL_REVIEW_REQUIRED.md. +- Pera Wallet is really wired up: @txnlab/use-wallet-react + + @perawallet/connect (all real installed packages, including the other + connectors' peer deps this library unconditionally imports — see the + "@txnlab/use-wallet build failure" note in PROJECT_STATUS.md/git history + if you touch next.config.ts or package.json around wallets). Connect, + build-ASA-transfer, sign, submit, wait-for-confirmation, and re-POST + with a real X-PAYMENT header are all implemented against the documented + adapter contract — NOT yet verified end-to-end with a funded TestNet + wallet in this environment. +- The landing page (src/app/page.tsx) now has an interactive, no-DB, + no-wallet demo (InteractivePackDemo component, also used at + /dev/rip-preview) of the full carousel-select → rip → spin → reveal → + bonus-flip sequence, plus a 4-step plain-language walkthrough of the + commit-reveal algorithm. Verified live in-browser that pack selection + and rip progression both work correctly. + +Known gaps / not-yet-done (see PROJECT_STATUS.md for the authoritative, +up-to-date list): +- No Docker/Postgres available in this dev environment — DB-backed pages + (marketplace, pack detail with real tiers, opening theater, collection) + cannot be exercised live here; only /dev/rip-preview and the new + landing-page demo work without a DB. +- No real Google OAuth credentials for end-to-end auth testing. +- No funded TestNet Pera wallet to click through connect → sign → submit + → settle for real. +- Defly / Phantom (Solana, EVM) wallets are not yet integrated — only Pera + is wired. +- The supplier-purchase worker process (consumes the `queued` + SupplierPurchase rows) is not implemented — currently just enqueues. +- CardTrader and x402 facilitator are both in mock mode; live mode needs a + verified integration pass against real credentials before flipping + CARDTRADER_MODE=live / X402_FACILITATOR_MODE=live. +- The "volatility level" (Normal/High/Max) odds selector from the + reference competitor app has been discussed but not built. + +Before committing anything, run: + npm run typecheck && npm run lint && npm run test && npm run build +and update PROJECT_STATUS.md (and docs/LEGAL_REVIEW_REQUIRED.md / +docs/FAIRNESS_PROTOCOL.md if relevant) per AGENTS.md's "Required after any +change" section. Work stays on beta/initial-packx402-build against main; +PR #1 is a draft — don't self-merge. + +Ask me what to pick up next, or if I've already told you, start there. +``` + +## Notes for whoever pastes this + +- This file (`HANDOFF.md`) is a point-in-time snapshot from the session + that added the landing-page interactive demo. Treat PROJECT_STATUS.md, + not this file, as authoritative for anything that may have changed + since — this file itself is not a durable memory and can drift. +- Delete or update this file once its contents are stale rather than + letting two conflicting status documents accumulate. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c276224 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2026 The-Daly. All rights reserved. + +This repository is publicly visible for transparency and collaboration purposes only. +No license is granted to copy, modify, distribute, sublicense, or use this software or +any portion of it, in source or compiled form, for any purpose, without prior written +permission from the copyright holder. + +NOTE TO MAINTAINER: this default was chosen automatically because a license decision +prompt went unanswered during the initial scaffold. This is a placeholder appropriate for +a proprietary commercial product kept on a public repo for transparency — revisit and +replace with your actual intended license (proprietary, MIT, Business Source License, +etc.) before this matters for a real dispute. See the PR description for context. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..713dbfb --- /dev/null +++ b/PROJECT_STATUS.md @@ -0,0 +1,370 @@ +# PackX402 — Project Status + +Last updated: 2026-08-02 (beta scaffold + PackArt visual system + opening theater + +Google-only OAuth + real bonus-flip mechanic + pack shelf). + +This document is the single source of truth for what is actually implemented, what is +scaffolded but unverified, and what has not been started. Do not trust marketing language +elsewhere in the repo over this file — if something here says "not built," it is not built. + +## How to read this file + +- ✅ **Implemented & tested** — real code, covered by passing automated tests (unit or + integration), or verified against a real cryptographic primitive/keypair. +- 🟡 **Implemented, unverified** — real code that type-checks and builds, but has not been + exercised against a live database, live network, or live third-party credential in this + environment (none were available: no Docker/Postgres, no CardTrader token, no GoPlausible + facilitator credentials). +- ⬜ **Not started** — schema/types may exist, but no business logic or UI. + +## Environment constraints during this build + +This repository was built in a sandboxed environment with **no Docker, no local +PostgreSQL, and no live third-party credentials** (CardTrader, GoPlausible/x402 +facilitator). Everything that could be verified without those — TypeScript strict +typechecking against the full Drizzle schema, the production `next build`, and 86 Vitest +unit/integration tests including real generated-keypair signature verification for +Algorand/Solana/EVM — passes. Anything that requires a live database or live credentials +is marked 🟡 and needs to be verified by a human with `docker compose up -d` before +production use. + +## What's implemented and tested (✅) + +- **Data model**: full 59-table Drizzle/Postgres schema covering every entity in the spec + (users, wallets, sessions, pack tiers, pools, suppliers, offers, payments, rips, + fairness proofs, fulfillment, free packs, loyalty, referrals, affiliates, social, + purchase controls, support, security/audit, feature flags). Generates a clean initial + migration (`drizzle/0000_daffy_darkstar.sql`). +- **Pack tier config**: all 14 tiers (Spark–Genesis), integer USDC base units, server-side + network/value gating (`src/server/config/pack-tiers.ts`). **Only Spark and Starter are + currently unlocked/purchasable** (`TESTNET_CEILING`/`MAINNET_CEILING` both scoped to just + those two) — every other tier, including Scout through Mythic which have real art, is + locked until they get the same real video-driven rip-open treatment Spark/Starter + already have (see the "Real video-driven rip animation" entry below). This is a + deliberate temporary narrowing, not a bug — the server-side lock is the actual gate + (`isTierPurchasableOn()` + `offer-service.ts`), not just a frontend display state. +- **Rarity-band pool structure** (`src/server/packs/rarity-bands.ts`): a fixed six-rarity + odds table (Common 25% / Uncommon 24.8% / Rare 16.1% / Epic 16.1% / Legendary 14% / + Grail 4%, summing to exactly 100.00%) applied uniformly to all 14 tiers, with each + rarity's price band scaled proportionally to that tier's own price (e.g. a $5 pack's + Grail band is $9–$100). The tier's `procurementPriceCapUsdcBaseUnits` is now derived + directly from the Grail band's upper bound (20x price) rather than a separate schedule. + 8 unit tests, including an exact reproduction of the $5-tier example this was specified + against. `src/server/db/seed.ts` picks one representative fixture per rarity band from + the mock CardTrader ladder — a stand-in given the mock ladder's ~20 fixtures, not a claim + about real supplier inventory depth. Structurally inspired by a reference competitor + app's odds-breakdown UI, not its branding or its real-money cash-out mechanic (which + PackX402 does not have). A user-selectable "volatility level" that reshapes these odds + (also seen in that reference app) is **not implemented**. +- **User-scoped pack-opening history** (`GET /api/packs/openings`): returns only the + authenticated user's own rips (joined through `packOffers.userId`), never a cross-user + listing. Deliberately separate from `/api/fairness/verify`, which stays public-by-ripId + on purpose — that's what makes fairness independently verifiable by any third party, not + just the pack's owner (see docs/FAIRNESS_PROTOCOL.md). No UI page consumes this endpoint + yet. +- **Fairness engine** (`src/server/fairness/engine.ts`): deterministic server-seed-commit + → reveal → sha256-combine → weighted selection algorithm. 12 unit tests including a + **fixed, hand-computed test vector** (published in `docs/FAIRNESS_PROTOCOL.md`) so a + third party can independently reproduce it in any language. +- **Field-level encryption** (AES-256-GCM) for sensitive columns, round-trip and + tamper-detection tested. +- **Responsible-purchasing limit evaluation**: self-exclusion, cool-off, pause, daily/ + weekly/monthly limits, and the "decrease is immediate / increase is delayed" rule — + pure logic, 15 unit tests. +- **Supplier eligibility rules** (spec section 38): pure rule evaluator, 8 unit tests. +- **CardTrader mock provider**: full `SupplierAdapter` interface implementation over + deterministic fixtures — idempotent purchase (same idempotency key never double-charges), + cart-safety abort on unexpected non-empty cart, 6 integration-style tests. +- **Loyalty calculation**: level determination, beta reward cap at Silver, eligible-spend + arithmetic — 7 unit tests. +- **ISO week key** for the weekly free-pack one-claim-per-week rule — 5 unit tests, + verified against a reference algorithm. +- **Eligibility/age-gate policy**: age calculation and full denial-reason evaluation — 10 + unit tests. +- **Wallet signature verification**: real per-chain crypto for Algorand (algosdk), + Solana (tweetnacl), and EVM (viem) — **each verified against an actual generated + keypair signing and verifying a real message**, including negative tests (wrong signer, + tampered message). 8 tests. +- **Wallet signature message format**: SIWE-style canonical message with domain/URI/nonce/ + chain/purpose/issued/expiration, round-trip tested. +- **Production build**: `npm run build` succeeds (Next.js 16, Turbopack, strict + TypeScript). All pages and API routes compile and are correctly typed against the live + Drizzle schema. +- **Account creation/login model**: PackX402 has exactly two ways into an account — + **Google sign-in** (Auth.js/NextAuth v5, `src/server/auth/google-oauth.ts`, mounted at + `/api/oauth/[...nextauth]`) and **direct wallet signature** + (`completeWalletAuth` in `src/server/auth/auth-service.ts`, unchanged). The previous + passwordless-email signup/login has been **removed entirely** — the routes + (`/api/auth/signup`, `/api/auth/login/*`, `/api/auth/verify-email`) and their + auth-service functions no longer exist. See `docs/GOOGLE_OAUTH_SETUP.md` for the exact + Google Cloud Console steps and required env vars (`GOOGLE_CLIENT_ID`/ + `GOOGLE_CLIENT_SECRET`) — no real Google credentials were available in this + environment, so the OAuth handshake is implemented against Auth.js's documented Google + provider but unverified against a real Google account. +- **Eligibility gate enforced at the point of purchase**: neither Google nor wallet + sign-in collects DOB/location at account-creation time (Google's basic profile scope + has no birthdate; wallet-first never has). Previously this meant NO eligibility check + happened anywhere for wallet accounts — a real gap. `createPackOffer()` in + `offer-service.ts` now rejects any offer for a user with no passing eligibility record + (`error: "eligibility_required"`), and `POST /api/auth/oauth/complete-eligibility` + (tested indirectly via evaluateEligibility's existing 10 unit tests) is what a client + calls to satisfy it. There is still no UI page for this — see next steps. +- **Bonus-flip mechanic** (`deriveBonusFlipHit`/`selectBonusPoolEntry` in + `src/server/fairness/engine.ts`, wired into `settleOfferAndOpen`): a fixed 4% chance, + evaluated server-side on every completed pack opening from the same committed fairness + seed as the primary pull (never client-side randomness), of awarding a second real card + from the same pool. Real money/EV impact — see `docs/LEGAL_REVIEW_REQUIRED.md`'s note + and `docs/FAIRNESS_PROTOCOL.md`'s bonus-flip addendum. **Now disclosed** on the + pack-detail page (a "Bonus flip: 4% chance of a second card" line) — still needs + counsel sign-off per the updated legal-review note, disclosure isn't the same as + clearance. Tested (4 new unit tests including a ~4% + distribution check over 5000 trials); the DB schema change (`rips.kind` + + `(packOfferId, kind)` composite unique index replacing the old single-column uniques) is + captured in `drizzle/0002_sturdy_synch.sql`, unverified against a live Postgres. +- **Pack shelf** (`PackShelf.tsx`, replacing the old infinite carousel on the opening + page): uniform-size packs in a horizontally scrollable row, sorted ascending by price + left to right, each with its own price/pay button feeding into `PaymentMethodPanel` + (wallet — the one real, functional method — plus Apple Pay/PayPal shown realistically + but disabled, no merchant credentials configured for either). + +## What's implemented but unverified against live infrastructure (🟡) + +- **x402 Algorand payment endpoint** (`/api/x402/algorand/v1/packs/open`): returns HTTP + 402 with PaymentRequirements when unpaid; verifies/settles via direct algod + verification in live mode, or a deterministic mock in `X402_FACILITATOR_MODE=mock` + (the default, and the only mode exercised so far). **Not tested against a real GoPlausible + facilitator** — no credentials were available. The `ChainPaymentAdapter` interface is + shaped so swapping in a real facilitator call is a single-file change; see + `docs/DECISIONS.md`. +- **Solana / EVM x402 adapters**: same mock-default pattern, live-mode paths use direct + RPC verification (web3.js / viem) but are unverified against real DevNet/TestNet + transactions. +- **CardTrader live provider**: implemented against CardTrader's documented v2 API shape + (`GET /marketplace/products`, `/cart`, `/cart/add`, `/cart/purchase`, etc., + `via_cardtrader_zero=false`), but **has not been run against a real CardTrader account** + — no API token was available. Do not enable `CARDTRADER_MODE=live` without a + verification pass against current CardTrader docs and a sandbox account. +- **Pack offer orchestration** (`src/server/packs/offer-service.ts`): the full + create-offer → verify/settle payment → run fairness selection → create Rip + + FairnessProof → queue supplier purchase flow. Type-checks cleanly against the live + Drizzle schema (a strong signal — Drizzle's generated types catch field/type mismatches + at compile time) but **has not been run end-to-end against a live Postgres database** in + this environment. +- **Responsible-purchasing rolling-spend aggregation**: `getRollingSpend()` in + `offer-service.ts` now sums real settled payments (`payments.status = "settled"`, joined + through `pack_offers.userId`) over rolling 24h/7d/30d windows — replacing the previous + `passes zeros` stub — and feeds directly into `evaluatePurchaseAgainstLimits()`. Uses + rolling windows, not calendar day/week/month, since no per-user timezone exists in the + schema. Type-checks cleanly; unverified against a live DB like everything else here. +- **Supplier purchase queue worker**: `src/server/suppliers/purchase-worker.ts` now + consumes the `supplier_purchases` queue (spec sections 39-40) — a real long-running + process (`npm run worker:supplier-purchases`, not a request handler), with a row-level + claim (conditional `UPDATE ... WHERE status = 'queued'`) so concurrent workers can't + double-process one row. Runs validate → add-to-cart → confirm → purchase against + `getCardTraderProvider()`, records a `fulfillments` row and `pack_offers.status` update + on success. **Documented gaps, not silently solved**: no cross-*process* lock for + multiple workers against the same supplier account (needs Redis, not built — no live + infra available); no substitution-search-on-unavailable per spec section 40 (no + configured price-increase tolerance exists to drive it — marks the row `failed` instead + of rerolling, which would violate the fairness proof); fails clearly with + `no_shipping_address_on_file` since there's no UI yet to add one. See + `docs/SUPPLIER_INTEGRATION.md`. +- **Migrations**: `drizzle-kit generate` produces a valid migration and Drizzle's schema + graph validates cleanly, but the migration has not been _applied_ to a real Postgres + instance in this environment (no Docker available). Run `docker compose up -d && npm +run db:migrate && npm run db:seed` to verify. + +## What's scaffolded (schema only) or not started (⬜) + +- **UI pages built**: landing page (now with an interactive, no-DB, no-wallet demo of the + full carousel-select → rip → spin → reveal → bonus-flip sequence via the shared + `InteractivePackDemo` component, plus a plain-language 4-step walkthrough of the + commit-reveal fairness algorithm — verified live in-browser that pack selection and rip + progression both work), pack marketplace, pack detail (now with per-card reference + values and a "max obtainable card value" summary), provably-fair verifier (with a real + working `/api/fairness/verify` endpoint), odds library + JSON download, and an + opening-theater page at `/packs/[tierKey]/open` (see below). The same `InteractivePackDemo` + also powers the dev-only `/dev/rip-preview` page (useful when Postgres isn't running). +- **Bonus-flip mechanic verified correct**: confirmed the coin-flip visual (`CoinFlip.tsx`) + never rolls its own odds — it only animates the server's already-determined 4% result + (`deriveBonusFlipHit` in `engine.ts`). The only place a different rate shows is the + landing-page demo, which intentionally uses ~40% (clearly commented as demo-only) so the + flourish is visible while clicking around instead of a 1-in-25 real rate. +- **Pack art fully redesigned, standardized across all 10 unlocked tiers**: replaced the + earlier per-tier wordmark art with one consistent template — a glowing "P+X" vault-arc + emblem inside a corner-bracket frame, the tier name in a bottom pill badge, no "PackX402" + wordmark on the face at all. Only the material/color and a tier-specific background + motif differ (Spark: cyan lightning, Starter: copper sunburst, Scout: teal radar lines, + Bronze: bronze art-deco fan, Silver: diamond facets, Gold: gold sunburst, Prism: + holographic rays, Platinum: ice facets, Obsidian: cracked emerald glass, Mythic: cosmic + nebula). Iterated through several rounds of real user feedback (corner tick-marks + removed, font mispositioning issues abandoned in favor of no baked-in text at all, + emblem recentered to fill the face). See `docs/HIGGSFIELD_PROMPTS.md` for the exact + prompts. +- **Real video-driven rip animation (7 of 10 unlocked tiers)**: replaced the CSS clip-path + rip illusion with an actual Higgsfield-generated video of the pack tearing open + (`public/video/open/{tier}.mp4` for Spark, Starter, Scout, Bronze, Silver, Gold, Prism, + via `kling3_0` image-to-image interpolation between a closed-pack still and a torn-open + still). `RipToOpenVideo.tsx` maps the user's drag progress directly to + `video.currentTime` — dragging scrubs through the real tear frame-by-frame; releasing + past the commit threshold plays the video through to the end before firing + `onRipped()`; releasing short of it scrubs back to frame 0. Wired into both the real + opening theater (`OpenPackClient.tsx`) and the no-DB demo (`InteractivePackDemo.tsx`) + via a shared `RIP_VIDEO_BY_TIER` map (`src/components/pack-art/rip-video-map.ts`) — any + tier not in that map falls back to the older `RipToOpen`/`PackArt` clip-path treatment, + so this is a drop-in tier-by-tier rollout, not an all-or-nothing swap. Verified live for + Spark: dragged the real page, watched the video's network request succeed and play + through to the reveal wheel; Scout and Starter's tear position/style were confirmed + correct by direct user review of the generated stills. **Platinum, Obsidian, and Mythic + do not have a video yet** — generation stopped mid-batch when the Higgsfield workspace + ran out of credits; same pipeline (closed + torn stills → kling3_0 + interpolation) needed per tier once art is finalized. +- **Mock CardTrader fixture ladder expanded to 200 real cards, plus a hard price-cap + safety fix**: `src/server/suppliers/cardtrader/fixtures.ts` now has 180 real Pokemon + cards (sampled from 799 candidates across 9 real sets — Base Set, Jungle, Fossil, Team + Rocket, Gym Heroes, Gym Challenge, Neo Genesis, Neo Discovery, Neo Revelation, via + api.pokemontcg.io) plus 20 real Yu-Gi-Oh cards (via db.ygoprodeck.com), each carrying + that card's real tcgplayer market price at fetch time (2026-08-02 snapshot), spanning + $0.05-$1,300, deliberately biased toward the cheap end so Spark/Starter's rarity bands + have real in-band matches. CardTrader itself is still mock mode (see AGENTS.md) — only + the card identity/pricing is real. **`pickFixtureForBand` (rarity-bands.ts) now takes a + hard `absoluteMaxUsdcBaseUnits` cap**, applied before both the in-band search and the + closest-match fallback — previously the fallback had no upper bound at all, so a cheap + tier with no in-band candidate could theoretically land a wildly expensive fixture (a + real gap, not hypothetical: closest-match-only logic has no ceiling by construction). + `db:seed` now passes each tier's own `procurementPriceCapUsdcBaseUnits` (the same + 20x-price ceiling used elsewhere) as that cap. Verified by simulation against the live + fixture ladder: Spark's ($0.50) actual max obtainable came out to $5.47, Starter's ($1) + to $10.90 — both well under their $10/$20 caps, and every rarity band resolved to a real + in-band fixture rather than a fallback pick. Two new regression tests in + `rarity-bands.test.ts` lock in the cap behavior. `db:seed`'s pool-version label bumped + to `2026-08-02.3`. +- **Opening theater** (`/packs/[tierKey]/open`): real page, not a mock. Pack shelf → + drag-to-rip gesture (`RipToOpen`) → calls the real `/api/x402/algorand/v1/packs/open` + endpoint to create a pack offer → **Pera Wallet is now really wired up** + (`@txnlab/use-wallet-react` + `@perawallet/connect`, both real installed packages, no + stubs — `WalletManagerProvider`, `ConnectPeraButton`, `PayWithWalletButton`). Connecting + Pera, building a real Algorand ASA-transfer transaction from the server's own + `PaymentRequirements`, signing it via `transactionSigner`, submitting to algod, waiting + for confirmation, and re-POSTing with a real `X-PAYMENT` header are all implemented per + the documented adapter contract (`algorand-adapter.ts`'s `decodePaymentHeader`). What's + **not verified in this environment**: no funded TestNet Pera wallet was available here to + actually click through connect → sign → submit → settle end to end, and the DB-backed + page itself needs Postgres to load at all (see next steps) — this is the same + "implemented against the documented contract, unverified live" status as CardTrader/x402 + live mode elsewhere in this repo. `settleOfferAndOpen` resolves the actual card, resolves + its image via `src/server/card-images/resolver.ts`, and the reveal wheel + (`CardRevealWheel`) spins down to and flips over that real card. A real 4% bonus-flip + mechanic (not the cosmetic version originally shipped — see the "Bonus-flip mechanic" + entry above) can award a second card alongside the primary pull, animated via + `CoinFlip.tsx` once the server has already determined the real outcome. +- **Personal opening history** (`/collection` page + `GET /api/packs/openings`): now + built — a simple table of the signed-in user's own past pulls (card, set, tier, value, + date, a link into the fairness verifier). Not the fuller "personal collection" experience + described in the original spec (no shipping status, no showcase/social integration). +- **Header sign-in** (`layout.tsx`): now real — "Sign in with Google" / "Log out" replace + the previous dead `/login`/`/signup` links (those pages never existed). Auth state is + checked client-side via `/api/auth/session` after mount rather than in the root layout + via `cookies()`, specifically so the rest of the site keeps static generation (checking + cookies() in the layout previously forced every single page to render dynamically). +- **Eligibility completion page** (`/eligibility`): now built — collects DOB, country + (+US state), and the 18+ acknowledgment via `POST /api/auth/oauth/complete-eligibility` + (now CSRF-protected, matching its sibling mutating routes). `OpenPackClient` links here + automatically when pack-offer creation fails with `eligibility_required`. This is a UX + convenience only — `createPackOffer()` remains the real, unbypassable server-side gate. +- **Shipping-address management** (`/shipping-addresses` + `GET/POST /api/shipping- + addresses`, `DELETE /api/shipping-addresses/:id`, `POST /api/shipping-addresses/:id/ + default`): add/list/remove/set-default, every free-text field encrypted at rest + (existing `encryptField`/`decryptField`, same as elsewhere in the schema), first address + added is auto-default, deleting the default promotes the next-oldest one so there's + always a clear default whenever at least one address exists. This is what the + supplier-purchase worker needs on file before it can complete a real purchase — linked + to from `/collection`. CSRF-protected like every other mutating route. +- **Account / wallet-center page** (`/account`, linked from the header once signed in): + connected wallets (read-only, `GET /api/auth/wallet/list`), active sessions with a + "sign out everywhere" control (`SessionsManager`, wired to the existing + `GET /api/auth/sessions` / `POST /api/auth/sessions/revoke-all`), and links to the other + account-scoped pages. **Not built**: linking an *additional* wallet to an + already-authenticated account (needs a nonce-request → sign-arbitrary-message → verify + flow with `isLinking: true` — the API supports it, but no UI calls it that way yet; + today a wallet only gets linked as this account's payment method the first time a + wallet-first user signs in through the opening flow). +- **UI pages NOT built**: Defly/Phantom for Solana+EVM (Pera/Algorand only for now), + order tracking UI, weekly-free-pack claim UI, loyalty dashboard, referral + dashboard, affiliate program UI, social profiles/feed/showcases/clubs/challenges, + notifications center, security center, support/dispute UI, and the entire admin + dashboard. The data model for all of these exists; the API routes and UI do not. +- **Free-pack claim / loyalty recalculation jobs**: pure calculation logic exists and is + tested; there is no scheduled job or API route that actually grants/claims a weekly pack + or recalculates a user's loyalty level. +- **Referral & affiliate business logic**: only the data model exists. No code. +- **Social moderation**: only the data model exists. No code. +- **Admin dashboard**: only the data model (`admin_users`, `audit_events`, etc.) exists. + No RBAC enforcement code or UI. +- **CardImageResolver** (`src/server/card-images/resolver.ts`): implements the documented + priority chain (supplier photo → PSA graded scan → CardTrader catalog → public catalog + APIs → PackX402 fallback) with a domain-allowlist/SSRF guard on any resolved URL. + Providers 1–3 (CardTrader photo, PSA cert lookup, CardTrader catalog) still return + `null` — no credential available for any of them. **Provider 4 makes real live calls** + to the public, keyless Pokémon TCG API (pokemontcg.io) and YGOPRODeck API + (ygoprodeck.com) — confirmed working from this environment — and resolves an actual + CATALOG_RENDER card image by name for both games. Only falls back to the generic + card-back PLACEHOLDER when a card name has no match, the API is unreachable, or the + returned URL isn't on the allowlist. Tested with mocked `fetch` for determinism + (`resolver.test.ts`); the live network path itself was manually verified against both + real APIs but is not covered by an automated live-network test. +- **Pack artwork**: real Higgsfield-generated cartoon-style art (cel-shaded, "PackX402" + wordmark, transparent-background cutouts) installed for all 10 unlocked tiers at + `public/packs/*.png` — see `docs/HIGGSFIELD_PROMPTS.md`. Crown/Vault/Grail/Genesis + remain CSS placeholders (locked tiers). +- **Max-obtainable-value cap**: `src/server/config/pack-tiers.ts`'s + `procurementPriceCapUsdcBaseUnits` now follows a tapering per-tier multiplier + (`MAX_OBTAINABLE_VALUE_MULTIPLIER`) instead of a flat 1.15x — e.g. Spark ($0.50) caps at + $25 (50x), Genesis ($10,000) caps at $20,000 (2x). This is a deliberate EV/odds design + choice; see `docs/LEGAL_REVIEW_REQUIRED.md` for the responsible-purchasing disclosure + implications before this ships beyond beta. +- **Playwright e2e tests**: not written. Only Vitest unit/integration tests exist. +- **GitHub Actions CI / issue templates / CODEOWNERS**: see `docs/` and `.github/` — being + added in this same pass; check their presence directly rather than trusting this + sentence after further commits. + +## Immediate next steps (in priority order) + +1. Install Docker Desktop (this dev machine doesn't have it) and run + `docker compose up -d && npm run db:migrate && npm run db:seed`, then `npm run dev` — + this is the single blocker keeping the pack-detail and opening-theater pages from + rendering at all right now (they 404 without a live Postgres). A no-DB animation + preview exists at `/dev/rip-preview` in the meantime (see below). +2. Create a real Google OAuth client (see `docs/GOOGLE_OAUTH_SETUP.md`) and set + `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` in `.env.local` to actually test Google + sign-in end to end. +3. ~~Build a UI page for `POST /api/auth/oauth/complete-eligibility`~~ — done, see + `/eligibility` above. +4. Pera Wallet (Algorand) is now wired up for real — get a funded TestNet account into + Pera and click through connect → sign → submit → settle end to end for the first time; + nothing in this environment could exercise that live. Defly and Phantom (Solana/EVM) + still need their own connect flows built the same way. +5. ~~Build the supplier-purchase worker process~~ — done, see + `src/server/suppliers/purchase-worker.ts` above. Still needs: a cross-process lock if + ever running more than one worker instance against the same supplier account, and the + spec-section-40 substitution search (no configured price tolerance exists yet). +6. ~~Wire real rolling-spend aggregation into `offer-service.ts`'s limit check~~ — done, + see `getRollingSpend()` above. +7. Wire a live CardTrader photo/catalog credential (or PSA/public-catalog credential) into + `src/server/card-images/resolver.ts`'s provider stubs so real card images resolve + instead of always falling back to the placeholder. +8. Obtain CardTrader and GoPlausible sandbox credentials and run an actual integration + test pass before ever setting `CARDTRADER_MODE=live` or + `X402_FACILITATOR_MODE=live` outside of TestNet dry runs. +9. ~~Build a shipping-address UI~~ — done, see `/shipping-addresses` above. + +## Beta restrictions verified present in code + +- No custodial wallets, no private-key/seed storage anywhere in the schema or code + (`WalletIdentity` stores only public addresses). +- High-value tiers (`requiresHighValueReleaseGate`) are gated by a **server-side** feature + flag (`FEATURE_HIGH_VALUE_PACKS_ENABLED`), checked in `isTierPurchasableOn()` and + enforced in `offer-service.ts` — a client can request any tier key and will still be + rejected server-side. +- `Math.random` is never used for card selection — `selectPoolEntry()` uses only sha256 + over committed/revealed/on-chain values. diff --git a/README.md b/README.md index e215bc4..4f23ba2 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,81 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# PackX402 -## Getting Started +A provably fair, supplier-backed trading-card pack platform powered by Algorand x402. -First, run the development server: +> **Beta status.** See [PROJECT_STATUS.md](PROJECT_STATUS.md) for exactly what is +> implemented, tested, or still scaffolding. Do not deploy to production without reading +> it and [docs/LEGAL_REVIEW_REQUIRED.md](docs/LEGAL_REVIEW_REQUIRED.md). + +PackX402 lets users purchase digital pack-opening experiences and receive authentic +physical trading cards, supplied and shipped by approved third-party card marketplaces. +PackX402 never warehouses cards during beta — every card ships directly from the supplier +to the customer. Every opening is deterministically, publicly verifiable. + +## Quick start ```bash +npm install +docker compose up -d # Postgres + Redis +npm run db:migrate +npm run db:seed # seeds pack tiers + a mock CardTrader catalog + sample pools npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Copy `.env.example` to `.env.local` and fill in real values before running anything beyond +local dev — the checked-in defaults are safe local-only placeholders (mock payment +facilitator, mock supplier, TestNet only). + +## Commands -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| Command | Purpose | +| --------------------- | ------------------------------------------------------ | +| `npm run dev` | Start the Next.js dev server | +| `npm run build` | Production build | +| `npm run lint` | ESLint | +| `npm run typecheck` | `tsc --noEmit` | +| `npm run test` | Vitest unit/integration tests | +| `npm run test:e2e` | Playwright end-to-end tests | +| `npm run db:generate` | Generate a Drizzle migration from schema changes | +| `npm run db:migrate` | Apply migrations | +| `npm run db:seed` | Seed pack tiers, mock supplier inventory, sample pools | +| `npm run db:studio` | Drizzle Studio DB browser | -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Stack -## Learn More +Next.js 16 (App Router, Turbopack) · React 19 · strict TypeScript · Tailwind CSS 4 · +Drizzle ORM · PostgreSQL · Redis (rate limiting) · Zod · Vitest · Playwright · Docker +Compose · algosdk / `@txnlab/use-wallet` (Algorand) · `@solana/web3.js` / `@phantom/react-sdk` +(Solana + EVM) · viem · `@x402/core` + `@x402/avm` + `x402`/`x402-next`. -To learn more about Next.js, take a look at the following resources: +## Documentation -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [ARCHITECTURE.md](docs/ARCHITECTURE.md) — system design and module layout +- [SECURITY.md](SECURITY.md) — security posture, reporting a vulnerability +- [THREAT_MODEL.md](docs/THREAT_MODEL.md) +- [FAIRNESS_PROTOCOL.md](docs/FAIRNESS_PROTOCOL.md) — the provably-fair algorithm + test vectors +- [SUPPLIER_INTEGRATION.md](docs/SUPPLIER_INTEGRATION.md) — CardTrader adapter, mock vs. live +- [RESPONSIBLE_PURCHASING.md](docs/RESPONSIBLE_PURCHASING.md) +- [LOYALTY_AND_FREE_PACKS.md](docs/LOYALTY_AND_FREE_PACKS.md) +- [AFFILIATE_PROGRAM.md](docs/AFFILIATE_PROGRAM.md) +- [SOCIAL_MODERATION.md](docs/SOCIAL_MODERATION.md) +- [PRIVACY_DATA_MAP.md](docs/PRIVACY_DATA_MAP.md) +- [DEPLOYMENT.md](docs/DEPLOYMENT.md) +- [LEGAL_REVIEW_REQUIRED.md](docs/LEGAL_REVIEW_REQUIRED.md) +- [ROADMAP.md](docs/ROADMAP.md) · [DECISIONS.md](docs/DECISIONS.md) · [RISKS.md](docs/RISKS.md) +- [COMPETITION_CHECKLIST.md](docs/COMPETITION_CHECKLIST.md) +- [INCIDENT_RESPONSE.md](docs/INCIDENT_RESPONSE.md) +- [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — how an AI coding session should start here -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## Beta restrictions (non-negotiable — see PROJECT_STATUS.md for enforcement) -## Deploy on Vercel +No custodial wallets, no seed/private-key storage, no cash withdrawals, no internal +withdrawable currency, no P2P card marketplace, no user-to-user crypto transfers, no +spending/loss leaderboards, no "almost won" or loss-recovery messaging, no autoplay or +one-click repeat purchases, no direct messaging in this beta. High-value packs +(> $250, Crown and up) are disabled server-side — the beta bankroll doesn't yet cover +funding supplier purchases above that price point, pending legal/financial/security +review before it's lifted. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## License -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7ad98cd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +## Reporting a vulnerability + +Please report suspected security vulnerabilities privately — **do not open a public GitHub +issue**. Email the security contact listed on the repository (see GitHub repo "About" +section) or use GitHub's private vulnerability reporting (Security tab → "Report a +vulnerability"). Include reproduction steps, affected endpoint/component, and impact. + +We aim to acknowledge reports within 3 business days. This is a beta project without a +formal bug-bounty program at this time. + +## Scope + +In scope: the application in this repository (`src/`), its API routes, its database +schema, and its supplier/payment adapters. Out of scope: third-party services it +integrates with (Algorand network, CardTrader, GoPlausible, Phantom, wallet apps) — report +those to their respective maintainers. + +## What PackX402 staff will never do + +PackX402 staff will **never** ask for your wallet seed phrase, private key, or password via +email, chat, or support ticket. Any such request is a phishing attempt. + +## Security posture summary + +See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for the full threat model. Highlights: + +- No custodial wallets or private-key/seed storage anywhere in the system. +- All monetary values are integer USDC base units — no floating-point money math. +- Card selection is a committed-then-revealed sha256 process; `Math.random` is never used + for a paid or promotional physical-card result. +- Sensitive fields (shipping addresses, the fairness server seed pre-reveal) are encrypted + at rest with AES-256-GCM (`src/server/crypto/field-encryption.ts`). +- Session tokens are opaque and stored only as a sha256 hash — a DB leak does not yield + usable session tokens. +- Wallet login uses a SIWE-style signed message with domain/URI/nonce/chain/purpose/ + issued/expiration, verified with real per-chain cryptography (algosdk / tweetnacl / + viem), not a bearer-token handoff. +- CSP, HSTS, and other secure headers are set globally in `src/proxy.ts`. +- Payment settlement is idempotent on the on-chain payment identifier; supplier purchases + are idempotent on a database-unique idempotency key. +- High-value pack tiers are gated **server-side** by a feature flag, never trusting a + client-supplied value. + +## Dependency and secret hygiene + +- Renovate/Dependabot configuration lives in `.github/`. +- `.gitignore` excludes `.env*` (except `.env.example`), key/credential file patterns, + database exports, and logs. Run a secret scan before every push (see + `.github/workflows/ci.yml`). +- `src/server/env.ts` validates all required environment variables with Zod at process + start and refuses to boot with an invalid or missing configuration. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..db5e726 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +name: packx402 + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: packx402 + POSTGRES_PASSWORD: packx402 + POSTGRES_DB: packx402 + ports: + - "5432:5432" + volumes: + - packx402_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U packx402 -d packx402"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + packx402_postgres_data: diff --git a/docs/AFFILIATE_PROGRAM.md b/docs/AFFILIATE_PROGRAM.md new file mode 100644 index 0000000..2ea0700 --- /dev/null +++ b/docs/AFFILIATE_PROGRAM.md @@ -0,0 +1,29 @@ +# Affiliate Program + +**Status: data model only.** `affiliate_applications`, `affiliate_accounts`, +`affiliate_campaigns`, `affiliate_commissions`, `affiliate_payouts` tables exist in the +schema (`src/server/db/schema/affiliate.ts`) with all the fields the spec requires +(attribution window, monthly caps, pending-through-fulfillment-window commissions, manual +payout approval). No application code, API routes, or UI exist yet. + +## Design constraints already encoded in the schema + +- `affiliateCommissions.pendingUntil` — commissions hold through the fulfillment and + refund window before becoming payable. +- `affiliateCommissions.commissionUsdcBaseUnits` is a flat value computed from PackX402's + margin, structurally separate from `packOffers`/`payments` — there is no schema + relationship that could let a commission alter a customer's odds or awarded card. + Affiliate commissions must never depend on customer losses (spec section 23) and, since + PackX402 has no loss/win framing at all (spec section 30), there is no "loss" value for a + commission to depend on even hypothetically. +- `affiliateAccounts.monthlyCommissionCapUsdcBaseUnits` and + `affiliateCampaigns.monthlyCapUsdcBaseUnits` — caps at both account and campaign level. +- `affiliatePayouts.approvedByAdminId` is `NOT NULL` — a payout row cannot exist without a + named manual approver. +- `affiliateAccounts.disclosureText` — every account carries required disclosure copy. + +## Not yet implemented + +Application form + review flow, self-referral/duplicate-wallet/velocity fraud detection, +conversion attribution logic, the affiliate dashboard, creative asset library, and +disclosure-label rendering on shared pull posts. See PROJECT_STATUS.md. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..72e48bb --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,71 @@ +# Architecture + +## Layers + +``` +src/ + app/ Next.js App Router — pages + API route handlers + api/ Route handlers (server-only, never bundled to the client) + server/ Server-only business logic, imported by app/ code + auth/ Sessions, tokens, wallet-signature message + verification + config/ Static config (pack tiers) + crypto/ Field-level encryption + db/ Drizzle schema, client, migration + seed scripts + eligibility/ Age-gate / blocked-location policy + fairness/ The provably-fair selection algorithm (docs/FAIRNESS_PROTOCOL.md) + free-packs/ ISO week-key helpers for the weekly claim rule + loyalty/ Loyalty level calculation + packs/ Pack-offer orchestration (offer creation, payment settlement) + payments/x402/ Per-chain x402 payment adapters (Algorand/Solana/EVM) + responsible-purchasing/ Purchase-limit evaluation + security/ CSRF + rate limiting + suppliers/ Provider-neutral SupplierAdapter + CardTrader implementation + shared/ Pure helpers usable by both server and (future) client code + proxy.ts Global security headers + CSRF cookie (Next.js "proxy"/middleware) +``` + +## Design principles + +1. **Server-side is the only source of truth.** Tier availability, pricing, purchase + limits, and fairness selection are never trusted from the client — every check is + re-run server-side against DB/config state on every request. +2. **Mock-by-default external integrations.** Every third-party integration (x402 + facilitator, CardTrader) has a mock mode that is the default and the only mode + exercised by automated tests. Live mode is opt-in via environment variables and is + explicitly flagged as unverified in PROJECT_STATUS.md until tested against real + credentials. +3. **Pure functions for anything safety-critical.** Fairness selection, purchase-limit + evaluation, eligibility, loyalty calculation, and supplier-listing eligibility are all + implemented as pure functions with no DB/network dependency, so they can be + unit-tested directly and reasoned about in isolation. +4. **Idempotency at the database layer**, not just in application logic: payments are + unique on their on-chain transaction identifier, supplier purchases are unique on a + derived idempotency key, free-pack grants are unique on `(userId, weekKey, reason)`. +5. **Append-only audit trail** for anything sensitive: `audit_events`, `security_events`, + `loyalty_calculations`, `tracking_events` are insert-only tables with no application + code path that updates or deletes a row. + +## Request flow: opening a pack (Algorand) + +1. Client `POST /api/x402/algorand/v1/packs/open` with `{ tierKey, network }` and a + session cookie. +2. `createPackOffer()` runs the full gate sequence (tier availability, self-exclusion, + purchase limits, active pool lookup), generates a server seed + commitment, and + persists an `OFFERED` `pack_offers` row. The route returns HTTP 402 with + `PaymentRequirements` and the `offerId`. +3. Client obtains payment (wallet flow, out of scope for this repo — see + `@txnlab/use-wallet` docs) and retries the same endpoint with `?offerId=...` and an + `X-PAYMENT` header. +4. `settleOfferAndOpen()` verifies the payment against the chain adapter, settles it, + fetches post-settlement chain randomness, runs `selectPoolEntry()`, creates the `Rip` + and `FairnessProof` rows, and queues a `SupplierPurchase`. +5. Response includes the card and the full fairness proof bundle. + +See [docs/FAIRNESS_PROTOCOL.md](FAIRNESS_PROTOCOL.md) for the selection algorithm and +[docs/SUPPLIER_INTEGRATION.md](SUPPLIER_INTEGRATION.md) for what happens after step 5. + +## Not yet built + +See [PROJECT_STATUS.md](../PROJECT_STATUS.md) for the authoritative list — most UI pages, +the supplier-purchase worker process, auth route handlers, and the admin dashboard are not +implemented yet. diff --git a/docs/ASSET_MANIFEST.md b/docs/ASSET_MANIFEST.md new file mode 100644 index 0000000..b0f2bc1 --- /dev/null +++ b/docs/ASSET_MANIFEST.md @@ -0,0 +1,125 @@ +# PACK402 Asset Manifest + +**Status: 10 of 14 tiers have real Higgsfield-generated art**, installed at +`public/packs/{tierKey}.png` — Spark, Starter, Scout, Bronze, Silver, Gold, Prism, +Platinum, Obsidian, Mythic. See `docs/HIGGSFIELD_PROMPTS.md` for exactly how each was +generated. Crown, Vault, Grail, and Genesis are still branded CSS/SVG development +placeholders rendered by `src/components/pack-art/*` (see `PackArt.tsx`'s fallback face) +— those four tiers are locked in the UI (bankroll gate, see +`docs/LEGAL_REVIEW_REQUIRED.md`), so this is not a user-facing gap today. The card back, +all marketing-variant shots, and every video/VFX asset below remain placeholders. This +document is also the reference for dropping in the remaining real assets with **zero code +changes** — every consuming component already resolves art from these locations first and +only falls back to CSS if the file is missing or fails to load. + +**Real video-driven rip animation: 1 of 14 tiers (Spark)** has an actual Higgsfield- +generated tear-open video at `public/video/open/spark.mp4`, plus a matching torn-open +still at `public/packs/spark-open.png` shown during the brief phase transition right after +the video finishes. `RipToOpenVideo.tsx` maps the user's drag position directly to +`video.currentTime` — this is a real interactive animation the user scrubs through, not a +CSS illusion. See `docs/HIGGSFIELD_PROMPTS.md` for the exact generation pipeline +(closed-still + torn-still → `kling3_0` image-to-video interpolation) and +`src/components/pack-art/rip-video-map.ts` for the tier→asset map. Any tier not in that +map (9 of 10 unlocked tiers, for now) falls back to the older CSS clip-path rip +(`RipToOpen.tsx` + `PackArt`'s `torn` prop, which requests `/packs/{tierKey}-torn.png` and +falls back silently to the closed-pack art if missing) — this is a drop-in, per-tier +rollout, not an all-or-nothing swap. + +## How the fallback system works + +- `PackArt` (`src/components/pack-art/PackArt.tsx`) requests `/packs/{tierKey}.png` via + `next/image`. On any load error (including "file doesn't exist," a 404), it renders a + tier-branded CSS face instead — see `tier-treatments.ts` for the per-tier colors. +- `CardBack` requests `/cards/pack402-card-back.png` with the same fallback pattern. +- `ResultEffect` accepts an optional `videoSrc`; when omitted it renders a CSS-only + placeholder effect layer. +- `OpeningStage` accepts optional `idleVideoSrc`/`openingVideoSrc` props for the same + reason. + +## Still images + +All packs share **locked 2:3 vertical proportions** as the canonical in-app format +(`aspect-[2/3]` throughout the UI). Additional formats below are for marketing/export use. + +| Asset | Path | Dimensions | Notes | +| ------------------------------------------------------- | ------------------------------------------------------ | --------------- | --------------------------------------------------------- | +| Brand crest + logo lockup (champagne-gold, transparent) | `/public/brand/pack402_brand_crest_v01.png` | 2048×2048 | Crest + "PACK402" wordmark + "Only the Best Packx" slogan | +| Logo lockup — ivory variant | `/public/brand/pack402_brand_crest_ivory_v01.png` | 2048×2048 | | +| Logo lockup — black monochrome | `/public/brand/pack402_brand_crest_mono_v01.png` | 2048×2048 | | +| Logo lockup — horizontal layout | `/public/brand/pack402_brand_crest_horizontal_v01.png` | 3072×1024 | | +| PACK402 card back (front) | `/public/cards/pack402_card_back_v01.png` | 1470×2058 (5:7) | Used by `CardBack.tsx` at `/cards/pack402-card-back.png` | +| Card back — three-quarter angle | `/public/cards/pack402_card_back_3q_v01.png` | 1470×2058 | | + +### Per-tier pack art (14 tiers × 6 shots each = 84 stills) + +For each `{tier}` in `spark, starter, scout, bronze, silver, gold, prism, platinum, +obsidian, mythic, crown, vault, grail, genesis`: + +| Shot | Path used by the app | Marketing filename | Dimensions | +| ---------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------- | ---------------- | +| Isolated front-facing (**primary — this is the one the app actually loads**) | `/public/packs/{tier}.png` | `pack402_pack_{tier}_front_v01` | 2048×3072 (2:3), **done for spark/starter/scout/bronze/silver/gold/prism/platinum/obsidian/mythic** | +| On black-marble pedestal | `/public/packs/marketing/{tier}_pedestal.png` | `pack402_pack_{tier}_pedestal_v01` | 2048×3072 | +| Three-quarter angle | `/public/packs/marketing/{tier}_3q.png` | `pack402_pack_{tier}_3q_v01` | 2048×3072 | +| Foil/engraving close-up | `/public/packs/marketing/{tier}_closeup.png` | `pack402_pack_{tier}_closeup_v01` | 2048×2048 | +| Mobile marketplace thumbnail | `/public/packs/marketing/{tier}_thumb.png` | `pack402_pack_{tier}_thumb_v01` | 512×768 | +| Website hero (wide) | `/public/packs/marketing/{tier}_hero.png` | `pack402_pack_{tier}_hero_v01` | 1920×1080 (16:9) | + +Only `/public/packs/{tier}.png` is wired into the running app today (`PackArt` +component). The marketing variants are documented for the eventual marketing/export +pipeline but have no consuming code yet. + +## Video / motion assets + +None of these are wired to any player yet beyond the optional `videoSrc`/`idleVideoSrc`/ +`openingVideoSrc` props on `PackArt`, `OpeningStage`, and `ResultEffect` — passing a path +activates it; omitting it (today's state) uses the CSS placeholder. + +| Asset | Suggested path | Filename | Duration | Dimensions | +| --------------------------------- | --------------------------------------- | --------------------------------- | ----------------- | ------------------------------------------ | +| Idle loop (per tier) | `/public/video/idle/{tier}.mp4` | `pack402_idle_{tier}_v01` | 3s, seamless loop | 1920×1080 | +| Single-pack opening (per tier) | `/public/video/open/{tier}.mp4` | `pack402_open_single_{tier}_v01` | 3s | 720×1280 (9:16), **done for spark** | +| Four-pack opening | `/public/video/open/four.mp4` | `pack402_open_four_v01` | 11s | 1920×1080 | +| Four-pack major-hit, top-left | `/public/video/open/four_major_tl.mp4` | `pack402_open_four_major_tl_v01` | 11s | 1920×1080 | +| Four-pack major-hit, top-right | `/public/video/open/four_major_tr.mp4` | `pack402_open_four_major_tr_v01` | 11s | 1920×1080 | +| Four-pack major-hit, bottom-left | `/public/video/open/four_major_bl.mp4` | `pack402_open_four_major_bl_v01` | 11s | 1920×1080 | +| Four-pack major-hit, bottom-right | `/public/video/open/four_major_br.mp4` | `pack402_open_four_major_br_v01` | 11s | 1920×1080 | +| Standard-hit VFX layer | `/public/video/vfx/standard.mp4` | `pack402_vfx_standard_v01` | 2s | 1920×1080, alpha/screen-blend if supported | +| Rare-hit VFX layer | `/public/video/vfx/rare.mp4` | `pack402_vfx_rare_v01` | 2.5s | 1920×1080 | +| Major-hit VFX layer | `/public/video/vfx/major.mp4` | `pack402_vfx_major_v01` | 3s | 1920×1080 | +| Genesis-hit VFX layer | `/public/video/vfx/genesis.mp4` | `pack402_vfx_genesis_v01` | 3.5s | 1920×1080 | +| Reduced-motion opening | `/public/video/open/reduced-motion.mp4` | `pack402_open_reduced_motion_v01` | <3s | 1920×1080 | +| Website hero background loop | `/public/video/hero-loop.mp4` | `pack402_website_hero_loop_v01` | 8s, seamless loop | 1920×1080 | +| Social pull-share clip | `/public/video/social/pull-share.mp4` | `pack402_social_pull_v01` | 6s | 1080×1920 (9:16) | + +`ResultEffect`'s `intensity` prop (`standard | rare | major | genesis`) maps 1:1 to the VFX +rows above. + +## File-naming convention (marketing/export pipeline) + +The user-specified naming system (`pack402_brand_crest_v01`, `pack402_pack_{tier}_front_v01`, +etc.) is preserved as the canonical export name. The app-facing paths in the tables above +are the actual paths the Next.js code reads from `/public`; rename on export from +whatever pipeline produces the final files, or add a build step that copies/renames from +the marketing naming convention into these paths. + +## Generation order (unchanged from creative brief) + +1. Brand crest + logo lockup +2. Master Obsidian pack (`obsidian.png`) — the structural/lighting reference for all other tiers +3. PACK402 card back +4. Remaining 13 tier stills, matched exactly to the Obsidian reference's proportions, seams, crest placement, and lighting +5. Idle loop (start with Obsidian, then per-tier) +6. Single-pack opening master (Obsidian), then per-tier +7. VFX layers: standard → rare → major → genesis +8. Four-pack opening + four major-hit positional variants +9. Reduced-motion opening +10. Website hero loop +11. Social sharing clip + +## Generation status + +Higgsfield image generation is working on the current "starter" plan at up to 2K +resolution (4K specifically requires a Plus-tier plan — 2K is what was used throughout). +Concurrency is capped at 4 simultaneous jobs on this plan; batch requests accordingly. +Remaining work: card back, Crown/Vault/Grail/Genesis tier art (once unlocked), all +marketing-variant shots, and all video/VFX assets. diff --git a/docs/COMPETITION_CHECKLIST.md b/docs/COMPETITION_CHECKLIST.md new file mode 100644 index 0000000..2290777 --- /dev/null +++ b/docs/COMPETITION_CHECKLIST.md @@ -0,0 +1,50 @@ +# Competition / x402 Checklist + +Tracking the spec's "Definition of Done" (section 53) items literally. + +- [x] The application runs from documented commands (`README.md` quick start). +- [x] Migrations create the database schema (`drizzle-kit generate` produces a valid + migration; **not yet applied to a live Postgres** in this environment — no Docker + available. Apply with `npm run db:migrate`.) +- [x] Fictional data can be seeded (`npm run db:seed`). +- [ ] Signup and login work — auth _logic_ is implemented and tested; the `/api/auth/*` + routes and UI are not yet built. +- [ ] Algorand wallet connection works — not yet built (wallet-signature _verification_ is + implemented and tested; the connect UI using `@txnlab/use-wallet-react` is not). +- [ ] Phantom exposes separate Solana and EVM identities — not yet built. +- [x] Pack marketplace works (`/packs`, `/packs/[tierKey]`). +- [ ] Opening animation works — not yet built. +- [x] x402 endpoint returns HTTP 402 (`/api/x402/algorand/v1/packs/open`, verified via + `npm run build` type-checking the full route; not yet exercised against a live + request in this environment). +- [ ] TestNet payment or documented payment harness creates one opening — the full + settlement→fairness→Rip pipeline is implemented (`offer-service.ts`) and type-checks + against the live schema, but has not been run end-to-end against a live database or + a real TestNet transaction. +- [x] Fairness proof is independently reproducible — yes, with a fixed test vector + (`docs/FAIRNESS_PROTOCOL.md`) and a public `/api/fairness/verify` endpoint + UI. +- [ ] CardTrader adapter works in configured mode — mock mode is implemented and tested; + live mode is implemented but unverified against a real account. +- [x] Supplier purchases are idempotent — tested in the mock provider (duplicate + idempotency key never double-charges). +- [x] High-value packs remain server-locked — `requiresHighValueReleaseGate` + + `FEATURE_HIGH_VALUE_PACKS_ENABLED`, checked server-side in `createPackOffer()`. +- [ ] Weekly free packs cannot be double-claimed — the DB unique constraint + (`free_pack_grants` unique on `userId, weekKey, reason`) exists and the ISO + week-key logic is tested, but no claim API route exists yet to exercise it. +- [ ] Loyalty levels are audited and capped — calculation + beta cap logic is implemented + and tested; no audited recalculation job/route exists yet. +- [ ] Referral and affiliate abuse controls work — schema only, no logic yet. +- [ ] Social sharing is opt-in — schema enforces it by construction (no auto-post trigger + exists), but no social feature is built yet to actually demonstrate this end to end. +- [ ] Blocking and reporting work — schema only, no logic yet. +- [x] Shipping addresses never appear publicly — no social/public-facing table or API + response includes a shipping-address field; addresses are also field-encrypted at + rest. +- [ ] Self-exclusion prevents paid and promotional openings — prevents _paid_ openings + (enforced in `createPackOffer`); promotional/free-pack enforcement not yet wired + since the free-pack claim flow doesn't exist yet. +- [x] All tests and the production build pass — 77 Vitest tests, `npm run build` succeeds, + `tsc --noEmit` is clean. +- [x] Remaining legal, credential, and production requirements are explicitly documented + (`PROJECT_STATUS.md`, `docs/LEGAL_REVIEW_REQUIRED.md`, `docs/RISKS.md`). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..e27a822 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,61 @@ +# Decisions + +Architecture-decision log, newest first. + +## 2026-08-01 — Live x402/CardTrader verification via direct chain/API calls, not through a facilitator SDK call graph we can't test + +**Context**: no GoPlausible facilitator credentials or CardTrader API token were available +in the build environment. + +**Decision**: implement live-mode payment verification via direct RPC calls to each +chain (algod for Algorand, web3.js for Solana, viem for EVM) rather than guessing at the +GoPlausible facilitator's exact HTTP contract. Implement the CardTrader live provider +against its documented v2 REST shape, clearly flagged as unverified. + +**Consequence**: the `ChainPaymentAdapter` and `SupplierAdapter` interfaces are the real +integration seam — swapping in a verified facilitator call or corrected CardTrader +request shape is a change scoped to `algorand-adapter.ts`/`solana-adapter.ts`/ +`evm-adapter.ts` and `live-provider.ts` respectively, not a schema or orchestration change. +Someone with real credentials must run an integration pass before `X402_FACILITATOR_MODE=live` +or `CARDTRADER_MODE=live` is ever used. + +## 2026-08-01 — `server-only` package import boundary + +**Context**: the `server-only` npm package only no-ops under Next.js's RSC +`"react-server"` module-resolution condition; under any other runner (Vitest, drizzle-kit, +`tsx`) it unconditionally throws. + +**Decision**: split environment loading into `src/server/env.core.ts` (unguarded, used by +CLI scripts: `drizzle.config.ts`, `migrate.ts`, `seed.ts`) and `src/server/env.ts` +(`server-only`-guarded, re-exports from `env.core.ts`, used by actual app runtime code). +For Vitest specifically, `server-only` is aliased to a no-op shim +(`vitest/shims/server-only.ts`) so app code that legitimately imports the guarded +`env.ts` can still be unit-tested directly, rather than forking a second unguarded copy of +every module that needs testing. + +## 2026-08-01 — Vitest default environment is `node`, not `jsdom` + +**Context**: jsdom replaces global typed-array constructors (`Uint8Array` etc.) in its +realm; `algosdk` and `tweetnacl` do `instanceof`-style checks against the _global_ +`Uint8Array`, which silently fail or throw when a Node `Buffer` (built against Node's +realm) crosses into jsdom's realm. This was caught by a real failing test, not discovered +by inspection — see the wallet-signature test suite. + +**Decision**: default `vitest.config.ts` to `environment: "node"`; future component tests +opt into jsdom per-file via a `// @vitest-environment jsdom` docblock. + +## 2026-08-01 — Money as integer USDC base units everywhere + +Per spec section 3. All price/limit/commission columns are `bigint` (mode `"number"`, +values fit safely under `Number.MAX_SAFE_INTEGER` even at the $10,000 Genesis tier in +6-decimal base units). `src/shared/money.ts` centralizes display formatting so no call +site does its own division/rounding. + +## 2026-08-01 — Opaque, hashed session tokens over JWTs + +**Context**: spec requires device/session listing and a "revoke all sessions" control. + +**Decision**: DB-backed opaque session tokens (sha256-hashed at rest), not stateless JWTs +— revocation is a single `UPDATE`, and a DB leak yields no usable tokens. Trade-off: +every request needs a DB round-trip to validate a session (acceptable for this +application's traffic profile; would need a cache layer at much higher scale). diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..4dbb824 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,66 @@ +# Deployment + +## Local development + +```bash +npm install +cp .env.example .env.local # then fill in real values +docker compose up -d +npm run db:migrate +npm run db:seed +npm run dev +``` + +## Environments + +| Env | `APP_ENV` | Notes | +| ---------- | ------------- | ----------------------------------------------------------------------- | +| Local | `development` | Docker Compose Postgres/Redis, mock facilitator, mock supplier | +| Staging | `staging` | Real Postgres/Redis, TestNet only, mock or sandbox facilitator/supplier | +| Production | `production` | Real Postgres/Redis, `ALGORAND_MAINNET_ENABLED` only after full review | + +## Required before any production deploy + +1. Provision Postgres (with connection pooling — e.g. PgBouncer or a managed pooled + connection string) and Redis. +2. Set every secret in `.env.example` to a real, environment-specific value — + `SESSION_SECRET` and `FIELD_ENCRYPTION_KEY` must be freshly generated per environment + (`openssl rand -base64 32` / `openssl rand -hex 32`), never reused from `.env.local`. +3. Run `npm run db:migrate` against the target database as part of the deploy pipeline, + before starting new application instances. +4. Confirm `FEATURE_HIGH_VALUE_PACKS_ENABLED=false` unless legal/financial/security review + is complete (see `docs/LEGAL_REVIEW_REQUIRED.md`). +5. Confirm `ALGORAND_NETWORK=testnet` unless MainNet has been explicitly approved; if + MainNet is enabled, `ALGORAND_MAINNET_ENABLED=true` must also be set — the app refuses + to boot otherwise (`src/server/env.ts`). +6. Confirm `X402_FACILITATOR_MODE=mock` and `CARDTRADER_MODE=mock` unless the + corresponding live integration has been verified against real credentials (see + PROJECT_STATUS.md — neither has been, as of this build). +7. Point `S3_*` env vars at a real object store for uploads before enabling any upload + feature (none exist yet in this codebase). + +## Build + +```bash +npm run build +npm run start +``` + +`npm run build` has been verified to succeed in this repository's build environment +(Next.js 16 + Turbopack, strict TypeScript, 11 routes). It has **not** been deployed to +any hosting platform in this session. + +## Database migrations in CI/CD + +`.github/workflows/ci.yml` validates that `drizzle-kit generate` produces no pending +schema changes (i.e. the checked-in migration matches the checked-in schema) — it does not +apply migrations to a live database as part of CI, since CI has no persistent database in +this repo's current workflow. Add a migration-apply step against a CI Postgres service +container as a follow-up if desired. + +## Rollback + +Standard blue/green or rolling deploy rollback applies at the application layer. Database +migrations in this repo are additive-only so far (new tables/columns); no destructive +migration has been generated. Before ever writing a destructive migration, add a reviewed +rollback migration alongside it. diff --git a/docs/FAIRNESS_PROTOCOL.md b/docs/FAIRNESS_PROTOCOL.md new file mode 100644 index 0000000..2334d4c --- /dev/null +++ b/docs/FAIRNESS_PROTOCOL.md @@ -0,0 +1,115 @@ +# Fairness Protocol + +Reference implementation: `src/server/fairness/engine.ts` (dependency-free besides +Node's built-in `crypto` sha256 — deliberately, so it's easy to reimplement in another +language). Automated tests: `src/server/fairness/engine.test.ts` (12 tests, all passing). + +## Algorithm + +**Phase 1 — before payment (offer creation):** + +1. Generate a random 32-byte server seed. +2. Commit to it publicly as `serverSeedCommitment = sha256(serverSeed)`, without revealing + the seed. +3. Record the pool version's `poolHash` (sha256 of the canonical serialized pool entries) + and `oddsHash` (sha256 of the published probability bands), the tier, price, network, + a client nonce, and an expiration. + +**Phase 2 — after payment settles:** + +1. Reveal `serverSeed`. +2. Build the message: + `serverSeed | clientNonce | paymentIdentifier | chainRandomnessInput | poolHash` + (pipe-separated, UTF-8). +3. `combinedSeedHash = sha256(message)`. +4. Take the leading 16 hex characters (64 bits) of `combinedSeedHash`, interpret as an + unsigned integer, and reduce modulo the pool's `totalWeight` → `selectionRoll`. +5. Sort pool entries by `id` ascending (a canonical order independent of insertion order + or array shuffling) and walk them, accumulating `weight`, until the running total + exceeds `selectionRoll`. That entry is the result. + +`paymentIdentifier` is the settled on-chain transaction/payment id (only known after +payment). `chainRandomnessInput` is derived from post-settlement chain state — for +Algorand, the confirming block's sortition seed; for Solana, the transaction's recent +blockhash; for EVM, the confirming block's hash. No party, including PackX402, can predict +or choose any of these before the server seed commitment is published, so no party can +select a favorable outcome after the fact. + +`Math.random` (or any non-cryptographic, non-committed source) is never used for a paid or +promotional physical-card result. + +## Why modulo bias is not a practical concern here + +Reducing a 64-bit value modulo a pool's total weight (packs have at most a few thousand +possible outcomes) introduces a bias on the order of `totalWeight / 2^64`, which is +astronomically smaller than any measurable statistical effect. This is a standard, +widely-used trade-off in provably-fair systems; a full rejection-sampling implementation +was not judged worth the added complexity for this use case. + +## Fixed test vector + +This exact vector is asserted in `engine.test.ts` — anyone can reimplement `sha256` and +this walk in any language and reproduce it exactly. + +``` +serverSeed = 1111111111111111111111111111111111111111111111111111111111111111 (64 hex chars) +clientNonce = nonce-fixture-0001 +paymentIdentifier = algorand-testnet:AAAABBBBCCCCDDDD1111 +chainRandomnessInput = chainrand-fixture-block-99887766 +poolHash = poolhash-fixture-v1 + +pool entries (id, weight): + entry-a, 700 + entry-b, 250 + entry-c, 50 + (totalWeight = 1000) + +serverSeedCommitment = sha256(serverSeed) + = 3138bb9bc78df27c473ecfd1410f7bd45ebac1f59cf3ff9cfe4db77aab7aedd3 + +message = serverSeed + "|" + clientNonce + "|" + paymentIdentifier + "|" + + chainRandomnessInput + "|" + poolHash + +combinedSeedHash = sha256(message) + = 794aea81eee7d9d2ed1cf3e3f22621e445383493b8a1288fb373e06355126b6b + +leading 16 hex chars of combinedSeedHash = 794aea81eee7d9d2 +as unsigned 64-bit integer = 8740055870645721554 +selectionRoll = 8740055870645721554 mod 1000 = 554 + +Walking entries sorted by id ascending (entry-a, entry-b, entry-c): + entry-a: cumulative weight 700 → 554 < 700 → SELECTED + +selectedEntryId = entry-a +``` + +## Independent verification + +`POST /api/fairness/verify` with `{ "ripId": "" }` looks up the published proof +bundle for a completed pull and recomputes the selection from scratch using +`verifySelection()`, returning both the stored claim and the recomputation. The +`/fairness` page provides a form UI over this endpoint. Because the recomputation uses the +exact same pure function as the original selection, a mismatch can only mean the stored +proof was tampered with or the revealed seed doesn't match its commitment — both are +explicitly checked and reported as separate failure reasons. + +## Rip-ID lookup, pool hash, odds hash + +Every `FairnessProof` row (keyed by `ripId`) stores the `poolHash`, `oddsHash`, +`serverSeedCommitment`, `revealedServerSeed`, `clientNonce`, `paymentIdentifier`, +`chainRandomnessInput`, `combinedSeedHash`, and `selectionRoll` — the full bundle needed +for reproduction, all copyable as JSON from the `/fairness` verifier UI. + +## Bonus-flip addendum + +Every completed pack opening also evaluates a fixed 4% chance of awarding a second card +from the same pool (see `deriveBonusFlipHit`/`selectBonusPoolEntry` in +`src/server/fairness/engine.ts`). Both the hit/miss determination and the bonus card pick +are derived from the exact same committed server seed as the primary pull — never +client-side randomness — via domain-separated string suffixes (`|bonus_flip_trigger` and +`|bonus_flip_pull`) so the two derivations are cryptographically independent despite +sharing one underlying seed. This produces a second, independent `FairnessProof` row (with +its own `ripId`, `combinedSeedHash`, and `selectionRoll`) whenever the bonus flip hits, +reproducible by anyone the same way as the primary pull. **Not yet reflected** in the +published probability-band table on the pack-detail page or disclosed pre-purchase — see +the note in `docs/LEGAL_REVIEW_REQUIRED.md`. diff --git a/docs/GOOGLE_OAUTH_SETUP.md b/docs/GOOGLE_OAUTH_SETUP.md new file mode 100644 index 0000000..5857afc --- /dev/null +++ b/docs/GOOGLE_OAUTH_SETUP.md @@ -0,0 +1,72 @@ +# Google OAuth Setup + +PackX402 has exactly two ways to create or access an account: **Sign in with Google**, or +a **direct wallet signature** (see `docs/DECISIONS.md` for why — no site-native +email/password, per product decision). This doc covers setting up the Google side. + +The code (`src/server/auth/google-oauth.ts`) is already implemented against Auth.js +(NextAuth v5)'s documented Google provider. It has not been exercised against a real +Google Cloud project in this environment — no credentials were available. Follow these +steps to create real ones. + +## 1. Create a Google Cloud project (skip if you already have one) + +1. Go to . +2. Top-left project dropdown → **New Project**. +3. Name it (e.g. "PackX402"), click **Create**. + +## 2. Configure the OAuth consent screen + +1. In the left sidebar: **APIs & Services → OAuth consent screen**. +2. User type: **External** (unless this is an internal Google Workspace-only app). +3. Fill in the required fields: app name ("PackX402"), user support email, developer + contact email. +4. Scopes: the default `openid`, `email`, `profile` scopes are sufficient — PackX402 does + not request a birthdate scope (Google's birthday scope requires extra verification and + most users don't share it anyway), which is why age/location eligibility is collected + separately after sign-in (see `submitOAuthEligibility` in + `src/server/auth/auth-service.ts` and the `/api/auth/oauth/complete-eligibility` + route). +5. Add test users if the app is still in "Testing" publishing status (required before + verification — anyone not on this list will be blocked from signing in). + +## 3. Create OAuth 2.0 credentials + +1. **APIs & Services → Credentials → Create Credentials → OAuth client ID**. +2. Application type: **Web application**. +3. Name it (e.g. "PackX402 web"). +4. **Authorized redirect URIs** — this must exactly match where NextAuth is mounted + (`basePath: "/api/oauth"` in `google-oauth.ts`), not the default `/api/auth` path: + - Local dev: `http://localhost:3000/api/oauth/callback/google` + - Production: `https:///api/oauth/callback/google` +5. Click **Create**. Copy the **Client ID** and **Client Secret** shown. + +## 4. Set environment variables + +Add to `.env.local` (never commit this file): + +```bash +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +``` + +No separate `AUTH_SECRET` is needed — the NextAuth config reuses the existing +`SESSION_SECRET` env var (see `google-oauth.ts`). + +## 5. Verify + +Once `docker compose up -d && npm run db:migrate && npm run db:seed && npm run dev` is +running with real credentials set, visiting `/packs//open` and hitting "Sign in +to open a pack" should redirect to Google's real consent screen, then back to +`/api/oauth/callback/google`, at which point `findOrCreateGoogleUser()` creates (or +finds) the PackX402 account and a normal `packx402_session` cookie is issued — the same +cookie every other route in the app already checks. + +## What still needs building after this + +- A UI step collecting DOB/country/terms right after first Google (or wallet) sign-in, + calling `POST /api/auth/oauth/complete-eligibility` — the API is implemented and + tested; there is no page for it yet. +- `createPackOffer()` already rejects any purchase attempt for a user with no passing + eligibility record (`error: "eligibility_required"`), so the missing UI step is + surfaced as a real, enforced block today — not a silent gap. diff --git a/docs/HIGGSFIELD_PROMPTS.md b/docs/HIGGSFIELD_PROMPTS.md new file mode 100644 index 0000000..bb7703e --- /dev/null +++ b/docs/HIGGSFIELD_PROMPTS.md @@ -0,0 +1,113 @@ +# PACK402 Higgsfield Pack Artwork + +**Status: real assets generated and installed, current design (v2).** Ten of the fourteen +tiers (Spark through Mythic — every tier priced at or under $250, i.e. everything not +currently locked per `docs/LEGAL_REVIEW_REQUIRED.md`'s bankroll gate) have real +Higgsfield-generated artwork at `public/packs/{tierKey}.png`. Crown, Vault, Grail, and +Genesis remain CSS/SVG placeholders (`PackArt`'s fallback face) — they're locked in the +UI, so there's no user-facing gap. Model used: `nano_banana_pro` (Google), 1K resolution, +`2:3` aspect ratio. + +This is the **second full art pass**. The first pass (wordmark-based, described in earlier +revisions of this doc) was replaced entirely after user review — see "Design history" +below for why. + +## Current design (all 10 unlocked tiers) + +One standardized template, only material/color and a tier-specific background motif +differ: + +- A glowing **"P+X" vault-arc emblem** (a stylized "P" crossed by an "X", drawn as + geometric arc line-work) centered on the face, inside a **corner-bracket frame** (four + simple bracket marks, no checkmarks — an earlier draft had checkmarks in the corners, + removed per feedback). +- **No "PackX402" wordmark anywhere on the pack face** — after repeated attempts to get + clean, correctly-positioned baked-in text failed (see "Design history"), the wordmark + was dropped entirely rather than keep fighting the image model's text rendering. +- The tier name in a **rounded pill badge** near the bottom — the only text on the pack. +- Jagged foil seams top and bottom, glossy cartoon/toon-shaded style, isolated cutout on a + transparent background. +- A **tier-specific background motif** behind the emblem, distinct per tier (not just a + color swap): + +| Tier | File | Material / accent | Background motif | +|---|---|---|---| +| Spark | `spark.png` | Satin black, electric cyan (`#3FE1FF`) | Radiating cyan glow | +| Starter | `starter.png` | Brushed copper (`#C97A44`) | Sunburst rays | +| Scout | `scout.png` | Deep forest teal (`#3FA88C`) | Silver compass/radar lines | +| Bronze | `bronze.png` | Aged bronze (`#B08050`) | Art-deco fan/sunray | +| Silver | `silver.png` | Satin silver/white (`#C7CDD6`) | Diamond-facet lines | +| Gold | `gold.png` | Champagne gold (`#D4AF6A`) | Art-deco golden sunburst | +| Prism | `prism.png` | Satin black + spectral purple-blue | Holographic prism rays | +| Platinum | `platinum.png` | Ice-white platinum (`#CFE7F5`) | Icy frost-crystal facets | +| Obsidian | `obsidian.png` | Volcanic black + emerald + gold | Cracked-glass shard lines | +| Mythic | `mythic.png` | Cosmic violet-black (`#9B6FD6`) + antique gold | Starfield/nebula swirl, ornate gold trim | +| Crown/Vault/Grail/Genesis | *(placeholder)* | Not generated — locked tiers | — | + +Mythic's ornate gold border is a deliberate departure from the others (it's the top +unlocked tier) — confirmed acceptable, not a bug. + +Base prompt template used for tiers 2-10 (Spark generated first as the confirmed +reference, then each other tier generated fresh from the same template rather than +image-edited from Spark, to avoid compounding edit artifacts): + +> Using this pack as the exact template (same P+X emblem in corner-bracket square frame, +> same jagged foil seams top and bottom, same bottom pill badge shape, same cartoon/ +> toon-shaded glossy style, no wordmark text anywhere), create the {TIER} tier variant: +> {material description}. Bottom badge reads "{TIER}". Isolated cutout on a transparent +> background. + +## Design history — why the wordmark was dropped + +The first full art pass baked "PackX402" into every pack face as a rounded bubble-style +wordmark near the top. On review: + +1. The bubble-cartoon font read as unpolished — asked for a cleaner, more modern + geometric/fintech-style font. +2. Multiple regeneration attempts to restyle the font kept either barely changing it, or + fixing the font but re-introducing corner checkmarks that had just been removed, or + moving the wordmark to touch/overlap the top zigzag seam. +3. After several rounds of this, rather than keep spending generations fighting + image-model text positioning, the wordmark was dropped entirely — the emblem moved up + to fill the resulting space, and the tier-name badge is now the only text on the pack. + This was explicitly confirmed as the direction to take before the current set was + batch-generated. + +## Real video-driven rip animation (Spark only so far) + +Superseding the CSS clip-path rip illusion (`RipToOpen.tsx`, still used by tiers without a +video) for Spark: a real Higgsfield `kling3_0` image-to-video interpolation between two +stills — + +1. **Closed still**: the tier's normal pack art, background swapped from transparent to a + solid `#0b0d10` (matching the site's `--background`) via a `nano_banana_pro` edit — + video generation doesn't support alpha transparency, so a matching solid background is + the practical substitute. +2. **Torn-open still**: same edit pass, prompted for a **completely straight horizontal + tear spanning the full width** near the top (an earlier attempt produced a + triangular/peaked tear — corrected per feedback), flap folded back, same solid + background. +3. **`kling3_0`** (`mode: "std"`, `sound: "off"`, `duration: 3`, `aspect_ratio: "9:16"`), + `start_image`/`end_image` = the two stills above, prompt: "The pack tears open along a + completely straight horizontal seam near the top, spanning the full width edge to + edge: the top flap folds back and opens in one smooth continuous motion, revealing the + dark empty interior. Camera locked, no camera movement, nothing else moves." + +Output saved to `public/video/open/spark.mp4`; the torn-open still is also saved to +`public/packs/spark-open.png` (shown as a static frame during the brief "tearing" phase +transition). `RipToOpenVideo.tsx` maps the user's drag position directly to +`video.currentTime` — see `src/components/pack-art/rip-video-map.ts` for the tier→asset +map and `PROJECT_STATUS.md` for how it's wired into both the real opening theater and the +no-DB demo. The remaining 9 tiers need the same 3-asset pipeline (closed-solid-bg still → +torn-solid-bg still → `kling3_0` interpolation) once their base art is finalized. + +## Known follow-ups + +- **File size**: raw PNGs are ~1.3-1.5MB each (~14MB total for the 10 tiers) — Next.js's + image optimizer resizes/re-encodes on request, so served bytes are much smaller, but the + committed repo size is worth reducing (re-export as compressed PNG/WebP) before this + matters for clone times. +- **Crown/Vault/Grail/Genesis**: not generated — regenerate once those tiers unlock. +- **9 of 10 tiers still need the video-rip treatment** — only Spark has one so far. +- **Card back, marketing variants (pedestal/3q/closeup/thumb/hero shots), other VFX + assets**: none generated yet — see `docs/ASSET_MANIFEST.md` for the full remaining list. diff --git a/docs/INCIDENT_RESPONSE.md b/docs/INCIDENT_RESPONSE.md new file mode 100644 index 0000000..e9e3e0e --- /dev/null +++ b/docs/INCIDENT_RESPONSE.md @@ -0,0 +1,61 @@ +# Incident Response + +## Severity levels + +- **SEV1** — funds at risk, fairness integrity compromised, or full outage. +- **SEV2** — a single subsystem down (e.g. supplier purchasing) with a workaround. +- **SEV3** — degraded but functional (e.g. elevated latency). + +## Immediate actions by incident type + +### Suspected fairness compromise (a server seed leaked before payment, or selection + +manipulated) + +1. Immediately set `FEATURE_HIGH_VALUE_PACKS_ENABLED=false` and consider disabling new + offer creation entirely (kill switch — not yet implemented as a single flag; today this + means stopping the application or blocking `/api/x402/*` at the edge/load balancer). +2. Freeze the affected pool version (`poolVersions.archivedAt`) so no further offers use it. +3. Pull every `FairnessProof` row for the affected pool version and independently + re-verify each one via `verifySelection()`. +4. Do not reroll or retroactively change any already-revealed result — the commitment + model means a past reveal is either valid or it isn't; if it isn't, that is a + fulfillment/refund matter for the affected users, not a "redo." + +### Duplicate or ambiguous payment + +`payments.txHashOrPaymentId` is DB-unique, so a true duplicate insert cannot happen. An +"ambiguous" payment (e.g. correct amount but from an unexpected address, or a +partial/overpayment) surfaces as a `payment_invalid`/`payment_ambiguous` state from +`settleOfferAndOpen()`. Manually review via the transaction hash on-chain before taking +any refund action. + +### Supplier account compromise or unexpected cart contents + +Both the mock and live CardTrader providers **abort `addToCart()` if the account cart is +already non-empty** rather than assuming it's empty — this is the primary automated +defense. If it fires unexpectedly in production, treat it as a signal of either a bug in +the (not-yet-built) serialization worker or genuine account compromise; do not manually +clear the cart without confirming which. + +### Database compromise + +Session tokens, recovery codes, and the fairness server seed are stored only as hashes/ +ciphertext — a raw DB dump does not yield usable session tokens or pre-reveal fairness +secrets. Shipping addresses are AES-256-GCM encrypted. Rotate `SESSION_SECRET` and +`FIELD_ENCRYPTION_KEY` immediately (this invalidates all existing sessions and makes +existing encrypted fields unreadable — only do this as part of a real compromise +response, with a plan for re-encrypting live data under the new key first if data must be +preserved). + +## Postmortem + +Every SEV1/SEV2 incident gets a written postmortem: timeline, root cause, what +`audit_events`/`security_events` rows show, what was fixed, and what monitoring/test gap +allowed it. File it under `docs/incidents/` (directory not yet created — create on first +real incident). + +## Contacts + +See the repository's Security tab / `SECURITY.md` for the reporting channel. This beta has +no formal on-call rotation defined yet. diff --git a/docs/LEGAL_REVIEW_REQUIRED.md b/docs/LEGAL_REVIEW_REQUIRED.md new file mode 100644 index 0000000..23f1910 --- /dev/null +++ b/docs/LEGAL_REVIEW_REQUIRED.md @@ -0,0 +1,77 @@ +# Legal Review Required + +This document lists items that require legal, compliance, or licensing review before +production launch or before enabling a currently-gated feature. Nothing in this list has +been reviewed by counsel as part of this build session — this is an engineering-generated +checklist, not a legal opinion. + +## Before enabling high-value packs (Crown and above, > $250) + +Locked during beta primarily because the current supplier-purchase bankroll doesn't cover +funding fulfillment above this price point — the items below are what's additionally +required before lifting the lock once that changes. + +- Consumer-protection review of randomized physical-goods sales in every jurisdiction + PackX402 operates in (loot-box-style regulation varies significantly by country and, in + the US, by state). +- Confirm `BLOCKED_US_STATES` (`src/server/eligibility/policy.ts`, currently empty) is + populated per counsel's guidance before launch, not left empty. +- Financial review of supplier-account funding at higher price points. +- Security review of the (not-yet-built) supplier-purchase worker at higher transaction + values. +- Responsible-purchasing review of default limits at higher price points. + +## Before enabling Algorand MainNet + +- Confirm money-transmission / payment-processor licensing posture in each operating + jurisdiction for real-value USDC settlement. +- Confirm merchant wallet custody and key-management procedures (outside this + repository's scope — PackX402's own merchant keys, not customer keys, still require + operational security review). + +## IP / licensing + +- **No Pokémon or Yu-Gi-Oh artwork, logos, or trademarks are used anywhere in this + codebase.** Card _names_ used in seed fixtures (`src/server/suppliers/cardtrader/ +fixtures.ts`) are factual identifiers of real, third-party-owned cards being resold by a + supplier — this is the same descriptive-use pattern any card marketplace uses, not + original artwork. Get trademark/fair-use counsel sign-off before any public marketing + use of card names, and ensure all pack artwork is generated per + `docs/HIGGSFIELD_PROMPTS.md` (no existing character likenesses). +- Confirm CardTrader's terms of service actually permit the direct-seller-fulfillment + integration pattern implemented in `docs/SUPPLIER_INTEGRATION.md` before enabling live + mode. +- Confirm the TCGplayer affiliate-link fallback (not yet implemented) complies with + TCGplayer's affiliate program terms before building it. + +## Responsible purchasing / gambling-adjacent regulation + +- Have counsel confirm PackX402's specific mechanic (fixed-price purchase, published odds, + guaranteed physical delivery of _something_ every time — no "nothing" outcome) falls + outside gambling regulation in target jurisdictions, and confirm required disclosures. +- Confirm age-gate and self-exclusion mechanisms (`src/server/eligibility/policy.ts`, + `self_exclusions` table) meet the jurisdiction-specific bar, not just the beta's + 18+/blocked-country baseline. +- **Bonus-flip mechanic** (`deriveBonusFlipHit`/`selectBonusPoolEntry` in + `src/server/fairness/engine.ts`): a fixed 4% chance, evaluated on every completed pack + opening, of awarding a second real card from the same pool alongside the one paid for. + This changes the effective expected value/odds of every pack tier. The 4% figure is now + disclosed on the pack-detail page (a "Bonus flip: 4% chance of a second card" line) and + in `docs/FAIRNESS_PROTOCOL.md`'s addendum, both pre-purchase — but counsel should still + confirm this doesn't change PackX402's gambling-regulation analysis (an "extra" reward on + a fixed-price purchase, even disclosed, may read differently under some jurisdictions' + rules than the base mechanic alone), and that the disclosure wording/placement meets + whatever pre-purchase disclosure standard applies. + +## Data protection + +- GDPR/CCPA (or applicable regional) compliance review of `docs/PRIVACY_DATA_MAP.md`, + especially the not-yet-encrypted `eligibility_records.dateOfBirth` field flagged there. +- Confirm data-retention periods for `security_events`, `audit_events`, and shipping + address history. + +## Affiliate program + +- Confirm disclosure requirements (FTC endorsement guides or regional equivalent) are met + by `affiliateAccounts.disclosureText` before the affiliate program (not yet built) + launches. diff --git a/docs/LOYALTY_AND_FREE_PACKS.md b/docs/LOYALTY_AND_FREE_PACKS.md new file mode 100644 index 0000000..430fd29 --- /dev/null +++ b/docs/LOYALTY_AND_FREE_PACKS.md @@ -0,0 +1,46 @@ +# Loyalty Levels & Weekly Free Packs + +## Loyalty levels + +Implementation: `src/server/loyalty/calculate.ts` (7 passing tests) + +`loyalty_levels`/`loyalty_calculations` tables. + +| Level | Rolling 30-day eligible spend | Weekly reward pack | +| -------- | ----------------------------- | ------------------ | +| Member | $0 – $24.99 | Spark | +| Copper | $25 – $99.99 | Starter | +| Silver | $100 – $249.99 | Scout | +| Gold | $250 – $499.99 | Bronze | +| Obsidian | $500+ | Silver | + +**Maximum reward level during beta is Silver** — `determineCappedRewardLevel()` computes +the true spend-based level but always caps the _granted reward_ at Silver, tested +explicitly for Gold- and Obsidian-tier spend. `MAX_BETA_REWARD_LEVEL` is the single +constant controlling this cap. + +`computeEligibleSpend()` subtracts refunds, affiliate-attributed spend, and self-referral +spend from fulfilled-order totals — never based on losses, never personalized to "recover" +a loss (no such code path exists). + +**Not yet implemented**: the scheduled job that actually recomputes a user's level from +real order history and writes a `loyalty_calculations` audit row; a global kill switch +flag exists in the schema (`feature_flags` / `FEATURE_LOYALTY_KILL_SWITCH` env var) but no +code currently reads it in a loyalty code path (there is no loyalty code path yet beyond +the pure calculation). + +## Weekly free packs + +Implementation: `src/server/free-packs/week-key.ts` (ISO week key, 5 passing tests, +verified against a reference algorithm) + `free_pack_grants`/`free_pack_claims` tables. + +- `free_pack_grants` is unique on `(userId, weekKey, reason)` — the database itself + prevents a second grant for the same user/week/reason, not just application logic. +- `FreePackGrant` and `FreePackClaim` are separate tables/records per spec: a grant + existing does not imply a claim. Claiming is a distinct, explicit user action. +- Free-pack pools are separately published promotional pool versions + (`pool_versions.isPromotional = true`), never mixed with a tier's paid pool. + +**Not yet implemented**: the API route/UI for claiming a free pack, the job that issues +weekly grants to eligible verified users, and the enforcement that self-excluded/paused +accounts cannot claim (the underlying self-exclusion check exists and is tested — it just +isn't called from a free-pack code path yet, because that code path doesn't exist yet). diff --git a/docs/PRIVACY_DATA_MAP.md b/docs/PRIVACY_DATA_MAP.md new file mode 100644 index 0000000..3d4ae0a --- /dev/null +++ b/docs/PRIVACY_DATA_MAP.md @@ -0,0 +1,51 @@ +# Privacy Data Map + +## Personal data collected and where it lives + +| Data | Table | Protection | +| --------------------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Email | `users.email` | Plaintext (needed for login/lookup); unique index | +| Date of birth | `eligibility_records.dateOfBirth` | Application-layer encryption intended (see note below) | +| Shipping address (name, lines, city, state, postal code, phone) | `shipping_addresses.*Encrypted` | AES-256-GCM field encryption (`src/server/crypto/field-encryption.ts`) | +| Wallet addresses | `wallet_identities.address` | Plaintext (public by nature of blockchain addresses); `isPublic` flag defaults `false` for profile display | +| IP address | `sessions.ipHash`, `security_events.ipHash`, `eligibility_records.ipHash` | Only a hash is ever stored, never a raw IP | +| Session tokens | `sessions.tokenHash` | Only a sha256 hash is stored; raw token never persisted | +| Fairness server seed (pre-reveal) | `pack_offers.serverSeedEncrypted` | AES-256-GCM field encryption | + +**Note**: `eligibility_records.dateOfBirth` is currently stored as plaintext ISO-date text +in the schema with a comment marking it for field-level encryption — this is a follow-up +item, not yet wired to `encryptField()`/`decryptField()`. Track in PROJECT_STATUS.md. + +## What is never stored + +- Wallet private keys or seed phrases — no column, anywhere, for any purpose. +- Supplier payment-card data — PackX402 never touches CardTrader's own payment + instruments; only shipping-address data is sent to the supplier at purchase time. +- Plaintext session tokens, plaintext recovery codes (only `codeHash`), plaintext + passwords where used (`users.passwordHash` — hashing algorithm to be finalized before + password auth ships; email flows in this repo are currently magic-link/verification + based, not password-based). + +## What is never exposed in public/social API responses + +Shipping addresses, exact wallet addresses (only an optional `isPublic` badge, never the +raw address, is intended for profile display), supplier order numbers, private payment +metadata, and a user's total spend or loss/profit estimate — none of these fields exist on +any of the social-facing tables (`pull_posts`, `showcases`, `social_posts`) by +construction; there is no column to leak. + +## Data subject rights + +Schema supports account deletion (`users.deletionRequestedAt`) and the security center +requirement to "download account data" (spec section 32) — no export/delete job is +implemented yet (see PROJECT_STATUS.md). + +## Third parties data is shared with + +- **CardTrader** (supplier): shipping address, at purchase time only, in live mode only. +- **Algorand/Solana/EVM networks**: payment transaction data is inherently public + on-chain; PackX402 does not control this. +- **GoPlausible facilitator** (when live mode is enabled): payment verification data per + the x402 protocol. + +No analytics, advertising, or cross-site tracking integration exists in this codebase. diff --git a/docs/RESPONSIBLE_PURCHASING.md b/docs/RESPONSIBLE_PURCHASING.md new file mode 100644 index 0000000..cace727 --- /dev/null +++ b/docs/RESPONSIBLE_PURCHASING.md @@ -0,0 +1,36 @@ +# Responsible Purchasing + +Implementation: `src/server/responsible-purchasing/limits.ts` (pure decision logic, 15 +passing tests) + `purchase_limits` / `self_exclusions` tables, enforced in +`createPackOffer()` before any offer is created. + +## Rules implemented and tested + +- Self-exclusion (indefinite or time-boxed) blocks all purchases; checked first, before + any limit math. +- Account pause blocks purchases for a configured window. +- Cool-off period blocks purchases for a configured window, independent of a full pause. +- Daily / weekly / monthly spend limits: a purchase is denied if it would push rolling + spend over any configured limit; `null` means unbounded. +- **Limit decreases apply immediately; limit increases apply only after a cooling + period** (`scheduleLimitChange()`), tested for both directions and for "removing a limit + entirely" being treated as an increase. + +## Not yet implemented + +- Rolling spend aggregation from real fulfilled orders (`offer-service.ts` currently + passes zeros — see PROJECT_STATUS.md). The limit _check_ is fully correct; only the + _input_ (actual spend-to-date) is not yet wired to live data. +- The settings UI for users to view/change their own limits. +- Cross-wallet self-exclusion enforcement (the schema supports it — `self_exclusions` is + keyed by user, and all wallets belong to a user — but no code path currently checks it + from a wallet-first purchase flow). +- Admin override with documented reason field (schema: `audit_events` supports it; no UI). + +## Policy alignment with spec section 33 + +- Self-excluded users cannot claim randomized promotional packs — same check + (`evaluatePurchaseAgainstLimits`) is intended to gate the free-pack claim path once that + flow is built; not yet wired (see `docs/LOYALTY_AND_FREE_PACKS.md`). +- No "spend $X more today" countdown or targeted upsell messaging exists anywhere in this + codebase — by omission, not by a suppressed feature. diff --git a/docs/RISKS.md b/docs/RISKS.md new file mode 100644 index 0000000..e64772c --- /dev/null +++ b/docs/RISKS.md @@ -0,0 +1,14 @@ +# Risk Register + +| # | Risk | Likelihood | Impact | Mitigation / status | +| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | Live x402 facilitator integration behaves differently than the direct-chain-verification approach implemented here | Medium | High (payment correctness) | Explicitly flagged unverified; direct algod/web3.js/viem verification is a defensible, testable interim approach. Must integration-test before going live. | +| 2 | Live CardTrader integration's request/response shapes don't match reality | Medium | High (duplicate/failed purchases, wasted supplier spend) | Explicitly flagged unverified; mock mode is the default and only tested path. Must integration-test with a sandbox account before `CARDTRADER_MODE=live`. | +| 3 | Supplier-purchase queue has no worker yet — purchases enqueue but never execute | High (certain, until built) | High (no fulfillment happens) | Top item in ROADMAP.md Phase 1. | +| 4 | `eligibility_records.dateOfBirth` stored as plaintext, not yet field-encrypted | High (certain, until fixed) | Medium (PII exposure on DB compromise) | Flagged in PRIVACY_DATA_MAP.md; small fix once the eligibility-submission API route is built. | +| 5 | No rate limiting is actually applied to any route yet (the helper exists, unused) | High | Medium (brute force / abuse until wired in) | `checkRateLimit()` implemented and ready; needs to be called from auth and payment routes. | +| 6 | High-value or MainNet packs accidentally enabled in production via misconfigured env vars | Low (guarded) | Critical | Server-side gates (`FEATURE_HIGH_VALUE_PACKS_ENABLED`, `ALGORAND_MAINNET_ENABLED`) checked in `src/server/env.ts` at boot and in `createPackOffer()` per-request; a client cannot bypass either. | +| 7 | No live database was available to integration-test the full offer→payment→fairness→fulfillment flow | Medium | High | `tsc --noEmit` against the live Drizzle schema is a strong compile-time signal but not a substitute for a real run. Documented as the #1 "immediate next step" in PROJECT_STATUS.md. | +| 8 | Modulo bias in fairness selection (64-bit hash mod pool weight) | Very low (cryptographically negligible) | Low | Accepted trade-off, documented in FAIRNESS_PROTOCOL.md. | +| 9 | No Playwright/e2e coverage exists | High (certain, until built) | Medium (regressions in UI flows undetected by unit tests) | ROADMAP.md Phase 5. | +| 10 | Dependency vulnerabilities in transitive wallet-SDK packages (`npm audit` reports 46, mostly moderate, from WalletConnect/MetaMask transitive deps pulled in by `@phantom/react-sdk`/`@txnlab/use-wallet-react`) | Medium | Medium | Run `npm audit` regularly; these packages are widely used in production wallet integrations but should be monitored via Dependabot/Renovate (configured in `.github/`). | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..fdecd0c --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,45 @@ +# Roadmap + +See [PROJECT_STATUS.md](../PROJECT_STATUS.md) for the authoritative done/not-done split. +This file is the forward-looking sequencing. + +## Phase 0 — this build (complete) + +Data model, fairness engine, pack-tier config, x402 payment adapters (mock-default), +CardTrader adapter (mock-default), auth primitives (sessions, wallet-signature crypto), +eligibility/responsible-purchasing/loyalty pure logic, landing/marketplace/pack-detail/ +fairness-center/odds-library pages, security headers + CSRF + rate-limit primitives, CI, +docs. + +## Phase 1 — golden path to a real TestNet opening + +1. `/api/auth/*` route handlers (signup, login, wallet-link, logout, session management) + on top of the already-tested session/crypto logic. +2. Signup/login/wallet-center UI. +3. Purchase-confirmation screen + animated opening theater UI wired to the working + `/api/x402/algorand/v1/packs/open` endpoint. +4. Pull result page + personal collection (read-only first). +5. Supplier-purchase worker process (currently only enqueues, never processes). +6. Verify the whole flow against a real Algorand TestNet transaction end-to-end. + +## Phase 2 — retention mechanics + +Weekly free-pack claim flow, loyalty recalculation job, shipping center, order tracking +UI, notifications center. + +## Phase 3 — community + +Social profiles/feed, pull sharing, showcases, follow/block/report, clubs, challenges, +opt-in leaderboards, referral dashboard. + +## Phase 4 — growth & admin + +Affiliate program (application → payout), admin dashboard (RBAC across all 20 areas in +spec section 35), support/dispute case management UI, transparency/status pages. + +## Phase 5 — production hardening + +Live CardTrader + live GoPlausible facilitator integration testing with real credentials, +Playwright e2e suite, load testing the supplier-purchase queue, full accessibility audit, +legal review sign-off (`docs/LEGAL_REVIEW_REQUIRED.md`), then and only then consider +enabling MainNet or high-value tiers. diff --git a/docs/SOCIAL_MODERATION.md b/docs/SOCIAL_MODERATION.md new file mode 100644 index 0000000..4efac46 --- /dev/null +++ b/docs/SOCIAL_MODERATION.md @@ -0,0 +1,33 @@ +# Social & Moderation + +**Status: data model only.** Tables exist for pull posts, showcases, social posts, +comments, reactions, follows, blocks, reports, badges, clubs/club members, challenges/ +completions, notifications (`src/server/db/schema/social.ts`). No application code, API +routes, or UI exist yet. + +## Design constraints already encoded in the schema + +- `pullPosts` requires an explicit row to exist before a pull is shareable — a `Rip` never + automatically creates a `PullPost`. Sharing is opt-in by construction: there is no + trigger, no default-true visibility flag, and no code path that inserts a `PullPost` + except a future explicit "share" action. +- `pullPosts.visibility` defaults to `"public"` only for the _post itself_ once a user has + chosen to create one — shipping address, exact wallet address, supplier order number, + private payment metadata, spend/loss totals are **not columns on `pullPosts` at all**, + so there is no field to accidentally expose them from. +- `moderationActions` covers comment/post removal, temporary/permanent suspension, with an + `appealNote` field. +- `reports` has a `status` enum (`open`/`actioned`/`dismissed`) and `resolvedByAdminId`. +- No direct-messaging table exists anywhere in the schema — DMs are out of scope for this + beta per spec section 45, enforced by omission. +- No spend/loss/purchase-count leaderboard table or column combination exists — only + collection-focused fields (showcases, badges, follower counts) are queryable for + leaderboard purposes, per spec section 30. + +## Not yet implemented + +Feed rendering, follow/block/report UI and API routes, comment/reaction endpoints, image +scanning and upload validation for avatars/banners/showcase covers, username moderation, +link filtering, anti-spam rate limits (the generic `checkRateLimit()` helper exists and +would be reused here), club moderation tools, and challenge completion tracking. See +PROJECT_STATUS.md. diff --git a/docs/SUPPLIER_INTEGRATION.md b/docs/SUPPLIER_INTEGRATION.md new file mode 100644 index 0000000..ee76dd5 --- /dev/null +++ b/docs/SUPPLIER_INTEGRATION.md @@ -0,0 +1,73 @@ +# Supplier Integration + +## Interface + +`src/server/suppliers/types.ts` defines `SupplierAdapter` — provider-neutral, with +`searchEligibleListings`, `getLiveQuote`, `validateListing`, `addToCart`, `confirmCart`, +`purchaseListing`, `removeFromCart`, `getOrder`, `getTracking`, `requestCancellation`, +`healthCheck`. Any future marketplace integration must implement this same interface. + +## CardTrader (first and only beta implementation) + +- **Mock** (`src/server/suppliers/cardtrader/mock-provider.ts`, `CARDTRADER_MODE=mock`, + the default): in-memory, deterministic fixture inventory + (`src/server/suppliers/cardtrader/fixtures.ts`), 6 passing integration-style tests + covering idempotent purchase and cart-safety abort behavior. +- **Live** (`src/server/suppliers/cardtrader/live-provider.ts`, `CARDTRADER_MODE=live`): + implements the documented v2 API shape — `GET /marketplace/products`, `GET /cart`, + `POST /cart/add`, `POST /cart/remove`, `POST /cart/purchase`, order/tracking endpoints, + `via_cardtrader_zero=false` (direct seller fulfillment, per spec). **Has not been run + against a real CardTrader account** — no API token was available in the build + environment. Verify against current CardTrader API docs and a sandbox account before + ever setting `CARDTRADER_MODE=live`. + +TCGplayer is explicitly **not** an automated provider — it remains a disabled future +provider / approved affiliate-link fallback / manual admin source only, per spec section 37. No TCGplayer scraping exists anywhere in this codebase. + +## Eligibility rules (spec section 38) + +`src/server/suppliers/eligibility.ts` — a pure function checking quantity, seller +vacation status, shipping capability, full card identity (game/set/number/condition/ +language/finish/grade), tier procurement price cap, seller reliability score, inventory +freshness, shipping estimability, and image-use permission. 8 passing tests. + +## Cart concurrency (spec section 39) + +CardTrader uses an **account-level cart** — PackX402 must never have two purchase jobs +racing on the same account cart. The schema (`supplier_purchases`) is designed for a +serialized queue: one `queued` row per rip, unique on `idempotencyKey`. Both providers' +`addToCart()` **abort if the cart already has contents** rather than assuming it's empty +(tested in the mock provider). `purchaseListing()` is idempotent: replaying the same +idempotency key returns the original order instead of creating a duplicate (tested). + +**Now implemented**: `src/server/suppliers/purchase-worker.ts` consumes the +`supplier_purchases` queue — `runSupplierPurchaseWorkerLoop()` polls for the oldest +`queued` row (claimed via a conditional `UPDATE ... WHERE status = 'queued'`, safe against +two workers racing the same row), then runs validate → add-to-cart → confirm → purchase +against `getCardTraderProvider()`, records the outcome (`purchased` + a `fulfillments` row, +or `failed` + a reason), and updates the parent `pack_offers.status`. Run it with `npm run +worker:supplier-purchases` (entry point: `src/server/suppliers/run-worker.ts`) as its own +long-running process — not a request handler, not a cron job. + +**Known gaps in the worker, left as documented follow-ups** (not silently solved): +- **Cross-account serialization**: the row-level claim prevents double-processing one row, + but running more than one worker *process* against the same supplier account still needs + an external lock (e.g. Redis) — not implemented, no live infrastructure was available to + build and verify one. +- **Substitution-on-unavailable (spec section 40)**: if a listing is out of stock/removed, + the worker marks the row `failed` rather than searching for a substitute — there is no + configured procurement-price-increase tolerance anywhere in this codebase to drive that + search, and rerolling would violate the fairness proof. A human resolves it manually today. +- **No shipping address on file**: marks the row `failed` with `no_shipping_address_on_file` + — there is no UI yet for a user to add one (see PROJECT_STATUS.md's UI gaps list). +- Unverified against a live Postgres/CardTrader account, like everything else DB-dependent + in this repo (see PROJECT_STATUS.md's environment-constraints note). + +## Failure process (spec section 40) + +Documented target behavior (not yet coded — depends on the worker above): if the winning +listing becomes unavailable, search for an exact match (same set/number/language/finish/ +condition/grade) within a configured procurement-price increase; never reroll; if no exact +match exists, transition to `FULFILLMENT_FAILED_REFUND_REQUIRED`; allow a +customer-approved equal-or-better substitution; always preserve the original fairness +proof; record the full decision trail in `audit_events`. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..030773e --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,44 @@ +# Threat Model + +Assumption: every client, supplier response, facilitator response, upload, webhook, +social post, and affiliate request is hostile. + +## Assets + +- User funds in transit (payment settlement correctness) +- Fairness integrity (a user or PackX402 insider rerolling/predicting an outcome) +- Shipping addresses and other PII +- Session tokens / account takeover +- Supplier-account funds (CardTrader spending) +- Platform integrity (referral/affiliate/loyalty abuse, self-exclusion bypass) + +## Threats and mitigations + +| Threat | Mitigation | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Client reroll after seeing an unfavorable commitment | Server seed is committed (hashed) before payment; revealed only after settlement; selection also depends on post-settlement chain randomness no party controls in advance. | +| Replayed/duplicate payment credited twice | `payments.tx_hash_or_payment_id` is DB-unique; `settleOfferAndOpen` checks for an existing payment before creating a new one. | +| Duplicate supplier purchase (double order) | `supplier_purchases.idempotency_key` is DB-unique; `SupplierAdapter.purchaseListing` in the mock/live providers replays the existing order for a repeated key instead of creating a new one. | +| Client-supplied tier price/availability trusted | `isTierPurchasableOn()` and `createPackOffer()` re-derive price and eligibility from server/DB state on every request; a client can only supply a tier _key_. | +| High-value pack purchased before legal review | `requiresHighValueReleaseGate` + `FEATURE_HIGH_VALUE_PACKS_ENABLED` server flag; a locked/gated tier is rejected in `createPackOffer` regardless of client input. | +| Self-excluded user purchases anyway | `evaluatePurchaseAgainstLimits()` checks self-exclusion before any limit math; enforced in `createPackOffer`. | +| Session token theft via DB leak | Only a sha256 hash of the token is stored; the raw token is never persisted. | +| Wallet-signature replay | Nonces are single-use (`auth_nonces.consumed_at`), short-lived, and bound to domain/URI/chain/purpose in the signed message. | +| CSRF on state-changing requests | Double-submit cookie pattern (`src/proxy.ts` + `src/server/security/csrf.ts`). | +| XSS via stored content (comments, captions, usernames) | React's default escaping; CSP restricts script execution to same-origin + nonce; no `dangerouslySetInnerHTML` is used anywhere in this codebase. | +| SSRF via a supplier/webhook URL | Supplier base URLs are fixed server env config, never taken from request input. | +| Shipping address exposure | Encrypted at rest (AES-256-GCM); never included in any public/social API response — see `docs/PRIVACY_DATA_MAP.md`. | +| Malicious upload (profile image) | Not yet implemented — flagged in PROJECT_STATUS.md; must add MIME-signature verification + metadata stripping before shipping upload support. | +| Admin privilege escalation | `admin_users` is a separate table from `users` with no user-facing write path; sensitive admin actions are designed to require reauthentication (`ADMIN_REAUTH_TTL_SECONDS`) — enforcement UI not yet built. | +| Rate-limit bypass / brute force | `checkRateLimit()` (Redis fixed-window) — needs to be wired into auth and payment routes; currently implemented but not yet applied to every mutating route (see PROJECT_STATUS.md). | + +## Explicitly out of scope for this beta (by design, see main spec) + +Custodial wallets, cash withdrawals, P2P trading, user-to-user transfers — these attack +surfaces don't exist because the features don't exist. + +## Residual risk / follow-up + +See [docs/RISKS.md](RISKS.md) for the prioritized risk register, and +[PROJECT_STATUS.md](../PROJECT_STATUS.md) for what's unverified against live +infrastructure. diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..7cc0f1c --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "drizzle-kit"; +// Deliberately imports env.core.ts, not env.ts: env.ts is guarded by the `server-only` +// package so it can never leak into a client bundle, but drizzle-kit runs as a +// standalone CLI outside the Next.js server-component boundary, where that guard misfires. +import { serverEnv } from "./src/server/env.core"; + +export default defineConfig({ + schema: "./src/server/db/schema/index.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: serverEnv.DATABASE_URL, + }, + strict: true, + verbose: true, +}); diff --git a/drizzle/0000_glamorous_fabian_cortez.sql b/drizzle/0000_glamorous_fabian_cortez.sql new file mode 100644 index 0000000..eff04c0 --- /dev/null +++ b/drizzle/0000_glamorous_fabian_cortez.sql @@ -0,0 +1,942 @@ +CREATE TYPE "public"."affiliate_application_status" AS ENUM('pending', 'approved', 'rejected', 'suspended');--> statement-breakpoint +CREATE TYPE "public"."affiliate_commission_status" AS ENUM('pending', 'approved', 'paid', 'reversed', 'rejected');--> statement-breakpoint +CREATE TYPE "public"."audit_event_actor_type" AS ENUM('user', 'admin', 'system', 'webhook');--> statement-breakpoint +CREATE TYPE "public"."auth_method" AS ENUM('email', 'wallet', 'passkey');--> statement-breakpoint +CREATE TYPE "public"."card_condition" AS ENUM('mint', 'near_mint', 'lightly_played', 'moderately_played', 'heavily_played', 'damaged');--> statement-breakpoint +CREATE TYPE "public"."card_finish" AS ENUM('normal', 'holofoil', 'reverse_holofoil', 'first_edition', 'other_foil');--> statement-breakpoint +CREATE TYPE "public"."card_game" AS ENUM('pokemon', 'yugioh', 'other');--> statement-breakpoint +CREATE TYPE "public"."chain" AS ENUM('algorand', 'solana', 'evm');--> statement-breakpoint +CREATE TYPE "public"."fulfillment_status" AS ENUM('opening_completed', 'supplier_purchase_queued', 'supplier_order_submitted', 'supplier_confirmed', 'preparing_shipment', 'shipped', 'tracking_available', 'delivered', 'problem_reported', 'refund_or_substitution_review');--> statement-breakpoint +CREATE TYPE "public"."loyalty_level_key" AS ENUM('member', 'copper', 'silver', 'gold', 'obsidian');--> statement-breakpoint +CREATE TYPE "public"."moderation_action_type" AS ENUM('comment_removed', 'post_removed', 'temporary_suspension', 'permanent_suspension', 'warning_issued', 'appeal_upheld', 'appeal_denied');--> statement-breakpoint +CREATE TYPE "public"."network_mode" AS ENUM('testnet', 'mainnet');--> statement-breakpoint +CREATE TYPE "public"."pack_offer_status" AS ENUM('DRAFT', 'ELIGIBILITY_CHECKED', 'INVENTORY_SNAPSHOTTED', 'RESULT_COMMITTED', 'OFFERED', 'PAYMENT_PENDING', 'PAID', 'OPENED', 'SUPPLIER_PURCHASE_QUEUED', 'SUPPLIER_PURCHASED', 'SHIPPED', 'DELIVERED', 'EXPIRED', 'PAYMENT_FAILED', 'PAYMENT_AMBIGUOUS', 'SECURITY_HOLD', 'SUPPLIER_FAILED', 'REFUND_REQUIRED', 'REFUNDED', 'CUSTOMER_SUBSTITUTION_REVIEW', 'CANCELLED');--> statement-breakpoint +CREATE TYPE "public"."payment_status" AS ENUM('pending', 'settled', 'underpaid', 'wrong_network', 'wrong_token', 'wrong_recipient', 'duplicate', 'ambiguous', 'failed', 'refunded');--> statement-breakpoint +CREATE TYPE "public"."referral_attribution_status" AS ENUM('pending', 'qualified', 'rewarded', 'rejected', 'reversed');--> statement-breakpoint +CREATE TYPE "public"."report_status" AS ENUM('open', 'actioned', 'dismissed');--> statement-breakpoint +CREATE TYPE "public"."security_event_type" AS ENUM('login_success', 'login_failed', 'suspicious_login', 'session_revoked', 'wallet_linked', 'wallet_unlinked', 'mfa_enabled', 'mfa_disabled', 'password_reset_requested', 'account_locked', 'admin_reauth', 'self_exclusion_set', 'compromise_reported');--> statement-breakpoint +CREATE TYPE "public"."session_revoked_reason" AS ENUM('user_revoked', 'user_revoked_all', 'admin_revoked', 'suspicious_login', 'expired', 'password_reset');--> statement-breakpoint +CREATE TYPE "public"."shipping_treatment" AS ENUM('included', 'separately_charged', 'subsidized');--> statement-breakpoint +CREATE TYPE "public"."social_visibility" AS ENUM('public', 'private');--> statement-breakpoint +CREATE TYPE "public"."supplier_purchase_status" AS ENUM('queued', 'cart_reserved', 'purchased', 'failed', 'cancelled');--> statement-breakpoint +CREATE TYPE "public"."support_case_status" AS ENUM('open', 'investigating', 'awaiting_customer', 'awaiting_supplier', 'resolved', 'closed');--> statement-breakpoint +CREATE TYPE "public"."support_case_type" AS ENUM('missing_order', 'incorrect_card', 'incorrect_condition', 'damaged_card', 'tracking_problem', 'supplier_cancellation', 'duplicate_payment', 'payment_settled_without_result', 'refund_required', 'account_security', 'affiliate_dispute', 'social_moderation_appeal');--> statement-breakpoint +CREATE TABLE "auth_nonces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "nonce" text NOT NULL, + "chain" "chain" NOT NULL, + "address" text NOT NULL, + "domain" text NOT NULL, + "uri" text NOT NULL, + "purpose" text NOT NULL, + "issued_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + CONSTRAINT "auth_nonces_nonce_unique" UNIQUE("nonce") +); +--> statement-breakpoint +CREATE TABLE "eligibility_records" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "session_correlation_id" text NOT NULL, + "date_of_birth" text NOT NULL, + "age_acknowledged_18_plus" boolean NOT NULL, + "location_country" text NOT NULL, + "location_state_or_province" text, + "location_allowed" boolean NOT NULL, + "policy_version" text NOT NULL, + "acknowledged_at" timestamp with time zone DEFAULT now() NOT NULL, + "ip_hash" text +); +--> statement-breakpoint +CREATE TABLE "email_verification_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "purpose" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "email_verification_tokens_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "recovery_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "code_hash" text NOT NULL, + "used_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "auth_method" "auth_method" NOT NULL, + "user_agent" text, + "ip_hash" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_reason" "session_revoked_reason", + CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "shipping_addresses" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "full_name_encrypted" text NOT NULL, + "line1_encrypted" text NOT NULL, + "line2_encrypted" text, + "city_encrypted" text NOT NULL, + "state_or_province_encrypted" text, + "postal_code_encrypted" text NOT NULL, + "country" text NOT NULL, + "phone_encrypted" text, + "is_default" boolean DEFAULT false NOT NULL, + "verified_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_profiles" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "display_name" text NOT NULL, + "avatar_url" text, + "banner_url" text, + "bio" text, + "favorite_games" jsonb DEFAULT '[]'::jsonb NOT NULL, + "show_wallet_badge" boolean DEFAULT false NOT NULL, + "show_affiliate_badge" boolean DEFAULT false NOT NULL, + "show_collection_stats" boolean DEFAULT false NOT NULL, + "is_public" boolean DEFAULT true NOT NULL, + "opt_out_of_leaderboards" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_profiles_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text, + "email_verified_at" timestamp with time zone, + "username" text NOT NULL, + "password_hash" text, + "mfa_enabled" boolean DEFAULT false NOT NULL, + "mfa_secret_encrypted" text, + "primary_auth_method" "auth_method" NOT NULL, + "marketing_consent" boolean DEFAULT false NOT NULL, + "marketing_consent_at" timestamp with time zone, + "terms_accepted_version" text NOT NULL, + "terms_accepted_at" timestamp with time zone NOT NULL, + "privacy_accepted_version" text NOT NULL, + "responsible_purchasing_accepted_version" text NOT NULL, + "official_pack_rules_accepted_version" text NOT NULL, + "country" text NOT NULL, + "state_or_province" text, + "suspended_at" timestamp with time zone, + "suspended_reason" text, + "deletion_requested_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "wallet_identities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "chain" "chain" NOT NULL, + "address" text NOT NULL, + "network_mode" text NOT NULL, + "verified_at" timestamp with time zone NOT NULL, + "is_preferred_payment" boolean DEFAULT false NOT NULL, + "is_public" boolean DEFAULT false NOT NULL, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pack_tiers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "price_usdc_base_units" bigint NOT NULL, + "sort_order" integer NOT NULL, + "shipping_treatment" "shipping_treatment" NOT NULL, + "estimated_shipping_usdc_base_units" bigint DEFAULT 0 NOT NULL, + "card_games" jsonb DEFAULT '[]'::jsonb NOT NULL, + "min_disclosed_condition" text NOT NULL, + "procurement_price_cap_usdc_base_units" bigint NOT NULL, + "available_testnet" boolean DEFAULT false NOT NULL, + "available_algorand_mainnet" boolean DEFAULT false NOT NULL, + "available_solana" boolean DEFAULT false NOT NULL, + "available_evm" boolean DEFAULT false NOT NULL, + "requires_high_value_release_gate" boolean DEFAULT false NOT NULL, + "locked" boolean DEFAULT false NOT NULL, + "weekly_free_pack_eligible" boolean DEFAULT false NOT NULL, + "image_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pack_tiers_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "pool_entries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pool_version_id" uuid NOT NULL, + "card_game" "card_game" NOT NULL, + "card_name" text NOT NULL, + "set_name" text NOT NULL, + "card_number" text NOT NULL, + "finish" text NOT NULL, + "min_condition" text NOT NULL, + "grade_label" text, + "weight" bigint NOT NULL, + "probability_band_label" text NOT NULL, + "reference_value_usdc_base_units" bigint, + "reference_value_as_of" timestamp with time zone, + "supplier_listing_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pool_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pack_tier_id" uuid NOT NULL, + "version_label" text NOT NULL, + "is_promotional" boolean DEFAULT false NOT NULL, + "pool_hash" text NOT NULL, + "odds_hash" text NOT NULL, + "supplier_snapshot_hash" text, + "total_weight" bigint NOT NULL, + "card_count_total" integer NOT NULL, + "published_at" timestamp with time zone DEFAULT now() NOT NULL, + "archived_at" timestamp with time zone, + "is_immutable" boolean DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE "supplier_inventory_snapshots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "supplier_listing_id" uuid NOT NULL, + "snapshot_hash" text NOT NULL, + "price_usdc_base_units" bigint NOT NULL, + "quantity_available" integer NOT NULL, + "captured_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "supplier_listings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "supplier_id" uuid NOT NULL, + "external_listing_id" text NOT NULL, + "external_seller_id" text NOT NULL, + "card_game" "card_game" NOT NULL, + "card_name" text NOT NULL, + "set_name" text NOT NULL, + "card_number" text NOT NULL, + "language" text NOT NULL, + "finish" "card_finish" NOT NULL, + "condition" "card_condition" NOT NULL, + "grade_label" text, + "quantity_available" integer NOT NULL, + "price_usdc_base_units" bigint NOT NULL, + "seller_on_vacation" boolean DEFAULT false NOT NULL, + "ships_to_customer" boolean DEFAULT true NOT NULL, + "image_use_permitted" boolean DEFAULT false NOT NULL, + "seller_reliability_score" integer, + "last_refreshed_at" timestamp with time zone NOT NULL, + "is_eligible" boolean DEFAULT false NOT NULL, + "ineligibility_reasons" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "supplier_purchases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "supplier_id" uuid NOT NULL, + "supplier_listing_id" uuid NOT NULL, + "rip_id" uuid NOT NULL, + "idempotency_key" text NOT NULL, + "status" "supplier_purchase_status" DEFAULT 'queued' NOT NULL, + "expected_price_usdc_base_units" bigint NOT NULL, + "actual_price_usdc_base_units" bigint, + "external_order_id" text, + "cart_verification_log" jsonb DEFAULT '[]'::jsonb NOT NULL, + "failure_reason" text, + "queued_at" timestamp with time zone DEFAULT now() NOT NULL, + "purchased_at" timestamp with time zone, + CONSTRAINT "supplier_purchases_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "suppliers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "display_name" text NOT NULL, + "is_enabled" boolean DEFAULT false NOT NULL, + "mode" text DEFAULT 'mock' NOT NULL, + "health_status" text DEFAULT 'unknown' NOT NULL, + "last_health_check_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "suppliers_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "fairness_proofs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "rip_id" uuid NOT NULL, + "pool_version_id" uuid NOT NULL, + "pool_hash" text NOT NULL, + "odds_hash" text NOT NULL, + "server_seed_commitment" text NOT NULL, + "revealed_server_seed" text NOT NULL, + "client_nonce" text NOT NULL, + "payment_identifier" text NOT NULL, + "chain_randomness_input" text NOT NULL, + "combined_seed_hash" text NOT NULL, + "selection_roll" text NOT NULL, + "selected_pool_entry_id" uuid NOT NULL, + "algorithm_version" text DEFAULT 'packx402-fair-v1' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "fairness_proofs_rip_id_unique" UNIQUE("rip_id") +); +--> statement-breakpoint +CREATE TABLE "pack_offers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "pack_tier_id" uuid NOT NULL, + "pool_version_id" uuid NOT NULL, + "status" "pack_offer_status" DEFAULT 'DRAFT' NOT NULL, + "chain" "chain" NOT NULL, + "network_mode" text NOT NULL, + "price_usdc_base_units" bigint NOT NULL, + "shipping_usdc_base_units" bigint DEFAULT 0 NOT NULL, + "supplier_fees_usdc_base_units" bigint DEFAULT 0 NOT NULL, + "total_usdc_base_units" bigint NOT NULL, + "merchant_address" text NOT NULL, + "supplier_snapshot_hash" text, + "server_seed_commitment" text NOT NULL, + "server_seed_encrypted" text NOT NULL, + "client_nonce" text NOT NULL, + "is_free_pack" boolean DEFAULT false NOT NULL, + "free_pack_claim_id" uuid, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pack_offer_id" uuid NOT NULL, + "chain" "chain" NOT NULL, + "network_mode" text NOT NULL, + "status" "payment_status" DEFAULT 'pending' NOT NULL, + "expected_amount_usdc_base_units" bigint NOT NULL, + "settled_amount_usdc_base_units" bigint, + "payer_address" text, + "recipient_address" text NOT NULL, + "token_identifier" text NOT NULL, + "tx_hash_or_payment_id" text, + "facilitator_receipt_id" text, + "idempotency_key" text NOT NULL, + "settled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "payments_tx_hash_or_payment_id_unique" UNIQUE("tx_hash_or_payment_id"), + CONSTRAINT "payments_idempotency_key_unique" UNIQUE("idempotency_key") +); +--> statement-breakpoint +CREATE TABLE "rips" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pack_offer_id" uuid NOT NULL, + "payment_id" uuid NOT NULL, + "pool_entry_id" uuid NOT NULL, + "card_name" text NOT NULL, + "set_name" text NOT NULL, + "card_number" text NOT NULL, + "finish" text NOT NULL, + "condition" text NOT NULL, + "grade_label" text, + "reference_value_usdc_base_units" bigint, + "reference_value_as_of" timestamp with time zone, + "is_public" boolean DEFAULT false NOT NULL, + "opened_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "rips_pack_offer_id_unique" UNIQUE("pack_offer_id"), + CONSTRAINT "rips_payment_id_unique" UNIQUE("payment_id") +); +--> statement-breakpoint +CREATE TABLE "fulfillments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "rip_id" uuid NOT NULL, + "supplier_purchase_id" uuid, + "shipping_address_id" uuid NOT NULL, + "status" "fulfillment_status" DEFAULT 'opening_completed' NOT NULL, + "carrier" text, + "tracking_number" text, + "tracking_url" text, + "estimated_delivery_start" timestamp with time zone, + "estimated_delivery_end" timestamp with time zone, + "delivered_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "fulfillments_rip_id_unique" UNIQUE("rip_id") +); +--> statement-breakpoint +CREATE TABLE "tracking_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "fulfillment_id" uuid NOT NULL, + "status" "fulfillment_status" NOT NULL, + "note" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "free_pack_claims" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "free_pack_grant_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "pack_offer_id" uuid, + "shipping_acknowledged" boolean DEFAULT false NOT NULL, + "claimed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "free_pack_claims_free_pack_grant_id_unique" UNIQUE("free_pack_grant_id") +); +--> statement-breakpoint +CREATE TABLE "free_pack_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "pack_tier_id" uuid NOT NULL, + "promotional_pool_version_id" uuid NOT NULL, + "week_key" text NOT NULL, + "reason" text NOT NULL, + "loyalty_level_key" text, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "loyalty_calculations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "window_end" timestamp with time zone NOT NULL, + "eligible_fulfilled_spend_usdc_base_units" bigint NOT NULL, + "excluded_refunds_usdc_base_units" bigint DEFAULT 0 NOT NULL, + "excluded_affiliate_or_self_referral_usdc_base_units" bigint DEFAULT 0 NOT NULL, + "resulting_level_key" "loyalty_level_key" NOT NULL, + "previous_level_key" "loyalty_level_key", + "kill_switch_active" boolean DEFAULT false NOT NULL, + "input_order_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "calculated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "loyalty_levels" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" "loyalty_level_key" NOT NULL, + "name" text NOT NULL, + "min_spend_usdc_base_units" bigint NOT NULL, + "max_spend_usdc_base_units" bigint, + "weekly_reward_pack_tier_id" uuid, + "sort_order" integer NOT NULL, + "is_max_beta_level" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "loyalty_levels_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "referral_attributions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "referral_code_id" uuid NOT NULL, + "referred_user_id" uuid NOT NULL, + "status" "referral_attribution_status" DEFAULT 'pending' NOT NULL, + "rejection_reason" text, + "qualifying_order_id" uuid, + "reward_usdc_base_units" bigint, + "reward_approved_at" timestamp with time zone, + "reversed_at" timestamp with time zone, + "reversed_reason" text, + "requires_manual_review" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "referral_attributions_referred_user_id_unique" UNIQUE("referred_user_id") +); +--> statement-breakpoint +CREATE TABLE "referral_codes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "code" text NOT NULL, + "click_count" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "referral_codes_user_id_unique" UNIQUE("user_id"), + CONSTRAINT "referral_codes_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "affiliate_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "application_id" uuid NOT NULL, + "is_public_profile_enabled" boolean DEFAULT false NOT NULL, + "disclosure_text" text DEFAULT 'Paid partner of PackX402.' NOT NULL, + "suspended_at" timestamp with time zone, + "suspended_reason" text, + "monthly_commission_cap_usdc_base_units" bigint NOT NULL, + "attribution_window_days" integer DEFAULT 30 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "affiliate_accounts_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "affiliate_applications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "status" "affiliate_application_status" DEFAULT 'pending' NOT NULL, + "platform_description" text NOT NULL, + "audience_description" text NOT NULL, + "tax_info_status" text DEFAULT 'not_submitted' NOT NULL, + "reviewed_by_admin_id" uuid, + "reviewed_at" timestamp with time zone, + "rejection_reason" text, + "submitted_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "affiliate_campaigns" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "affiliate_account_id" uuid NOT NULL, + "code" text NOT NULL, + "label" text NOT NULL, + "monthly_cap_usdc_base_units" bigint, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "affiliate_campaigns_code_unique" UNIQUE("code") +); +--> statement-breakpoint +CREATE TABLE "affiliate_commissions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "affiliate_account_id" uuid NOT NULL, + "affiliate_campaign_id" uuid, + "order_id" uuid NOT NULL, + "status" "affiliate_commission_status" DEFAULT 'pending' NOT NULL, + "commission_usdc_base_units" bigint NOT NULL, + "pending_until" timestamp with time zone NOT NULL, + "approved_at" timestamp with time zone, + "reversed_at" timestamp with time zone, + "reversed_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "affiliate_payouts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "affiliate_account_id" uuid NOT NULL, + "total_usdc_base_units" bigint NOT NULL, + "approved_by_admin_id" uuid NOT NULL, + "payout_reference" text, + "paid_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "badges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "description" text NOT NULL, + "icon_url" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "badges_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "blocks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "blocker_id" uuid NOT NULL, + "blocked_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "challenge_completions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "challenge_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "completed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "name" text NOT NULL, + "description" text NOT NULL, + "requires_paid_purchase" boolean DEFAULT false NOT NULL, + "badge_reward_id" uuid, + "free_pack_entry_reward_eligible" boolean DEFAULT false NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "challenges_key_unique" UNIQUE("key") +); +--> statement-breakpoint +CREATE TABLE "club_members" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "club_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" text DEFAULT 'member' NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "clubs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "description" text, + "category" text NOT NULL, + "rules" text, + "requires_join_approval" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "clubs_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "body" text NOT NULL, + "removed_at" timestamp with time zone, + "removed_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "follows" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "follower_id" uuid NOT NULL, + "following_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notification_preferences" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "email_enabled" jsonb DEFAULT '{}'::jsonb NOT NULL, + "push_enabled" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "notification_preferences_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "kind" text NOT NULL, + "title" text NOT NULL, + "body" text, + "link_url" text, + "read_at" timestamp with time zone, + "email_sent" boolean DEFAULT false NOT NULL, + "push_sent" boolean DEFAULT false NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pull_posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "rip_id" uuid NOT NULL, + "caption" text, + "include_animation_replay" boolean DEFAULT false NOT NULL, + "affiliate_disclosure" boolean DEFAULT false NOT NULL, + "visibility" "social_visibility" DEFAULT 'public' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "reactions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "kind" text DEFAULT 'like' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "reports" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "reporter_id" uuid NOT NULL, + "target_type" text NOT NULL, + "target_id" uuid NOT NULL, + "reason" text NOT NULL, + "details" text, + "status" "report_status" DEFAULT 'open' NOT NULL, + "resolved_by_admin_id" uuid, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "showcase_cards" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "showcase_id" uuid NOT NULL, + "rip_id" uuid NOT NULL, + "sort_order" text DEFAULT '0' NOT NULL, + "added_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "showcases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "name" text NOT NULL, + "description" text, + "cover_image_url" text, + "visibility" "social_visibility" DEFAULT 'public' NOT NULL, + "comments_enabled" boolean DEFAULT true NOT NULL, + "followable" boolean DEFAULT true NOT NULL, + "slug" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "showcases_slug_unique" UNIQUE("slug") +); +--> statement-breakpoint +CREATE TABLE "social_posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "kind" text NOT NULL, + "body" text, + "ref_id" uuid, + "visibility" "social_visibility" DEFAULT 'public' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_badges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "badge_id" uuid NOT NULL, + "awarded_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "purchase_limits" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "daily_limit_usdc_base_units" bigint, + "weekly_limit_usdc_base_units" bigint, + "monthly_limit_usdc_base_units" bigint, + "pending_daily_limit_usdc_base_units" bigint, + "pending_weekly_limit_usdc_base_units" bigint, + "pending_monthly_limit_usdc_base_units" bigint, + "increase_effective_at" timestamp with time zone, + "cool_off_until" timestamp with time zone, + "paused_until" timestamp with time zone, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "purchase_limits_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "self_exclusions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "is_active" boolean DEFAULT false NOT NULL, + "reason" text, + "started_at" timestamp with time zone, + "ends_at" timestamp with time zone, + "lifted_by_admin_id" uuid, + "lifted_reason" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "self_exclusions_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "support_case_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "support_case_id" uuid NOT NULL, + "author_admin_id" uuid, + "author_user_id" uuid, + "is_internal" text DEFAULT 'false' NOT NULL, + "body" text NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "support_cases" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" "support_case_type" NOT NULL, + "status" "support_case_status" DEFAULT 'open' NOT NULL, + "subject" text NOT NULL, + "description" text NOT NULL, + "linked_payment_id" uuid, + "linked_supplier_order_id" uuid, + "assigned_admin_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "closed_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "audit_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "actor_type" "audit_event_actor_type" NOT NULL, + "actor_id" uuid, + "action" text NOT NULL, + "target_type" text, + "target_id" uuid, + "reason" text, + "correlation_id" text NOT NULL, + "before_state" jsonb, + "after_state" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "moderation_actions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "target_user_id" uuid NOT NULL, + "action_type" "moderation_action_type" NOT NULL, + "reason" text NOT NULL, + "report_id" uuid, + "moderator_admin_id" uuid NOT NULL, + "appeal_note" text, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "security_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "type" "security_event_type" NOT NULL, + "ip_hash" text, + "user_agent" text, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "admin_users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "role" text NOT NULL, + "reauthenticated_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "admin_users_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +CREATE TABLE "feature_flags" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "key" text NOT NULL, + "description" text NOT NULL, + "is_enabled" boolean DEFAULT false NOT NULL, + "config" jsonb DEFAULT '{}'::jsonb NOT NULL, + "updated_by_admin_id" uuid, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "feature_flags_key_unique" UNIQUE("key") +); +--> statement-breakpoint +ALTER TABLE "eligibility_records" ADD CONSTRAINT "eligibility_records_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "recovery_codes" ADD CONSTRAINT "recovery_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shipping_addresses" ADD CONSTRAINT "shipping_addresses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_profiles" ADD CONSTRAINT "user_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_identities" ADD CONSTRAINT "wallet_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pool_entries" ADD CONSTRAINT "pool_entries_pool_version_id_pool_versions_id_fk" FOREIGN KEY ("pool_version_id") REFERENCES "public"."pool_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pool_entries" ADD CONSTRAINT "pool_entries_supplier_listing_id_supplier_listings_id_fk" FOREIGN KEY ("supplier_listing_id") REFERENCES "public"."supplier_listings"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pool_versions" ADD CONSTRAINT "pool_versions_pack_tier_id_pack_tiers_id_fk" FOREIGN KEY ("pack_tier_id") REFERENCES "public"."pack_tiers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "supplier_inventory_snapshots" ADD CONSTRAINT "supplier_inventory_snapshots_supplier_listing_id_supplier_listings_id_fk" FOREIGN KEY ("supplier_listing_id") REFERENCES "public"."supplier_listings"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "supplier_listings" ADD CONSTRAINT "supplier_listings_supplier_id_suppliers_id_fk" FOREIGN KEY ("supplier_id") REFERENCES "public"."suppliers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "supplier_purchases" ADD CONSTRAINT "supplier_purchases_supplier_id_suppliers_id_fk" FOREIGN KEY ("supplier_id") REFERENCES "public"."suppliers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "supplier_purchases" ADD CONSTRAINT "supplier_purchases_supplier_listing_id_supplier_listings_id_fk" FOREIGN KEY ("supplier_listing_id") REFERENCES "public"."supplier_listings"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fairness_proofs" ADD CONSTRAINT "fairness_proofs_rip_id_rips_id_fk" FOREIGN KEY ("rip_id") REFERENCES "public"."rips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fairness_proofs" ADD CONSTRAINT "fairness_proofs_pool_version_id_pool_versions_id_fk" FOREIGN KEY ("pool_version_id") REFERENCES "public"."pool_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fairness_proofs" ADD CONSTRAINT "fairness_proofs_selected_pool_entry_id_pool_entries_id_fk" FOREIGN KEY ("selected_pool_entry_id") REFERENCES "public"."pool_entries"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pack_offers" ADD CONSTRAINT "pack_offers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pack_offers" ADD CONSTRAINT "pack_offers_pack_tier_id_pack_tiers_id_fk" FOREIGN KEY ("pack_tier_id") REFERENCES "public"."pack_tiers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pack_offers" ADD CONSTRAINT "pack_offers_pool_version_id_pool_versions_id_fk" FOREIGN KEY ("pool_version_id") REFERENCES "public"."pool_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_pack_offer_id_pack_offers_id_fk" FOREIGN KEY ("pack_offer_id") REFERENCES "public"."pack_offers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rips" ADD CONSTRAINT "rips_pack_offer_id_pack_offers_id_fk" FOREIGN KEY ("pack_offer_id") REFERENCES "public"."pack_offers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rips" ADD CONSTRAINT "rips_payment_id_payments_id_fk" FOREIGN KEY ("payment_id") REFERENCES "public"."payments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "rips" ADD CONSTRAINT "rips_pool_entry_id_pool_entries_id_fk" FOREIGN KEY ("pool_entry_id") REFERENCES "public"."pool_entries"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fulfillments" ADD CONSTRAINT "fulfillments_rip_id_rips_id_fk" FOREIGN KEY ("rip_id") REFERENCES "public"."rips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "fulfillments" ADD CONSTRAINT "fulfillments_supplier_purchase_id_supplier_purchases_id_fk" FOREIGN KEY ("supplier_purchase_id") REFERENCES "public"."supplier_purchases"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracking_events" ADD CONSTRAINT "tracking_events_fulfillment_id_fulfillments_id_fk" FOREIGN KEY ("fulfillment_id") REFERENCES "public"."fulfillments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "free_pack_claims" ADD CONSTRAINT "free_pack_claims_free_pack_grant_id_free_pack_grants_id_fk" FOREIGN KEY ("free_pack_grant_id") REFERENCES "public"."free_pack_grants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "free_pack_claims" ADD CONSTRAINT "free_pack_claims_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "free_pack_grants" ADD CONSTRAINT "free_pack_grants_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "free_pack_grants" ADD CONSTRAINT "free_pack_grants_pack_tier_id_pack_tiers_id_fk" FOREIGN KEY ("pack_tier_id") REFERENCES "public"."pack_tiers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "free_pack_grants" ADD CONSTRAINT "free_pack_grants_promotional_pool_version_id_pool_versions_id_fk" FOREIGN KEY ("promotional_pool_version_id") REFERENCES "public"."pool_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "loyalty_calculations" ADD CONSTRAINT "loyalty_calculations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "loyalty_levels" ADD CONSTRAINT "loyalty_levels_weekly_reward_pack_tier_id_pack_tiers_id_fk" FOREIGN KEY ("weekly_reward_pack_tier_id") REFERENCES "public"."pack_tiers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_attributions" ADD CONSTRAINT "referral_attributions_referral_code_id_referral_codes_id_fk" FOREIGN KEY ("referral_code_id") REFERENCES "public"."referral_codes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_attributions" ADD CONSTRAINT "referral_attributions_referred_user_id_users_id_fk" FOREIGN KEY ("referred_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_codes" ADD CONSTRAINT "referral_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_accounts" ADD CONSTRAINT "affiliate_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_accounts" ADD CONSTRAINT "affiliate_accounts_application_id_affiliate_applications_id_fk" FOREIGN KEY ("application_id") REFERENCES "public"."affiliate_applications"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_applications" ADD CONSTRAINT "affiliate_applications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_campaigns" ADD CONSTRAINT "affiliate_campaigns_affiliate_account_id_affiliate_accounts_id_fk" FOREIGN KEY ("affiliate_account_id") REFERENCES "public"."affiliate_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_commissions" ADD CONSTRAINT "affiliate_commissions_affiliate_account_id_affiliate_accounts_id_fk" FOREIGN KEY ("affiliate_account_id") REFERENCES "public"."affiliate_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_commissions" ADD CONSTRAINT "affiliate_commissions_affiliate_campaign_id_affiliate_campaigns_id_fk" FOREIGN KEY ("affiliate_campaign_id") REFERENCES "public"."affiliate_campaigns"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "affiliate_payouts" ADD CONSTRAINT "affiliate_payouts_affiliate_account_id_affiliate_accounts_id_fk" FOREIGN KEY ("affiliate_account_id") REFERENCES "public"."affiliate_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocker_id_users_id_fk" FOREIGN KEY ("blocker_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_id_users_id_fk" FOREIGN KEY ("blocked_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "challenge_completions" ADD CONSTRAINT "challenge_completions_challenge_id_challenges_id_fk" FOREIGN KEY ("challenge_id") REFERENCES "public"."challenges"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "challenge_completions" ADD CONSTRAINT "challenge_completions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "challenges" ADD CONSTRAINT "challenges_badge_reward_id_badges_id_fk" FOREIGN KEY ("badge_reward_id") REFERENCES "public"."badges"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "club_members" ADD CONSTRAINT "club_members_club_id_clubs_id_fk" FOREIGN KEY ("club_id") REFERENCES "public"."clubs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "club_members" ADD CONSTRAINT "club_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "follows" ADD CONSTRAINT "follows_follower_id_users_id_fk" FOREIGN KEY ("follower_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "follows" ADD CONSTRAINT "follows_following_id_users_id_fk" FOREIGN KEY ("following_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pull_posts" ADD CONSTRAINT "pull_posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pull_posts" ADD CONSTRAINT "pull_posts_rip_id_rips_id_fk" FOREIGN KEY ("rip_id") REFERENCES "public"."rips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reactions" ADD CONSTRAINT "reactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_users_id_fk" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "showcase_cards" ADD CONSTRAINT "showcase_cards_showcase_id_showcases_id_fk" FOREIGN KEY ("showcase_id") REFERENCES "public"."showcases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "showcase_cards" ADD CONSTRAINT "showcase_cards_rip_id_rips_id_fk" FOREIGN KEY ("rip_id") REFERENCES "public"."rips"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "showcases" ADD CONSTRAINT "showcases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "social_posts" ADD CONSTRAINT "social_posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_badges" ADD CONSTRAINT "user_badges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_badges" ADD CONSTRAINT "user_badges_badge_id_badges_id_fk" FOREIGN KEY ("badge_id") REFERENCES "public"."badges"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "purchase_limits" ADD CONSTRAINT "purchase_limits_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "self_exclusions" ADD CONSTRAINT "self_exclusions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_case_events" ADD CONSTRAINT "support_case_events_support_case_id_support_cases_id_fk" FOREIGN KEY ("support_case_id") REFERENCES "public"."support_cases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "support_cases" ADD CONSTRAINT "support_cases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "moderation_actions" ADD CONSTRAINT "moderation_actions_target_user_id_users_id_fk" FOREIGN KEY ("target_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "auth_nonces_expires_at_idx" ON "auth_nonces" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "eligibility_records_user_id_idx" ON "eligibility_records" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "recovery_codes_user_id_idx" ON "recovery_codes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "shipping_addresses_user_id_idx" ON "shipping_addresses" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "users_username_unique" ON "users" USING btree ("username");--> statement-breakpoint +CREATE UNIQUE INDEX "wallet_identities_chain_address_unique" ON "wallet_identities" USING btree ("chain","address");--> statement-breakpoint +CREATE INDEX "wallet_identities_user_id_idx" ON "wallet_identities" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "pool_entries_pool_version_id_idx" ON "pool_entries" USING btree ("pool_version_id");--> statement-breakpoint +CREATE UNIQUE INDEX "pool_versions_tier_label_unique" ON "pool_versions" USING btree ("pack_tier_id","version_label");--> statement-breakpoint +CREATE INDEX "pool_versions_pack_tier_id_idx" ON "pool_versions" USING btree ("pack_tier_id");--> statement-breakpoint +CREATE INDEX "supplier_inventory_snapshots_listing_id_idx" ON "supplier_inventory_snapshots" USING btree ("supplier_listing_id");--> statement-breakpoint +CREATE UNIQUE INDEX "supplier_listings_external_unique" ON "supplier_listings" USING btree ("supplier_id","external_listing_id");--> statement-breakpoint +CREATE INDEX "supplier_listings_supplier_id_idx" ON "supplier_listings" USING btree ("supplier_id");--> statement-breakpoint +CREATE INDEX "supplier_purchases_rip_id_idx" ON "supplier_purchases" USING btree ("rip_id");--> statement-breakpoint +CREATE INDEX "supplier_purchases_status_idx" ON "supplier_purchases" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "fairness_proofs_rip_id_unique" ON "fairness_proofs" USING btree ("rip_id");--> statement-breakpoint +CREATE INDEX "pack_offers_user_id_idx" ON "pack_offers" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "pack_offers_status_idx" ON "pack_offers" USING btree ("status");--> statement-breakpoint +CREATE INDEX "pack_offers_expires_at_idx" ON "pack_offers" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "payments_pack_offer_id_idx" ON "payments" USING btree ("pack_offer_id");--> statement-breakpoint +CREATE INDEX "payments_status_idx" ON "payments" USING btree ("status");--> statement-breakpoint +CREATE INDEX "rips_pool_entry_id_idx" ON "rips" USING btree ("pool_entry_id");--> statement-breakpoint +CREATE INDEX "fulfillments_rip_id_idx" ON "fulfillments" USING btree ("rip_id");--> statement-breakpoint +CREATE INDEX "fulfillments_status_idx" ON "fulfillments" USING btree ("status");--> statement-breakpoint +CREATE INDEX "tracking_events_fulfillment_id_idx" ON "tracking_events" USING btree ("fulfillment_id");--> statement-breakpoint +CREATE INDEX "free_pack_claims_user_id_idx" ON "free_pack_claims" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "free_pack_grants_user_week_unique" ON "free_pack_grants" USING btree ("user_id","week_key","reason");--> statement-breakpoint +CREATE INDEX "free_pack_grants_user_id_idx" ON "free_pack_grants" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "loyalty_calculations_user_id_idx" ON "loyalty_calculations" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "loyalty_calculations_calculated_at_idx" ON "loyalty_calculations" USING btree ("calculated_at");--> statement-breakpoint +CREATE UNIQUE INDEX "referral_attributions_referred_user_unique" ON "referral_attributions" USING btree ("referred_user_id");--> statement-breakpoint +CREATE INDEX "referral_attributions_referral_code_id_idx" ON "referral_attributions" USING btree ("referral_code_id");--> statement-breakpoint +CREATE INDEX "affiliate_campaigns_affiliate_account_id_idx" ON "affiliate_campaigns" USING btree ("affiliate_account_id");--> statement-breakpoint +CREATE INDEX "affiliate_commissions_affiliate_account_id_idx" ON "affiliate_commissions" USING btree ("affiliate_account_id");--> statement-breakpoint +CREATE INDEX "affiliate_commissions_status_idx" ON "affiliate_commissions" USING btree ("status");--> statement-breakpoint +CREATE INDEX "affiliate_payouts_affiliate_account_id_idx" ON "affiliate_payouts" USING btree ("affiliate_account_id");--> statement-breakpoint +CREATE UNIQUE INDEX "blocks_blocker_blocked_unique" ON "blocks" USING btree ("blocker_id","blocked_id");--> statement-breakpoint +CREATE UNIQUE INDEX "challenge_completions_challenge_user_unique" ON "challenge_completions" USING btree ("challenge_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "club_members_club_user_unique" ON "club_members" USING btree ("club_id","user_id");--> statement-breakpoint +CREATE INDEX "comments_target_idx" ON "comments" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE UNIQUE INDEX "follows_follower_following_unique" ON "follows" USING btree ("follower_id","following_id");--> statement-breakpoint +CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "notifications_read_at_idx" ON "notifications" USING btree ("read_at");--> statement-breakpoint +CREATE UNIQUE INDEX "pull_posts_rip_id_unique" ON "pull_posts" USING btree ("rip_id");--> statement-breakpoint +CREATE INDEX "pull_posts_user_id_idx" ON "pull_posts" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "reactions_user_target_unique" ON "reactions" USING btree ("user_id","target_type","target_id","kind");--> statement-breakpoint +CREATE INDEX "reports_target_idx" ON "reports" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE INDEX "reports_status_idx" ON "reports" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "showcase_cards_showcase_rip_unique" ON "showcase_cards" USING btree ("showcase_id","rip_id");--> statement-breakpoint +CREATE INDEX "showcases_user_id_idx" ON "showcases" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "social_posts_user_id_idx" ON "social_posts" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "user_badges_user_badge_unique" ON "user_badges" USING btree ("user_id","badge_id");--> statement-breakpoint +CREATE INDEX "self_exclusions_user_id_idx" ON "self_exclusions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "support_case_events_support_case_id_idx" ON "support_case_events" USING btree ("support_case_id");--> statement-breakpoint +CREATE INDEX "support_cases_user_id_idx" ON "support_cases" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "support_cases_status_idx" ON "support_cases" USING btree ("status");--> statement-breakpoint +CREATE INDEX "audit_events_actor_idx" ON "audit_events" USING btree ("actor_type","actor_id");--> statement-breakpoint +CREATE INDEX "audit_events_target_idx" ON "audit_events" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE INDEX "audit_events_correlation_id_idx" ON "audit_events" USING btree ("correlation_id");--> statement-breakpoint +CREATE INDEX "moderation_actions_target_user_id_idx" ON "moderation_actions" USING btree ("target_user_id");--> statement-breakpoint +CREATE INDEX "security_events_user_id_idx" ON "security_events" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "security_events_type_idx" ON "security_events" USING btree ("type"); \ No newline at end of file diff --git a/drizzle/0001_slimy_killraven.sql b/drizzle/0001_slimy_killraven.sql new file mode 100644 index 0000000..9aae2fd --- /dev/null +++ b/drizzle/0001_slimy_killraven.sql @@ -0,0 +1,14 @@ +ALTER TYPE "public"."auth_method" ADD VALUE 'google';--> statement-breakpoint +CREATE TABLE "oauth_identities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "provider" text NOT NULL, + "provider_account_id" text NOT NULL, + "email" text NOT NULL, + "verified_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "oauth_identities" ADD CONSTRAINT "oauth_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "oauth_identities_provider_account_unique" ON "oauth_identities" USING btree ("provider","provider_account_id");--> statement-breakpoint +CREATE INDEX "oauth_identities_user_id_idx" ON "oauth_identities" USING btree ("user_id"); \ No newline at end of file diff --git a/drizzle/0002_sturdy_synch.sql b/drizzle/0002_sturdy_synch.sql new file mode 100644 index 0000000..3ad5bc2 --- /dev/null +++ b/drizzle/0002_sturdy_synch.sql @@ -0,0 +1,5 @@ +CREATE TYPE "public"."rip_kind" AS ENUM('primary', 'bonus_flip');--> statement-breakpoint +ALTER TABLE "rips" DROP CONSTRAINT "rips_pack_offer_id_unique";--> statement-breakpoint +ALTER TABLE "rips" DROP CONSTRAINT "rips_payment_id_unique";--> statement-breakpoint +ALTER TABLE "rips" ADD COLUMN "kind" "rip_kind" DEFAULT 'primary' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "rips_pack_offer_id_kind_unique" ON "rips" USING btree ("pack_offer_id","kind"); \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..bffb1ca 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,19 @@ import type { NextConfig } from "next"; +// Every host here must also be in the resolver's own domain allowlist +// (src/server/card-images/resolver.ts's ALLOWED_IMAGE_HOSTS) — that's the security +// boundary; this is just what next/image needs to be told to actually fetch/optimize. const nextConfig: NextConfig = { - /* config options here */ + images: { + remotePatterns: [ + { protocol: "https", hostname: "images.pokemontcg.io" }, + { protocol: "https", hostname: "images.ygoprodeck.com" }, + { protocol: "https", hostname: "ygoprodeck.com" }, + { protocol: "https", hostname: "images.cardtrader.com" }, + { protocol: "https", hostname: "cardtrader.com" }, + { protocol: "https", hostname: "www.psacard.com" }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ae25e77..55e5a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,40 @@ { - "name": "pack402", + "name": "packx402", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "pack402", + "name": "packx402", "version": "0.1.0", "dependencies": { + "@agoralabs-sh/avm-web-provider": "^1.7.0", "@algorandfoundation/algokit-utils": "10.0.0-alpha.46", + "@blockshake/defly-connect": "^1.2.1", + "@perawallet/connect": "^1.6.0", "@phantom/react-sdk": "2.0.2", "@solana/web3.js": "1.98.4", "@txnlab/use-wallet": "4.6.0", "@txnlab/use-wallet-react": "4.6.0", + "@walletconnect/modal": "^2.7.0", + "@walletconnect/sign-client": "^2.23.10", "@x402/avm": "2.20.0", "@x402/core": "2.20.0", "algosdk": "3.6.0", + "dotenv": "17.4.2", "drizzle-orm": "0.45.2", "ioredis": "6.0.0", "jose": "6.2.7", + "lute-connect": "^1.7.0", "motion": "12.43.0", "nanoid": "6.0.0", "next": "16.2.12", + "next-auth": "^5.0.0-beta.32", "postgres": "3.4.9", "react": "19.2.4", "react-dom": "19.2.4", + "server-only": "0.0.1", + "tweetnacl": "1.0.3", "viem": "2.55.10", "x402": "1.2.0", "x402-next": "1.2.0", @@ -94,6 +104,32 @@ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", "license": "MIT" }, + "node_modules/@agoralabs-sh/avm-web-provider": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@agoralabs-sh/avm-web-provider/-/avm-web-provider-1.7.0.tgz", + "integrity": "sha512-AfMdBdFS3EW1RunzHVEwNFucS6UIALs661d+zL6AHFDdxOTvUgahRC4l163VoGWA0UEpnayT1jGObci8nitZeA==", + "license": "MIT", + "dependencies": { + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.20.3" + } + }, + "node_modules/@agoralabs-sh/avm-web-provider/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@algorandfoundation/algokit-utils": { "version": "10.0.0-alpha.46", "resolved": "https://registry.npmjs.org/@algorandfoundation/algokit-utils/-/algokit-utils-10.0.0-alpha.46.tgz", @@ -116,6 +152,18 @@ "node": ">=20.0" } }, + "node_modules/@algorandfoundation/algokit-utils/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@algorandfoundation/xhd-wallet-api": { "version": "2.0.0-canary.1", "resolved": "https://registry.npmjs.org/@algorandfoundation/xhd-wallet-api/-/xhd-wallet-api-2.0.0-canary.1.tgz", @@ -218,6 +266,45 @@ "node": "20 || >=22" } }, + "node_modules/@auth/core": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz", + "integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7 || ^8.0.5" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -570,6 +657,36 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@blockshake/defly-connect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@blockshake/defly-connect/-/defly-connect-1.2.1.tgz", + "integrity": "sha512-T9wAjPTFdc8iRiDzTqmeBRCIroWLgXmqZHwnpzuchjYZXXqbnj+zge+HS7UaNunpxGVjDUNK1ah7OR6hEqtJoQ==", + "license": "ISC", + "dependencies": { + "@likecoin/qr-code-styling": "^1.6.6", + "@walletconnect/client": "^1.8.0", + "@walletconnect/types": "^1.8.0", + "bowser": "2.11.0", + "buffer": "^6.0.3", + "lottie-web": "^5.12.2" + }, + "peerDependencies": { + "algosdk": "^3.0.0" + } + }, + "node_modules/@blockshake/defly-connect/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@blockshake/defly-connect/node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -2010,6 +2127,12 @@ "node": ">=14" } }, + "node_modules/@evanhahn/lottie-web-light": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/@evanhahn/lottie-web-light/-/lottie-web-light-5.8.1.tgz", + "integrity": "sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==", + "license": "MIT" + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -2686,6 +2809,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@likecoin/qr-code-styling": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@likecoin/qr-code-styling/-/qr-code-styling-1.6.6.tgz", + "integrity": "sha512-RbGK/+20bJhFZR70r8MeDvfyz3W7U5zXpykSTYOYxZGyo6wC+Y4QnbUpL+YdAtzT2ZIFeCNOcRs2W2FNrKPoaA==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.3" + } + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", @@ -3367,6 +3499,100 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@motionone/animation": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz", + "integrity": "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw==", + "license": "MIT", + "dependencies": { + "@motionone/easing": "^10.18.0", + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/dom": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/dom/-/dom-10.18.0.tgz", + "integrity": "sha512-bKLP7E0eyO4B2UaHBBN55tnppwRnaE3KFfh3Ps9HhnAkar3Cb69kUCJY9as8LrccVYKgHA+JY5dOQqJLOPhF5A==", + "license": "MIT", + "dependencies": { + "@motionone/animation": "^10.18.0", + "@motionone/generators": "^10.18.0", + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/easing": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/easing/-/easing-10.18.0.tgz", + "integrity": "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg==", + "license": "MIT", + "dependencies": { + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/generators": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/generators/-/generators-10.18.0.tgz", + "integrity": "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg==", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/svelte": { + "version": "10.16.4", + "resolved": "https://registry.npmjs.org/@motionone/svelte/-/svelte-10.16.4.tgz", + "integrity": "sha512-zRVqk20lD1xqe+yEDZhMYgftsuHc25+9JSo+r0a0OWUJFocjSV9D/+UGhX4xgJsuwB9acPzXLr20w40VnY2PQA==", + "license": "MIT", + "dependencies": { + "@motionone/dom": "^10.16.4", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/types": { + "version": "10.17.1", + "resolved": "https://registry.npmjs.org/@motionone/types/-/types-10.17.1.tgz", + "integrity": "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A==", + "license": "MIT" + }, + "node_modules/@motionone/utils": { + "version": "10.18.0", + "resolved": "https://registry.npmjs.org/@motionone/utils/-/utils-10.18.0.tgz", + "integrity": "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw==", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/vue": { + "version": "10.16.4", + "resolved": "https://registry.npmjs.org/@motionone/vue/-/vue-10.16.4.tgz", + "integrity": "sha512-z10PF9JV6SbjFq+/rYabM+8CVlMokgl8RFGvieSGNTmrkQanfHn+15XBrhG3BgUfvmTeSeyShfOHpG0i9zEdcg==", + "deprecated": "Motion One for Vue is deprecated. Use Oku Motion instead https://oku-ui.com/motion", + "license": "MIT", + "dependencies": { + "@motionone/dom": "^10.16.4", + "tslib": "^2.3.1" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@mysten/bcs": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/@mysten/bcs/-/bcs-0.11.1.tgz", @@ -3720,6 +3946,15 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@paulmillr/qr": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@paulmillr/qr/-/qr-0.2.1.tgz", @@ -3730,6 +3965,43 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@perawallet/connect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@perawallet/connect/-/connect-1.6.0.tgz", + "integrity": "sha512-lbOTCvHvlE06buRKIEx6pz0cOEpSoy0brUpOa6IWwNrVKihgSzOFxnYriqc8bUpwYjKDWKLi4bl5qJXcxRTeNw==", + "license": "ISC", + "dependencies": { + "@evanhahn/lottie-web-light": "5.8.1", + "@perawallet/walletconnect": "1.0.0", + "bowser": "2.11.0", + "buffer": "^6.0.3", + "qr-code-styling": "1.6.0-rc.1", + "tslib": "^2.8.1", + "tweetnacl-ts": "^1.0.3" + }, + "peerDependencies": { + "algosdk": "^3.5.2" + } + }, + "node_modules/@perawallet/connect/node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, + "node_modules/@perawallet/walletconnect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@perawallet/walletconnect/-/walletconnect-1.0.0.tgz", + "integrity": "sha512-y7nSd2OEyX9eLMtuZH3RXC083OQ6QY7Q90MN5WguuueShiiuuHu7Ow2ofRtD+y2Ts/FQDttqPTkD4LyKGwwk3Q==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@phantom/api-key-stamper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@phantom/api-key-stamper/-/api-key-stamper-2.0.2.tgz", @@ -11979,119 +12251,415 @@ "node": ">=22" } }, - "node_modules/@walletconnect/core": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.1.tgz", - "integrity": "sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==", + "node_modules/@walletconnect/browser-utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/browser-utils/-/browser-utils-1.8.0.tgz", + "integrity": "sha512-Wcqqx+wjxIo9fv6eBUFHPsW1y/bGWWRboni5dfD8PtOmrihrEpOCmvRJe4rfl7xgJW8Ea9UqKEaq0bIRLHlK4A==", "license": "Apache-2.0", "dependencies": { - "@walletconnect/heartbeat": "1.2.2", - "@walletconnect/jsonrpc-provider": "1.0.14", - "@walletconnect/jsonrpc-types": "1.0.4", - "@walletconnect/jsonrpc-utils": "1.0.8", - "@walletconnect/jsonrpc-ws-connection": "1.0.16", - "@walletconnect/keyvaluestorage": "1.1.1", - "@walletconnect/logger": "2.1.2", - "@walletconnect/relay-api": "1.0.11", - "@walletconnect/relay-auth": "1.1.0", - "@walletconnect/safe-json": "1.0.2", - "@walletconnect/time": "1.0.2", - "@walletconnect/types": "2.21.1", - "@walletconnect/utils": "2.21.1", - "@walletconnect/window-getters": "1.0.1", - "es-toolkit": "1.33.0", - "events": "3.3.0", - "uint8arrays": "3.1.0" - }, - "engines": { - "node": ">=18" + "@walletconnect/safe-json": "1.0.0", + "@walletconnect/types": "^1.8.0", + "@walletconnect/window-getters": "1.0.0", + "@walletconnect/window-metadata": "1.0.0", + "detect-browser": "5.2.0" } }, - "node_modules/@walletconnect/environment": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@walletconnect/environment/-/environment-1.0.1.tgz", - "integrity": "sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==", + "node_modules/@walletconnect/browser-utils/node_modules/@walletconnect/safe-json": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.0.tgz", + "integrity": "sha512-QJzp/S/86sUAgWY6eh5MKYmSfZaRpIlmCJdi5uG4DJlKkZrHEF7ye7gA+VtbVzvTtpM/gRwO2plQuiooIeXjfg==", + "license": "MIT" + }, + "node_modules/@walletconnect/browser-utils/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@walletconnect/browser-utils/node_modules/@walletconnect/window-getters": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@walletconnect/window-getters/-/window-getters-1.0.0.tgz", + "integrity": "sha512-xB0SQsLaleIYIkSsl43vm8EwETpBzJ2gnzk7e0wMF3ktqiTGS6TFHxcprMl5R44KKh4tCcHCJwolMCaDSwtAaA==", + "license": "MIT" + }, + "node_modules/@walletconnect/browser-utils/node_modules/@walletconnect/window-metadata": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@walletconnect/window-metadata/-/window-metadata-1.0.0.tgz", + "integrity": "sha512-9eFvmJxIKCC3YWOL97SgRkKhlyGXkrHwamfechmqszbypFspaSk+t2jQXAEU7YClHF6Qjw5eYOmy1//zFi9/GA==", "license": "MIT", "dependencies": { - "tslib": "1.14.1" + "@walletconnect/window-getters": "^1.0.0" } }, - "node_modules/@walletconnect/environment/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" + "node_modules/@walletconnect/browser-utils/node_modules/detect-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/detect-browser/-/detect-browser-5.2.0.tgz", + "integrity": "sha512-tr7XntDAu50BVENgQfajMLzacmSe34D+qZc4zjnniz0ZVuw/TZcLcyxHQjYpJTM36sGEkZZlYLnIM1hH7alTMA==", + "license": "MIT" }, - "node_modules/@walletconnect/ethereum-provider": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@walletconnect/ethereum-provider/-/ethereum-provider-2.21.1.tgz", - "integrity": "sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==", - "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "node_modules/@walletconnect/client": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/client/-/client-1.8.0.tgz", + "integrity": "sha512-svyBQ14NHx6Cs2j4TpkQaBI/2AF4+LXz64FojTjMtV4VMMhl81jSO1vNeg+yYhQzvjcGH/GpSwixjyCW0xFBOQ==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", "license": "Apache-2.0", "dependencies": { - "@reown/appkit": "1.7.8", - "@walletconnect/jsonrpc-http-connection": "1.0.8", - "@walletconnect/jsonrpc-provider": "1.0.14", - "@walletconnect/jsonrpc-types": "1.0.4", - "@walletconnect/jsonrpc-utils": "1.0.8", - "@walletconnect/keyvaluestorage": "1.1.1", - "@walletconnect/sign-client": "2.21.1", - "@walletconnect/types": "2.21.1", - "@walletconnect/universal-provider": "2.21.1", - "@walletconnect/utils": "2.21.1", - "events": "3.3.0" + "@walletconnect/core": "^1.8.0", + "@walletconnect/iso-crypto": "^1.8.0", + "@walletconnect/types": "^1.8.0", + "@walletconnect/utils": "^1.8.0" } }, - "node_modules/@walletconnect/events": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@walletconnect/events/-/events-1.0.1.tgz", - "integrity": "sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==", - "license": "MIT", + "node_modules/@walletconnect/client/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@walletconnect/client/node_modules/@walletconnect/utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-1.8.0.tgz", + "integrity": "sha512-zExzp8Mj1YiAIBfKNm5u622oNw44WOESzo6hj+Q3apSMIb0Jph9X3GDIdbZmvVZsNPxWDL7uodKgZcCInZv2vA==", + "license": "Apache-2.0", "dependencies": { - "keyvaluestorage-interface": "^1.0.0", - "tslib": "1.14.1" + "@walletconnect/browser-utils": "^1.8.0", + "@walletconnect/encoding": "^1.0.1", + "@walletconnect/jsonrpc-utils": "^1.0.3", + "@walletconnect/types": "^1.8.0", + "bn.js": "4.11.8", + "js-sha3": "0.8.0", + "query-string": "6.13.5" } }, - "node_modules/@walletconnect/events/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" + "node_modules/@walletconnect/client/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "license": "MIT" }, - "node_modules/@walletconnect/heartbeat": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@walletconnect/heartbeat/-/heartbeat-1.2.2.tgz", - "integrity": "sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==", + "node_modules/@walletconnect/client/node_modules/query-string": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.5.tgz", + "integrity": "sha512-svk3xg9qHR39P3JlHuD7g3nRnyay5mHbrPctEBDUxUkHRifPHXJDhBUycdCC0NBjXoDf44Gb+IsOZL1Uwn8M/Q==", "license": "MIT", "dependencies": { - "@walletconnect/events": "^1.0.1", - "@walletconnect/time": "^1.0.2", - "events": "^3.3.0" + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@walletconnect/jsonrpc-http-connection": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-http-connection/-/jsonrpc-http-connection-1.0.8.tgz", - "integrity": "sha512-+B7cRuaxijLeFDJUq5hAzNyef3e3tBDIxyaCNmFtjwnod5AGis3RToNqzFU33vpVcxFhofkpE7Cx+5MYejbMGw==", - "license": "MIT", + "node_modules/@walletconnect/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-1.8.0.tgz", + "integrity": "sha512-aFTHvEEbXcZ8XdWBw6rpQDte41Rxwnuk3SgTD8/iKGSRTni50gI9S3YEzMj05jozSiOBxQci4pJDMVhIUMtarw==", + "license": "Apache-2.0", "dependencies": { - "@walletconnect/jsonrpc-utils": "^1.0.6", - "@walletconnect/safe-json": "^1.0.1", - "cross-fetch": "^3.1.4", - "events": "^3.3.0" + "@walletconnect/socket-transport": "^1.8.0", + "@walletconnect/types": "^1.8.0", + "@walletconnect/utils": "^1.8.0" } }, - "node_modules/@walletconnect/jsonrpc-http-connection/node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "license": "MIT", + "node_modules/@walletconnect/core/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@walletconnect/core/node_modules/@walletconnect/utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-1.8.0.tgz", + "integrity": "sha512-zExzp8Mj1YiAIBfKNm5u622oNw44WOESzo6hj+Q3apSMIb0Jph9X3GDIdbZmvVZsNPxWDL7uodKgZcCInZv2vA==", + "license": "Apache-2.0", "dependencies": { - "node-fetch": "^2.7.0" + "@walletconnect/browser-utils": "^1.8.0", + "@walletconnect/encoding": "^1.0.1", + "@walletconnect/jsonrpc-utils": "^1.0.3", + "@walletconnect/types": "^1.8.0", + "bn.js": "4.11.8", + "js-sha3": "0.8.0", + "query-string": "6.13.5" } }, - "node_modules/@walletconnect/jsonrpc-provider": { - "version": "1.0.14", + "node_modules/@walletconnect/core/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "license": "MIT" + }, + "node_modules/@walletconnect/core/node_modules/query-string": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.5.tgz", + "integrity": "sha512-svk3xg9qHR39P3JlHuD7g3nRnyay5mHbrPctEBDUxUkHRifPHXJDhBUycdCC0NBjXoDf44Gb+IsOZL1Uwn8M/Q==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@walletconnect/crypto": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@walletconnect/crypto/-/crypto-1.1.0.tgz", + "integrity": "sha512-yZO8BBTQt7BcaemjDgwN56OmSv0OO4QjIpvtfj5OxZfL6IQZQWHOhwC6pJg+BmZPbDlJlWFqFuCZRtiPwRmsoA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "1.2.0", + "@noble/hashes": "1.7.0", + "@walletconnect/encoding": "^1.0.2", + "@walletconnect/environment": "^1.0.1", + "@walletconnect/randombytes": "^1.0.3", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/crypto/node_modules/@noble/ciphers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.0.tgz", + "integrity": "sha512-YGdEUzYEd+82jeaVbSKKVp1jFZb8LwaNMIIzHFkihGvYdd/KKAr7KaJHdEdSYGredE3ssSravXIa0Jxg28Sv5w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/crypto/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/crypto/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/encoding": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/encoding/-/encoding-1.0.2.tgz", + "integrity": "sha512-CrwSBrjqJ7rpGQcTL3kU+Ief+Bcuu9PH6JLOb+wM6NITX1GTxR/MfNwnQfhLKK6xpRAyj2/nM04OOH6wS8Imag==", + "license": "MIT", + "dependencies": { + "is-typedarray": "1.0.0", + "tslib": "1.14.1", + "typedarray-to-buffer": "3.1.5" + } + }, + "node_modules/@walletconnect/encoding/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/environment": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/environment/-/environment-1.0.1.tgz", + "integrity": "sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/environment/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/ethereum-provider": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/ethereum-provider/-/ethereum-provider-2.21.1.tgz", + "integrity": "sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@reown/appkit": "1.7.8", + "@walletconnect/jsonrpc-http-connection": "1.0.8", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/sign-client": "2.21.1", + "@walletconnect/types": "2.21.1", + "@walletconnect/universal-provider": "2.21.1", + "@walletconnect/utils": "2.21.1", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/@walletconnect/core": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.1.tgz", + "integrity": "sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@walletconnect/ethereum-provider/node_modules/@walletconnect/sign-client": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.1.tgz", + "integrity": "sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.1", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@walletconnect/events/-/events-1.0.1.tgz", + "integrity": "sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==", + "license": "MIT", + "dependencies": { + "keyvaluestorage-interface": "^1.0.0", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/events/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/heartbeat": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@walletconnect/heartbeat/-/heartbeat-1.2.2.tgz", + "integrity": "sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==", + "license": "MIT", + "dependencies": { + "@walletconnect/events": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/iso-crypto": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/iso-crypto/-/iso-crypto-1.8.0.tgz", + "integrity": "sha512-pWy19KCyitpfXb70hA73r9FcvklS+FvO9QUIttp3c2mfW8frxgYeRXfxLRCIQTkaYueRKvdqPjbyhPLam508XQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/crypto": "^1.0.2", + "@walletconnect/types": "^1.8.0", + "@walletconnect/utils": "^1.8.0" + } + }, + "node_modules/@walletconnect/iso-crypto/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@walletconnect/iso-crypto/node_modules/@walletconnect/utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-1.8.0.tgz", + "integrity": "sha512-zExzp8Mj1YiAIBfKNm5u622oNw44WOESzo6hj+Q3apSMIb0Jph9X3GDIdbZmvVZsNPxWDL7uodKgZcCInZv2vA==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/browser-utils": "^1.8.0", + "@walletconnect/encoding": "^1.0.1", + "@walletconnect/jsonrpc-utils": "^1.0.3", + "@walletconnect/types": "^1.8.0", + "bn.js": "4.11.8", + "js-sha3": "0.8.0", + "query-string": "6.13.5" + } + }, + "node_modules/@walletconnect/iso-crypto/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "license": "MIT" + }, + "node_modules/@walletconnect/iso-crypto/node_modules/query-string": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.5.tgz", + "integrity": "sha512-svk3xg9qHR39P3JlHuD7g3nRnyay5mHbrPctEBDUxUkHRifPHXJDhBUycdCC0NBjXoDf44Gb+IsOZL1Uwn8M/Q==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@walletconnect/jsonrpc-http-connection": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-http-connection/-/jsonrpc-http-connection-1.0.8.tgz", + "integrity": "sha512-+B7cRuaxijLeFDJUq5hAzNyef3e3tBDIxyaCNmFtjwnod5AGis3RToNqzFU33vpVcxFhofkpE7Cx+5MYejbMGw==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-utils": "^1.0.6", + "@walletconnect/safe-json": "^1.0.1", + "cross-fetch": "^3.1.4", + "events": "^3.3.0" + } + }, + "node_modules/@walletconnect/jsonrpc-http-connection/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@walletconnect/jsonrpc-provider": { + "version": "1.0.14", "resolved": "https://registry.npmjs.org/@walletconnect/jsonrpc-provider/-/jsonrpc-provider-1.0.14.tgz", "integrity": "sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==", "license": "MIT", @@ -12280,101 +12848,621 @@ "ioredis": { "optional": true }, - "uploadthing": { - "optional": true + "uploadthing": { + "optional": true + } + } + }, + "node_modules/@walletconnect/logger": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@walletconnect/logger/-/logger-2.1.2.tgz", + "integrity": "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.2", + "pino": "7.11.0" + } + }, + "node_modules/@walletconnect/modal": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@walletconnect/modal/-/modal-2.7.0.tgz", + "integrity": "sha512-RQVt58oJ+rwqnPcIvRFeMGKuXb9qkgSmwz4noF8JZGUym3gUAzVs+uW2NQ1Owm9XOJAV+sANrtJ+VoVq1ftElw==", + "deprecated": "Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/modal-core": "2.7.0", + "@walletconnect/modal-ui": "2.7.0" + } + }, + "node_modules/@walletconnect/modal-core": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@walletconnect/modal-core/-/modal-core-2.7.0.tgz", + "integrity": "sha512-oyMIfdlNdpyKF2kTJowTixZSo0PGlCJRdssUN/EZdA6H6v03hZnf09JnwpljZNfir2M65Dvjm/15nGrDQnlxSA==", + "license": "Apache-2.0", + "dependencies": { + "valtio": "1.11.2" + } + }, + "node_modules/@walletconnect/modal-core/node_modules/proxy-compare": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.5.1.tgz", + "integrity": "sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==", + "license": "MIT" + }, + "node_modules/@walletconnect/modal-core/node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@walletconnect/modal-core/node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@walletconnect/modal-core/node_modules/valtio": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/valtio/-/valtio-1.11.2.tgz", + "integrity": "sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw==", + "license": "MIT", + "dependencies": { + "proxy-compare": "2.5.1", + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@walletconnect/modal-ui": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@walletconnect/modal-ui/-/modal-ui-2.7.0.tgz", + "integrity": "sha512-gERYvU7D7K1ANCN/8vUgsE0d2hnRemfAFZ2novm9aZBg7TEd/4EgB+AqbJ+1dc7GhOL6dazckVq78TgccHb7mQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/modal-core": "2.7.0", + "lit": "2.8.0", + "motion": "10.16.2", + "qrcode": "1.5.3" + } + }, + "node_modules/@walletconnect/modal-ui/node_modules/@lit/reactive-element": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz", + "integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.0.0" + } + }, + "node_modules/@walletconnect/modal-ui/node_modules/lit": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz", + "integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.6.0", + "lit-element": "^3.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/@walletconnect/modal-ui/node_modules/lit-element": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz", + "integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.1.0", + "@lit/reactive-element": "^1.3.0", + "lit-html": "^2.8.0" + } + }, + "node_modules/@walletconnect/modal-ui/node_modules/lit-html": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz", + "integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/@walletconnect/modal-ui/node_modules/motion": { + "version": "10.16.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-10.16.2.tgz", + "integrity": "sha512-p+PurYqfUdcJZvtnmAqu5fJgV2kR0uLFQuBKtLeFVTrYEVllI99tiOTSefVNYuip9ELTEkepIIDftNdze76NAQ==", + "license": "MIT", + "dependencies": { + "@motionone/animation": "^10.15.1", + "@motionone/dom": "^10.16.2", + "@motionone/svelte": "^10.16.2", + "@motionone/types": "^10.15.1", + "@motionone/utils": "^10.15.1", + "@motionone/vue": "^10.16.2" + } + }, + "node_modules/@walletconnect/randombytes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@walletconnect/randombytes/-/randombytes-1.1.0.tgz", + "integrity": "sha512-X+LO/9ClnXX2Q/1+u83qMnohVaxC4qsXByM/gMSwGMrUObxEiqEWS+b9Upg9oNl6mTr85dTCRF8W17KVcKKXQw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.0", + "@walletconnect/encoding": "^1.0.2", + "@walletconnect/environment": "^1.0.1", + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/randombytes/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/randombytes/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/relay-api": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-api/-/relay-api-1.0.11.tgz", + "integrity": "sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==", + "license": "MIT", + "dependencies": { + "@walletconnect/jsonrpc-types": "^1.0.2" + } + }, + "node_modules/@walletconnect/relay-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@walletconnect/relay-auth/-/relay-auth-1.1.0.tgz", + "integrity": "sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.8.0", + "@noble/hashes": "1.7.0", + "@walletconnect/safe-json": "^1.0.1", + "@walletconnect/time": "^1.0.2", + "uint8arrays": "^3.0.0" + } + }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", + "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", + "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/safe-json": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.2.tgz", + "integrity": "sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==", + "license": "MIT", + "dependencies": { + "tslib": "1.14.1" + } + }, + "node_modules/@walletconnect/safe-json/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@walletconnect/sign-client": { + "version": "2.23.10", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.23.10.tgz", + "integrity": "sha512-vO7DGRRmKo+rykmjVyQR1aM4I2nbk9kJ6olbxgjFRR6Jdhy+Kz+zgN7Ce5xVhPfWYVu4bV/XhOQxhvnQw7S5ng==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/core": "2.23.10", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "3.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.23.10", + "@walletconnect/utils": "2.23.10", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@walletconnect/core": { + "version": "2.23.10", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.23.10.tgz", + "integrity": "sha512-Qq2btHEoCgruvkZCWLSrVsvg/dYbM9Z045qeClwhJR4meL32jbIRT0mKWjf0HkRc2LA82MsnszVnfuZl3yWl5A==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.23.10", + "@walletconnect/utils": "2.23.10", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.45.1", + "events": "3.3.0", + "uint8arrays": "3.1.1" + }, + "engines": { + "node": ">=18.20.8" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@walletconnect/logger": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@walletconnect/logger/-/logger-3.0.2.tgz", + "integrity": "sha512-7wR3wAwJTOmX4gbcUZcFMov8fjftY05+5cO/d4cpDD8wDzJ+cIlKdYOXaXfxHLSYeDazMXIsxMYjHYVDfkx+nA==", + "license": "MIT", + "dependencies": { + "@walletconnect/safe-json": "^1.0.2", + "pino": "10.0.0" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@walletconnect/types": { + "version": "2.23.10", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-2.23.10.tgz", + "integrity": "sha512-XP8d41979anTrc1OJF3ISF+g81cvp1wim+ObdNnbcaT/jhwLwv+0T7rRe9VwRv+h8EaRgLyeb5YGy7oJ49vxVg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "events": "3.3.0" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/@walletconnect/utils": { + "version": "2.23.10", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.23.10.tgz", + "integrity": "sha512-b1c9FRF2g7vNnz66oLW5WZD2VCMrbu9xhpmwJJwqGarBiGW7cY8NbUtS9/w2/qc0vsBVKJ/bzDn4TGjpELU6aQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@msgpack/msgpack": "3.1.3", + "@noble/ciphers": "1.3.0", + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "3.0.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.23.10", + "@walletconnect/window-getters": "1.0.1", + "@walletconnect/window-metadata": "1.0.1", + "blakejs": "1.2.1", + "detect-browser": "5.3.0", + "ox": "0.9.3", + "uint8arrays": "3.1.1" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/@walletconnect/sign-client/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@walletconnect/sign-client/node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/ox": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.9.3.tgz", + "integrity": "sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.0.9", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@walletconnect/sign-client/node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/pino": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.0.0.tgz", + "integrity": "sha512-eI9pKwWEix40kfvSzqEP6ldqOoBIN7dwD/o91TY5z8vQI12sAffpR/pOqAD1IVVwIVHDpHjkq0joBPdJD0rafA==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "slow-redact": "^0.3.0", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/@walletconnect/sign-client/node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/@walletconnect/sign-client/node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } + ], + "license": "MIT" + }, + "node_modules/@walletconnect/sign-client/node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" } }, - "node_modules/@walletconnect/logger": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@walletconnect/logger/-/logger-2.1.2.tgz", - "integrity": "sha512-aAb28I3S6pYXZHQm5ESB+V6rDqIYfsnHaQyzFbwUUBFY4H0OXx/YtTl8lvhUNhMMfb9UxbwEBS253TlXUYJWSw==", + "node_modules/@walletconnect/sign-client/node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "license": "MIT", "dependencies": { - "@walletconnect/safe-json": "^1.0.2", - "pino": "7.11.0" + "atomic-sleep": "^1.0.0" } }, - "node_modules/@walletconnect/relay-api": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@walletconnect/relay-api/-/relay-api-1.0.11.tgz", - "integrity": "sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==", + "node_modules/@walletconnect/sign-client/node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", "license": "MIT", "dependencies": { - "@walletconnect/jsonrpc-types": "^1.0.2" + "real-require": "^0.2.0" } }, - "node_modules/@walletconnect/relay-auth": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@walletconnect/relay-auth/-/relay-auth-1.1.0.tgz", - "integrity": "sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==", + "node_modules/@walletconnect/sign-client/node_modules/uint8arrays": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz", + "integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==", "license": "MIT", "dependencies": { - "@noble/curves": "1.8.0", - "@noble/hashes": "1.7.0", - "@walletconnect/safe-json": "^1.0.1", - "@walletconnect/time": "^1.0.2", - "uint8arrays": "^3.0.0" + "multiformats": "^9.4.2" } }, - "node_modules/@walletconnect/relay-auth/node_modules/@noble/curves": { + "node_modules/@walletconnect/socket-transport": { "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.0.tgz", - "integrity": "sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==", + "resolved": "https://registry.npmjs.org/@walletconnect/socket-transport/-/socket-transport-1.8.0.tgz", + "integrity": "sha512-5DyIyWrzHXTcVp0Vd93zJ5XMW61iDM6bcWT4p8DTRfFsOtW46JquruMhxOLeCOieM4D73kcr3U7WtyR4JUsGuQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/types": "^1.8.0", + "@walletconnect/utils": "^1.8.0", + "ws": "7.5.3" + } + }, + "node_modules/@walletconnect/socket-transport/node_modules/@walletconnect/types": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/types/-/types-1.8.0.tgz", + "integrity": "sha512-Cn+3I0V0vT9ghMuzh1KzZvCkiAxTq+1TR2eSqw5E5AVWfmCtECFkVZBP6uUJZ8YjwLqXheI+rnjqPy7sVM4Fyg==", + "deprecated": "WalletConnect's v1 SDKs are now deprecated. Please upgrade to a v2 SDK. For details see: https://docs.walletconnect.com/", + "license": "Apache-2.0" + }, + "node_modules/@walletconnect/socket-transport/node_modules/@walletconnect/utils": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-1.8.0.tgz", + "integrity": "sha512-zExzp8Mj1YiAIBfKNm5u622oNw44WOESzo6hj+Q3apSMIb0Jph9X3GDIdbZmvVZsNPxWDL7uodKgZcCInZv2vA==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/browser-utils": "^1.8.0", + "@walletconnect/encoding": "^1.0.1", + "@walletconnect/jsonrpc-utils": "^1.0.3", + "@walletconnect/types": "^1.8.0", + "bn.js": "4.11.8", + "js-sha3": "0.8.0", + "query-string": "6.13.5" + } + }, + "node_modules/@walletconnect/socket-transport/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "license": "MIT" + }, + "node_modules/@walletconnect/socket-transport/node_modules/query-string": { + "version": "6.13.5", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.5.tgz", + "integrity": "sha512-svk3xg9qHR39P3JlHuD7g3nRnyay5mHbrPctEBDUxUkHRifPHXJDhBUycdCC0NBjXoDf44Gb+IsOZL1Uwn8M/Q==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.7.0" + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" }, "engines": { - "node": "^14.21.3 || >=16" + "node": ">=6" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@walletconnect/relay-auth/node_modules/@noble/hashes": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.0.tgz", - "integrity": "sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==", + "node_modules/@walletconnect/socket-transport/node_modules/ws": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.3.tgz", + "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", "license": "MIT", "engines": { - "node": "^14.21.3 || >=16" + "node": ">=8.3.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@walletconnect/safe-json": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@walletconnect/safe-json/-/safe-json-1.0.2.tgz", - "integrity": "sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==", - "license": "MIT", - "dependencies": { - "tslib": "1.14.1" - } - }, - "node_modules/@walletconnect/safe-json/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD" - }, - "node_modules/@walletconnect/sign-client": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.1.tgz", - "integrity": "sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==", - "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", - "license": "Apache-2.0", - "dependencies": { - "@walletconnect/core": "2.21.1", - "@walletconnect/events": "1.0.1", - "@walletconnect/heartbeat": "1.2.2", - "@walletconnect/jsonrpc-utils": "1.0.8", - "@walletconnect/logger": "2.1.2", - "@walletconnect/time": "1.0.2", - "@walletconnect/types": "2.21.1", - "@walletconnect/utils": "2.21.1", - "events": "3.3.0" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, "node_modules/@walletconnect/time": { @@ -12427,6 +13515,52 @@ "events": "3.3.0" } }, + "node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/core": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/core/-/core-2.21.1.tgz", + "integrity": "sha512-Tp4MHJYcdWD846PH//2r+Mu4wz1/ZU/fr9av1UWFiaYQ2t2TPLDiZxjLw54AAEpMqlEHemwCgiRiAmjR1NDdTQ==", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-provider": "1.0.14", + "@walletconnect/jsonrpc-types": "1.0.4", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/jsonrpc-ws-connection": "1.0.16", + "@walletconnect/keyvaluestorage": "1.1.1", + "@walletconnect/logger": "2.1.2", + "@walletconnect/relay-api": "1.0.11", + "@walletconnect/relay-auth": "1.1.0", + "@walletconnect/safe-json": "1.0.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "@walletconnect/window-getters": "1.0.1", + "es-toolkit": "1.33.0", + "events": "3.3.0", + "uint8arrays": "3.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@walletconnect/universal-provider/node_modules/@walletconnect/sign-client": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@walletconnect/sign-client/-/sign-client-2.21.1.tgz", + "integrity": "sha512-QaXzmPsMnKGV6tc4UcdnQVNOz4zyXgarvdIQibJ4L3EmLat73r5ZVl4c0cCOcoaV7rgM9Wbphgu5E/7jNcd3Zg==", + "deprecated": "Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases", + "license": "Apache-2.0", + "dependencies": { + "@walletconnect/core": "2.21.1", + "@walletconnect/events": "1.0.1", + "@walletconnect/heartbeat": "1.2.2", + "@walletconnect/jsonrpc-utils": "1.0.8", + "@walletconnect/logger": "2.1.2", + "@walletconnect/time": "1.0.2", + "@walletconnect/types": "2.21.1", + "@walletconnect/utils": "2.21.1", + "events": "3.3.0" + } + }, "node_modules/@walletconnect/utils": { "version": "2.21.1", "resolved": "https://registry.npmjs.org/@walletconnect/utils/-/utils-2.21.1.tgz", @@ -13254,6 +14388,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "5.2.5", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", @@ -14086,9 +15226,9 @@ "peer": true }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -16516,6 +17656,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" + }, "node_modules/hi-base32": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", @@ -17120,6 +18266,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -17321,7 +18473,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -17900,7 +19051,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -17909,6 +19059,12 @@ "loose-envify": "cli.js" } }, + "node_modules/lottie-web": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/lottie-web/-/lottie-web-5.13.0.tgz", + "integrity": "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -17919,6 +19075,12 @@ "yallist": "^3.0.2" } }, + "node_modules/lute-connect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lute-connect/-/lute-connect-1.7.0.tgz", + "integrity": "sha512-/eXb2/c/xltKyVEVWchd1QZB6F0fvgXwVIqXDQWeJ9unPo0kMMbtuLkeb1v4Kr1lffxX8uGnb+8kAMYjczUASg==", + "license": "ISC" + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -18218,6 +19380,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.32", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz", + "integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7 || ^8.0.5", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -18373,6 +19562,15 @@ "node": ">=0.10.0" } }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/obj-multiplex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/obj-multiplex/-/obj-multiplex-1.0.0.tgz", @@ -19144,6 +20342,15 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -19354,6 +20561,15 @@ "node": ">=6" } }, + "node_modules/qr-code-styling": { + "version": "1.6.0-rc.1", + "resolved": "https://registry.npmjs.org/qr-code-styling/-/qr-code-styling-1.6.0-rc.1.tgz", + "integrity": "sha512-ModRIiW6oUnsP18QzrRYZSc/CFKFKIdj7pUs57AEVH20ajlglRpN3HukjHk0UbNMTlKGuaYl7Gt6/O5Gg2NU2Q==", + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.3" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -19372,6 +20588,12 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode-generator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.5.2.tgz", + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", + "license": "MIT" + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -19848,6 +21070,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -20086,6 +21314,12 @@ "dev": true, "license": "ISC" }, + "node_modules/slow-redact": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/slow-redact/-/slow-redact-0.3.2.tgz", + "integrity": "sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==", + "license": "MIT" + }, "node_modules/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -20766,6 +22000,21 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "license": "Unlicense" }, + "node_modules/tweetnacl-ts": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl-ts/-/tweetnacl-ts-1.0.3.tgz", + "integrity": "sha512-C5I/dWf6xjAXaCDlf84T4HvozU/8ycAlq5WRllF1hAeeq5390tfXD+bNas5bhEV0HMSOx8bsQYpLjPl8wfnEeQ==", + "license": "UNLICENSED", + "dependencies": { + "tslib": "^1" + } + }, + "node_modules/tweetnacl-ts/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -20856,6 +22105,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typeforce": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", diff --git a/package.json b/package.json index 3a647fb..1d64eb6 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "pack402", + "name": "packx402", "version": "0.1.0", "private": true, "scripts": { @@ -16,49 +16,60 @@ "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/server/db/migrate.ts", "db:studio": "drizzle-kit studio", - "db:seed": "tsx src/server/db/seed.ts" + "db:seed": "tsx src/server/db/seed.ts", + "worker:supplier-purchases": "tsx src/server/suppliers/run-worker.ts" }, "dependencies": { - "next": "16.2.12", - "react": "19.2.4", - "react-dom": "19.2.4", - "drizzle-orm": "0.45.2", - "postgres": "3.4.9", - "zod": "3.25.76", - "motion": "12.43.0", - "@x402/core": "2.20.0", - "@x402/avm": "2.20.0", + "@agoralabs-sh/avm-web-provider": "^1.7.0", "@algorandfoundation/algokit-utils": "10.0.0-alpha.46", - "algosdk": "3.6.0", + "@blockshake/defly-connect": "^1.2.1", + "@perawallet/connect": "^1.6.0", + "@phantom/react-sdk": "2.0.2", + "@solana/web3.js": "1.98.4", "@txnlab/use-wallet": "4.6.0", "@txnlab/use-wallet-react": "4.6.0", - "@solana/web3.js": "1.98.4", - "@phantom/react-sdk": "2.0.2", + "@walletconnect/modal": "^2.7.0", + "@walletconnect/sign-client": "^2.23.10", + "@x402/avm": "2.20.0", + "@x402/core": "2.20.0", + "algosdk": "3.6.0", + "dotenv": "17.4.2", + "drizzle-orm": "0.45.2", + "ioredis": "6.0.0", + "jose": "6.2.7", + "lute-connect": "^1.7.0", + "motion": "12.43.0", + "nanoid": "6.0.0", + "next": "16.2.12", + "next-auth": "^5.0.0-beta.32", + "postgres": "3.4.9", + "react": "19.2.4", + "react-dom": "19.2.4", + "server-only": "0.0.1", + "tweetnacl": "1.0.3", "viem": "2.55.10", "x402": "1.2.0", "x402-next": "1.2.0", - "ioredis": "6.0.0", - "jose": "6.2.7", - "nanoid": "6.0.0" + "zod": "3.25.76" }, "devDependencies": { + "@playwright/test": "1.62.1", "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "7.0.0", + "@testing-library/react": "16.3.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "6.0.5", + "drizzle-kit": "0.31.10", "eslint": "^9", "eslint-config-next": "16.2.12", - "tailwindcss": "^4", - "typescript": "^5", + "jsdom": "30.0.1", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.9", - "drizzle-kit": "0.31.10", + "tailwindcss": "^4", "tsx": "4.23.1", - "vitest": "4.1.10", - "@vitejs/plugin-react": "6.0.5", - "jsdom": "30.0.1", - "@testing-library/react": "16.3.2", - "@testing-library/jest-dom": "7.0.0", - "@playwright/test": "1.62.1" + "typescript": "^5", + "vitest": "4.1.10" } } diff --git a/public/packs/bronze-open.png b/public/packs/bronze-open.png new file mode 100644 index 0000000..a480a23 Binary files /dev/null and b/public/packs/bronze-open.png differ diff --git a/public/packs/bronze.png b/public/packs/bronze.png new file mode 100644 index 0000000..7a7542c Binary files /dev/null and b/public/packs/bronze.png differ diff --git a/public/packs/gold-open.png b/public/packs/gold-open.png new file mode 100644 index 0000000..69409c0 Binary files /dev/null and b/public/packs/gold-open.png differ diff --git a/public/packs/gold.png b/public/packs/gold.png new file mode 100644 index 0000000..da1ad3b Binary files /dev/null and b/public/packs/gold.png differ diff --git a/public/packs/mythic.png b/public/packs/mythic.png new file mode 100644 index 0000000..b7f3a96 Binary files /dev/null and b/public/packs/mythic.png differ diff --git a/public/packs/obsidian.png b/public/packs/obsidian.png new file mode 100644 index 0000000..461f895 Binary files /dev/null and b/public/packs/obsidian.png differ diff --git a/public/packs/platinum.png b/public/packs/platinum.png new file mode 100644 index 0000000..cbca7b1 Binary files /dev/null and b/public/packs/platinum.png differ diff --git a/public/packs/prism-open.png b/public/packs/prism-open.png new file mode 100644 index 0000000..24e2637 Binary files /dev/null and b/public/packs/prism-open.png differ diff --git a/public/packs/prism.png b/public/packs/prism.png new file mode 100644 index 0000000..a68f506 Binary files /dev/null and b/public/packs/prism.png differ diff --git a/public/packs/scout-open.png b/public/packs/scout-open.png new file mode 100644 index 0000000..70f4a96 Binary files /dev/null and b/public/packs/scout-open.png differ diff --git a/public/packs/scout.png b/public/packs/scout.png new file mode 100644 index 0000000..3d25e0d Binary files /dev/null and b/public/packs/scout.png differ diff --git a/public/packs/silver-open.png b/public/packs/silver-open.png new file mode 100644 index 0000000..195267c Binary files /dev/null and b/public/packs/silver-open.png differ diff --git a/public/packs/silver.png b/public/packs/silver.png new file mode 100644 index 0000000..da90b7a Binary files /dev/null and b/public/packs/silver.png differ diff --git a/public/packs/spark-open.png b/public/packs/spark-open.png new file mode 100644 index 0000000..98c8cd4 Binary files /dev/null and b/public/packs/spark-open.png differ diff --git a/public/packs/spark.png b/public/packs/spark.png new file mode 100644 index 0000000..8361755 Binary files /dev/null and b/public/packs/spark.png differ diff --git a/public/packs/starter-open.png b/public/packs/starter-open.png new file mode 100644 index 0000000..ed944c8 Binary files /dev/null and b/public/packs/starter-open.png differ diff --git a/public/packs/starter.png b/public/packs/starter.png new file mode 100644 index 0000000..f38db0d Binary files /dev/null and b/public/packs/starter.png differ diff --git a/public/video/open/bronze.mp4 b/public/video/open/bronze.mp4 new file mode 100644 index 0000000..463cd6b Binary files /dev/null and b/public/video/open/bronze.mp4 differ diff --git a/public/video/open/gold.mp4 b/public/video/open/gold.mp4 new file mode 100644 index 0000000..3c3424d Binary files /dev/null and b/public/video/open/gold.mp4 differ diff --git a/public/video/open/prism.mp4 b/public/video/open/prism.mp4 new file mode 100644 index 0000000..61879c7 Binary files /dev/null and b/public/video/open/prism.mp4 differ diff --git a/public/video/open/scout.mp4 b/public/video/open/scout.mp4 new file mode 100644 index 0000000..8318625 Binary files /dev/null and b/public/video/open/scout.mp4 differ diff --git a/public/video/open/silver.mp4 b/public/video/open/silver.mp4 new file mode 100644 index 0000000..71cb81c Binary files /dev/null and b/public/video/open/silver.mp4 differ diff --git a/public/video/open/spark.mp4 b/public/video/open/spark.mp4 new file mode 100644 index 0000000..75afb27 Binary files /dev/null and b/public/video/open/spark.mp4 differ diff --git a/public/video/open/starter.mp4 b/public/video/open/starter.mp4 new file mode 100644 index 0000000..c7846c3 Binary files /dev/null and b/public/video/open/starter.mp4 differ diff --git a/src/app/FeaturedPacksCarousel.tsx b/src/app/FeaturedPacksCarousel.tsx new file mode 100644 index 0000000..ecfc2bd --- /dev/null +++ b/src/app/FeaturedPacksCarousel.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { PackCarousel, type PackCarouselItem } from "@/components/pack-art/PackCarousel"; + +/** + * Thin client wrapper so the (server-rendered) landing page can still use the + * interactive carousel: tapping/clicking the currently-showing pack navigates to its + * detail page; dragging/flicking just spins to a different pack. + */ +export function FeaturedPacksCarousel({ items }: { items: PackCarouselItem[] }) { + const router = useRouter(); + + return ( + router.push(`/packs/${item.tierKey}`)} /> + ); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx new file mode 100644 index 0000000..f8eea9f --- /dev/null +++ b/src/app/account/page.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; +import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; +import { SessionsManager } from "@/components/auth/SessionsManager"; +import { ConnectedWalletsList } from "@/components/wallet/ConnectedWalletsList"; + +export const revalidate = 0; + +/** + * The wallet-center/account hub the roadmap calls for (Phase 1, item 2) — connected + * wallets (read-only; see ConnectedWalletsList's note on why linking an additional wallet + * isn't wired up yet), active sessions with a sign-out-everywhere control, and links to + * the other account-scoped pages (shipping addresses, eligibility, opening history). + */ +export default async function AccountPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + + if (!session) { + return ( +
+

Sign in to continue

+

Your account details are tied to your session.

+ +
+ ); + } + + return ( +
+
+

Account

+

+ + My Openings + + {" · "} + + Shipping addresses + + {" · "} + + Eligibility + + {" · "} + + Responsible purchasing + +

+
+ +
+

Connected wallets

+ +
+ +
+

Active sessions

+ +
+
+ ); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..427d2ff --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME, revokeSession, validateSessionToken } from "@/server/auth/session"; +import { verifyCsrf } from "@/server/security/csrf"; + +export async function POST(req: NextRequest) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + if (token) { + const session = await validateSessionToken(token); + if (session) { + await revokeSession(session.sessionId, "user_revoked"); + } + } + + const res = NextResponse.json({ loggedOut: true }); + res.cookies.delete(SESSION_COOKIE_NAME); + return res; +} diff --git a/src/app/api/auth/oauth/complete-eligibility/route.ts b/src/app/api/auth/oauth/complete-eligibility/route.ts new file mode 100644 index 0000000..38ab8d9 --- /dev/null +++ b/src/app/api/auth/oauth/complete-eligibility/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { submitOAuthEligibility } from "@/server/auth/auth-service"; +import { verifyCsrf } from "@/server/security/csrf"; + +/** + * Closes the eligibility gap noted in auth-service.ts's findOrCreateGoogleUser / + * completeWalletAuth: neither Google nor wallet sign-in collects DOB/location at + * account-creation time, so this must be called (and pass) before createPackOffer() + * will allow a purchase — see the check added there. Requires an existing session + * (Google or wallet) since it operates on the already-authenticated user, never on a + * client-supplied userId. + */ +const bodySchema = z.object({ + dateOfBirth: z.string(), // ISO date (YYYY-MM-DD) + ageAcknowledged18Plus: z.boolean(), + country: z.string().length(2), + stateOrProvince: z.string().optional(), +}); + +export async function POST(req: NextRequest) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + const session = await validateSessionToken(token); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const body = bodySchema.safeParse(await req.json().catch(() => ({}))); + if (!body.success) { + return NextResponse.json( + { error: "invalid_request", issues: body.error.issues }, + { status: 400 }, + ); + } + + const result = await submitOAuthEligibility({ + userId: session.userId, + dateOfBirth: body.data.dateOfBirth, + ageAcknowledged18Plus: body.data.ageAcknowledged18Plus, + country: body.data.country, + stateOrProvince: body.data.stateOrProvince, + sessionCorrelationId: session.sessionId, + }); + + if (!result.eligible) { + return NextResponse.json( + { eligible: false, reasons: result.reasons }, + { status: 403 }, + ); + } + + return NextResponse.json({ eligible: true }); +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..0d4477a --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { db } from "@/server/db/client"; +import { users, userProfiles } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; + +export async function GET(req: NextRequest) { + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + const session = await validateSessionToken(token); + if (!session) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + + const [user] = await db + .select({ + id: users.id, + username: users.username, + email: users.email, + emailVerifiedAt: users.emailVerifiedAt, + displayName: userProfiles.displayName, + }) + .from(users) + .leftJoin(userProfiles, eq(userProfiles.userId, users.id)) + .where(eq(users.id, session.userId)) + .limit(1); + + return NextResponse.json({ authenticated: true, user }); +} diff --git a/src/app/api/auth/sessions/revoke-all/route.ts b/src/app/api/auth/sessions/revoke-all/route.ts new file mode 100644 index 0000000..0037015 --- /dev/null +++ b/src/app/api/auth/sessions/revoke-all/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + SESSION_COOKIE_NAME, + revokeAllSessionsForUser, + validateSessionToken, +} from "@/server/auth/session"; +import { verifyCsrf } from "@/server/security/csrf"; + +export async function POST(req: NextRequest) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + await revokeAllSessionsForUser(session.userId); + + const res = NextResponse.json({ revokedAll: true }); + res.cookies.delete(SESSION_COOKIE_NAME); + return res; +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..f2577e5 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + SESSION_COOKIE_NAME, + listActiveSessionsForUser, + validateSessionToken, +} from "@/server/auth/session"; + +export async function GET(req: NextRequest) { + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const sessions = await listActiveSessionsForUser(session.userId); + return NextResponse.json({ + sessions: sessions.map((s) => ({ ...s, isCurrent: s.id === session.sessionId })), + }); +} diff --git a/src/app/api/auth/wallet/list/route.ts b/src/app/api/auth/wallet/list/route.ts new file mode 100644 index 0000000..2929927 --- /dev/null +++ b/src/app/api/auth/wallet/list/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq, and, isNull } from "drizzle-orm"; +import { db } from "@/server/db/client"; +import { walletIdentities } from "@/server/db/schema"; +import { requireSession } from "@/server/auth/require-session"; + +/** Lists the signed-in user's own linked wallet identities — never another user's. */ +export async function GET(req: NextRequest) { + const session = await requireSession(req); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const rows = await db + .select({ + id: walletIdentities.id, + chain: walletIdentities.chain, + address: walletIdentities.address, + networkMode: walletIdentities.networkMode, + isPreferredPayment: walletIdentities.isPreferredPayment, + verifiedAt: walletIdentities.verifiedAt, + }) + .from(walletIdentities) + .where(and(eq(walletIdentities.userId, session.userId), isNull(walletIdentities.revokedAt))); + + return NextResponse.json({ wallets: rows }); +} diff --git a/src/app/api/auth/wallet/nonce/route.ts b/src/app/api/auth/wallet/nonce/route.ts new file mode 100644 index 0000000..cbfe220 --- /dev/null +++ b/src/app/api/auth/wallet/nonce/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { createWalletNonce } from "@/server/auth/auth-service"; +import { serverEnv } from "@/server/env"; +import { getClientContext } from "@/server/http/request-context"; +import { checkRateLimit } from "@/server/security/rate-limit"; + +const bodySchema = z.object({ + chain: z.enum(["algorand", "solana", "evm"]), + address: z.string().min(1), + purpose: z.enum(["login", "wallet_link"]).default("login"), +}); + +export async function POST(req: NextRequest) { + const { ipHash } = getClientContext(req); + const rateLimit = await checkRateLimit({ + key: `wallet-nonce:${ipHash ?? "unknown"}`, + limit: 20, + windowSeconds: 60 * 15, + }); + if (!rateLimit.allowed) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + const url = new URL(req.url); + const { message, nonce } = await createWalletNonce({ + chain: parsed.data.chain, + address: parsed.data.address, + domain: url.host, + uri: serverEnv.APP_URL, + purpose: parsed.data.purpose, + }); + + return NextResponse.json({ message, nonce }); +} diff --git a/src/app/api/auth/wallet/verify/route.ts b/src/app/api/auth/wallet/verify/route.ts new file mode 100644 index 0000000..1019006 --- /dev/null +++ b/src/app/api/auth/wallet/verify/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { completeWalletAuth, AuthError } from "@/server/auth/auth-service"; +import { + SESSION_COOKIE_NAME, + sessionCookieOptions, + validateSessionToken, +} from "@/server/auth/session"; +import { getClientContext } from "@/server/http/request-context"; +import { checkRateLimit } from "@/server/security/rate-limit"; +import { verifyCsrf } from "@/server/security/csrf"; + +const bodySchema = z.object({ + chain: z.enum(["algorand", "solana", "evm"]), + address: z.string().min(1), + signature: z.string().min(1), + message: z.string().min(1), + isLinking: z.boolean().default(false), +}); + +export async function POST(req: NextRequest) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const { ipHash, userAgent } = getClientContext(req); + const rateLimit = await checkRateLimit({ + key: `wallet-verify:${ipHash ?? "unknown"}`, + limit: 20, + windowSeconds: 60 * 15, + }); + if (!rateLimit.allowed) { + return NextResponse.json({ error: "rate_limited" }, { status: 429 }); + } + + const parsed = bodySchema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + let linkingUserId: string | undefined; + if (parsed.data.isLinking) { + const existingToken = req.cookies.get(SESSION_COOKIE_NAME)?.value; + const existingSession = existingToken ? await validateSessionToken(existingToken) : null; + if (!existingSession) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + linkingUserId = existingSession.userId; + } + + try { + const { token, expiresAt, userId } = await completeWalletAuth({ + chain: parsed.data.chain, + address: parsed.data.address, + signature: parsed.data.signature, + message: parsed.data.message, + linkingUserId, + sessionParams: { ipHash, userAgent }, + }); + const res = NextResponse.json({ userId, loggedIn: true }); + res.cookies.set(SESSION_COOKIE_NAME, token, sessionCookieOptions(expiresAt)); + return res; + } catch (err) { + if (err instanceof AuthError) { + return NextResponse.json({ error: err.code, message: err.message }, { status: 400 }); + } + throw err; + } +} diff --git a/src/app/api/fairness/verify/route.ts b/src/app/api/fairness/verify/route.ts new file mode 100644 index 0000000..deda38c --- /dev/null +++ b/src/app/api/fairness/verify/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySelection } from "@/server/fairness/engine"; +import { db } from "@/server/db/client"; +import { fairnessProofs, poolEntries } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; + +const bodySchema = z.object({ + ripId: z.string().uuid(), +}); + +/** + * Public fairness verifier (spec section 18). Looks up the published proof bundle for a + * rip and independently recomputes the selection from first principles, returning both + * the stored claim and the recomputation so a caller can compare them directly rather + * than trusting a single "valid: true/false" flag. + */ +export async function POST(req: NextRequest) { + const parsed = bodySchema.safeParse(await req.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + const [proof] = await db + .select() + .from(fairnessProofs) + .where(eq(fairnessProofs.ripId, parsed.data.ripId)) + .limit(1); + if (!proof) { + return NextResponse.json({ error: "proof_not_found" }, { status: 404 }); + } + + const entries = await db + .select({ id: poolEntries.id, weight: poolEntries.weight }) + .from(poolEntries) + .where(eq(poolEntries.poolVersionId, proof.poolVersionId)); + + const verdict = verifySelection({ + serverSeedCommitment: proof.serverSeedCommitment, + revealedServerSeed: proof.revealedServerSeed, + clientNonce: proof.clientNonce, + paymentIdentifier: proof.paymentIdentifier, + chainRandomnessInput: proof.chainRandomnessInput, + poolHash: proof.poolHash, + entries, + claimedCombinedSeedHash: proof.combinedSeedHash, + claimedSelectionRoll: proof.selectionRoll, + claimedSelectedEntryId: proof.selectedPoolEntryId, + }); + + return NextResponse.json({ + ripId: parsed.data.ripId, + valid: verdict.valid, + failures: verdict.failures, + proof: { + poolHash: proof.poolHash, + oddsHash: proof.oddsHash, + serverSeedCommitment: proof.serverSeedCommitment, + revealedServerSeed: proof.revealedServerSeed, + clientNonce: proof.clientNonce, + paymentIdentifier: proof.paymentIdentifier, + chainRandomnessInput: proof.chainRandomnessInput, + combinedSeedHash: proof.combinedSeedHash, + selectionRoll: proof.selectionRoll, + algorithmVersion: proof.algorithmVersion, + }, + }); +} diff --git a/src/app/api/oauth/[...nextauth]/route.ts b/src/app/api/oauth/[...nextauth]/route.ts new file mode 100644 index 0000000..618a8d4 --- /dev/null +++ b/src/app/api/oauth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/server/auth/google-oauth"; + +export const { GET, POST } = handlers; diff --git a/src/app/api/odds/[poolVersionId]/route.ts b/src/app/api/odds/[poolVersionId]/route.ts new file mode 100644 index 0000000..af2554d --- /dev/null +++ b/src/app/api/odds/[poolVersionId]/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/server/db/client"; +import { packTiers, poolEntries, poolVersions } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; + +/** + * Machine-readable odds JSON download (spec section 19). + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ poolVersionId: string }> }, +) { + const { poolVersionId } = await params; + + const [pool] = await db + .select({ + id: poolVersions.id, + versionLabel: poolVersions.versionLabel, + poolHash: poolVersions.poolHash, + oddsHash: poolVersions.oddsHash, + totalWeight: poolVersions.totalWeight, + cardCountTotal: poolVersions.cardCountTotal, + publishedAt: poolVersions.publishedAt, + archivedAt: poolVersions.archivedAt, + isImmutable: poolVersions.isImmutable, + isPromotional: poolVersions.isPromotional, + tierName: packTiers.name, + tierKey: packTiers.key, + }) + .from(poolVersions) + .innerJoin(packTiers, eq(poolVersions.packTierId, packTiers.id)) + .where(eq(poolVersions.id, poolVersionId)) + .limit(1); + + if (!pool) { + return NextResponse.json({ error: "pool_not_found" }, { status: 404 }); + } + + const entries = await db + .select({ + id: poolEntries.id, + cardGame: poolEntries.cardGame, + cardName: poolEntries.cardName, + setName: poolEntries.setName, + cardNumber: poolEntries.cardNumber, + finish: poolEntries.finish, + minCondition: poolEntries.minCondition, + weight: poolEntries.weight, + probabilityBandLabel: poolEntries.probabilityBandLabel, + }) + .from(poolEntries) + .where(eq(poolEntries.poolVersionId, poolVersionId)); + + const body = JSON.stringify({ pool, entries }, null, 2); + return new NextResponse(body, { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="packx402-odds-${pool.tierKey}-${pool.versionLabel}.json"`, + }, + }); +} diff --git a/src/app/api/packs/[tierKey]/spin-preview/route.ts b/src/app/api/packs/[tierKey]/spin-preview/route.ts new file mode 100644 index 0000000..b7b0a68 --- /dev/null +++ b/src/app/api/packs/[tierKey]/spin-preview/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/server/db/client"; +import { packTiers, poolEntries, poolVersions } from "@/server/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { resolveCardImages } from "@/server/card-images/resolver"; + +/** + * Public, read-only preview of what a pack tier's pool could actually contain, with real + * resolved card images — used to populate the card-reveal wheel's spin with genuine + * possible outcomes instead of generic card-back placeholders. Never reveals which entry + * will actually be won (that's determined server-side by the fairness engine only after + * payment) — this is the same published-pool data already shown on the pack-detail odds + * table, just with images attached. + */ +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ tierKey: string }> }, +) { + const { tierKey } = await params; + + const [tier] = await db.select().from(packTiers).where(eq(packTiers.key, tierKey)).limit(1); + if (!tier) { + return NextResponse.json({ error: "unknown_tier" }, { status: 404 }); + } + + const [pool] = await db + .select() + .from(poolVersions) + .where( + and( + eq(poolVersions.packTierId, tier.id), + eq(poolVersions.isPromotional, false), + isNull(poolVersions.archivedAt), + ), + ) + .orderBy(poolVersions.publishedAt) + .limit(1); + + if (!pool) { + return NextResponse.json({ cards: [] }); + } + + const entries = await db.select().from(poolEntries).where(eq(poolEntries.poolVersionId, pool.id)); + + const resolved = await resolveCardImages( + entries.map((e) => ({ + cardGame: e.cardGame, + cardName: e.cardName, + setName: e.setName, + cardNumber: e.cardNumber, + supplierListingId: e.supplierListingId, + supplierImageUsePermitted: false, // pool preview never uses supplier photos — see resolver.ts + certificationNumber: null, + })), + ); + + return NextResponse.json({ + cards: entries.map((e, i) => ({ + cardName: e.cardName, + imageUrl: resolved[i].imageUrl, + })), + }); +} diff --git a/src/app/api/packs/openings/route.ts b/src/app/api/packs/openings/route.ts new file mode 100644 index 0000000..bcab619 --- /dev/null +++ b/src/app/api/packs/openings/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateSessionToken, SESSION_COOKIE_NAME } from "@/server/auth/session"; +import { db } from "@/server/db/client"; +import { rips, packOffers, packTiers } from "@/server/db/schema"; +import { desc, eq } from "drizzle-orm"; + +/** + * A user's own pack-opening history — deliberately scoped to `packOffers.userId` matching + * the authenticated session, never a global/cross-user listing. This is distinct from + * `/api/fairness/verify`, which stays intentionally public-by-ripId: that endpoint proves + * a specific already-known rip's fairness to any third party (the whole point of + * "provably fair"), while this endpoint is what would let a rip's owner discover their + * own past ripIds in the first place. No one else's opening history is ever exposed here, + * including for rips marked `isPublic` (that flag governs social/showcase display + * elsewhere, not this personal history feed). + */ +export async function GET(req: NextRequest) { + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + const session = await validateSessionToken(token); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const rows = await db + .select({ + ripId: rips.id, + kind: rips.kind, + cardName: rips.cardName, + setName: rips.setName, + cardNumber: rips.cardNumber, + finish: rips.finish, + condition: rips.condition, + referenceValueUsdcBaseUnits: rips.referenceValueUsdcBaseUnits, + openedAt: rips.openedAt, + tierKey: packTiers.key, + tierName: packTiers.name, + }) + .from(rips) + .innerJoin(packOffers, eq(packOffers.id, rips.packOfferId)) + .innerJoin(packTiers, eq(packTiers.id, packOffers.packTierId)) + .where(eq(packOffers.userId, session.userId)) + .orderBy(desc(rips.openedAt)); + + return NextResponse.json({ openings: rows }); +} diff --git a/src/app/api/packs/tiers/route.ts b/src/app/api/packs/tiers/route.ts new file mode 100644 index 0000000..4ea71da --- /dev/null +++ b/src/app/api/packs/tiers/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { db } from "@/server/db/client"; +import { packTiers } from "@/server/db/schema"; +import { asc } from "drizzle-orm"; + +export async function GET() { + const rows = await db.select().from(packTiers).orderBy(asc(packTiers.sortOrder)); + return NextResponse.json({ tiers: rows }); +} diff --git a/src/app/api/shipping-addresses/[id]/default/route.ts b/src/app/api/shipping-addresses/[id]/default/route.ts new file mode 100644 index 0000000..e1fbcfd --- /dev/null +++ b/src/app/api/shipping-addresses/[id]/default/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq, ne } from "drizzle-orm"; +import { db } from "@/server/db/client"; +import { shippingAddresses } from "@/server/db/schema"; +import { verifyCsrf } from "@/server/security/csrf"; +import { requireSession } from "@/server/auth/require-session"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const session = await requireSession(req); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const { id } = await params; + + const [target] = await db + .select({ id: shippingAddresses.id }) + .from(shippingAddresses) + .where(and(eq(shippingAddresses.id, id), eq(shippingAddresses.userId, session.userId))) + .limit(1); + if (!target) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + // Exactly one default at a time: unset every other address first, then set this one — + // never two updates that could race into "no default" or "two defaults" if interrupted + // between them at the DB level (both run in the same request, sequentially). + await db + .update(shippingAddresses) + .set({ isDefault: false }) + .where(and(eq(shippingAddresses.userId, session.userId), ne(shippingAddresses.id, id))); + await db.update(shippingAddresses).set({ isDefault: true }).where(eq(shippingAddresses.id, id)); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/shipping-addresses/[id]/route.ts b/src/app/api/shipping-addresses/[id]/route.ts new file mode 100644 index 0000000..c2d4a61 --- /dev/null +++ b/src/app/api/shipping-addresses/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, eq } from "drizzle-orm"; +import { db } from "@/server/db/client"; +import { shippingAddresses } from "@/server/db/schema"; +import { verifyCsrf } from "@/server/security/csrf"; +import { requireSession } from "@/server/auth/require-session"; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const session = await requireSession(req); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const { id } = await params; + + // Scoped to the session's own userId — never lets a user delete another user's address + // regardless of what id is requested. + const deleted = await db + .delete(shippingAddresses) + .where(and(eq(shippingAddresses.id, id), eq(shippingAddresses.userId, session.userId))) + .returning({ id: shippingAddresses.id, wasDefault: shippingAddresses.isDefault }); + + if (deleted.length === 0) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + + // If the deleted address was the default, promote the most recently added remaining + // one so there's always a clear default whenever at least one address exists. + if (deleted[0].wasDefault) { + const [nextDefault] = await db + .select({ id: shippingAddresses.id }) + .from(shippingAddresses) + .where(eq(shippingAddresses.userId, session.userId)) + .orderBy(shippingAddresses.createdAt) + .limit(1); + if (nextDefault) { + await db + .update(shippingAddresses) + .set({ isDefault: true }) + .where(eq(shippingAddresses.id, nextDefault.id)); + } + } + + return NextResponse.json({ deleted: true }); +} diff --git a/src/app/api/shipping-addresses/route.ts b/src/app/api/shipping-addresses/route.ts new file mode 100644 index 0000000..c697f25 --- /dev/null +++ b/src/app/api/shipping-addresses/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { eq, desc } from "drizzle-orm"; +import { db } from "@/server/db/client"; +import { shippingAddresses } from "@/server/db/schema"; +import { verifyCsrf } from "@/server/security/csrf"; +import { encryptField, decryptField } from "@/server/crypto/field-encryption"; +import { requireSession } from "@/server/auth/require-session"; + +/** + * A user's own shipping addresses — the piece the supplier-purchase worker + * (src/server/suppliers/purchase-worker.ts) needs on file before it can complete a real + * purchase. Every free-text field is encrypted at rest (see shippingAddresses schema); + * `country` stays plaintext since shipping-eligibility queries filter on it. + */ + +const addressSchema = z.object({ + fullName: z.string().min(1).max(200), + line1: z.string().min(1).max(200), + line2: z.string().max(200).optional(), + city: z.string().min(1).max(120), + stateOrProvince: z.string().max(120).optional(), + postalCode: z.string().min(1).max(20), + country: z.string().length(2), + phone: z.string().max(40).optional(), +}); + +export async function GET(req: NextRequest) { + const session = await requireSession(req); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const rows = await db + .select() + .from(shippingAddresses) + .where(eq(shippingAddresses.userId, session.userId)) + .orderBy(desc(shippingAddresses.isDefault), desc(shippingAddresses.createdAt)); + + return NextResponse.json({ + addresses: rows.map((row) => ({ + id: row.id, + fullName: decryptField(row.fullNameEncrypted), + line1: decryptField(row.line1Encrypted), + line2: row.line2Encrypted ? decryptField(row.line2Encrypted) : null, + city: decryptField(row.cityEncrypted), + stateOrProvince: row.stateOrProvinceEncrypted + ? decryptField(row.stateOrProvinceEncrypted) + : null, + postalCode: decryptField(row.postalCodeEncrypted), + country: row.country, + isDefault: row.isDefault, + })), + }); +} + +export async function POST(req: NextRequest) { + if (!verifyCsrf(req)) { + return NextResponse.json({ error: "csrf_failed" }, { status: 403 }); + } + + const session = await requireSession(req); + if (!session) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const body = addressSchema.safeParse(await req.json().catch(() => ({}))); + if (!body.success) { + return NextResponse.json( + { error: "invalid_request", issues: body.error.issues }, + { status: 400 }, + ); + } + + const [existingCount] = await db + .select({ id: shippingAddresses.id }) + .from(shippingAddresses) + .where(eq(shippingAddresses.userId, session.userId)) + .limit(1); + const isFirstAddress = !existingCount; + + const [row] = await db + .insert(shippingAddresses) + .values({ + userId: session.userId, + fullNameEncrypted: encryptField(body.data.fullName), + line1Encrypted: encryptField(body.data.line1), + line2Encrypted: body.data.line2 ? encryptField(body.data.line2) : null, + cityEncrypted: encryptField(body.data.city), + stateOrProvinceEncrypted: body.data.stateOrProvince + ? encryptField(body.data.stateOrProvince) + : null, + postalCodeEncrypted: encryptField(body.data.postalCode), + country: body.data.country.toUpperCase(), + phoneEncrypted: body.data.phone ? encryptField(body.data.phone) : null, + isDefault: isFirstAddress, // first address on file is always the default + }) + .returning({ id: shippingAddresses.id }); + + return NextResponse.json({ id: row.id, isDefault: isFirstAddress }, { status: 201 }); +} diff --git a/src/app/api/x402/algorand/v1/packs/open/route.ts b/src/app/api/x402/algorand/v1/packs/open/route.ts new file mode 100644 index 0000000..afe67ce --- /dev/null +++ b/src/app/api/x402/algorand/v1/packs/open/route.ts @@ -0,0 +1,201 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateSessionToken, SESSION_COOKIE_NAME } from "@/server/auth/session"; +import { + createPackOffer, + settleOfferAndOpen, + OfferCreationError, + PaymentSettlementError, +} from "@/server/packs/offer-service"; +import { PACK_TIERS } from "@/server/config/pack-tiers"; +import { db } from "@/server/db/client"; +import { rips, fairnessProofs, supplierListings, poolEntries } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; +import { resolveCardImage } from "@/server/card-images/resolver"; + +/** + * x402 competition endpoint (spec section 41). + * - No offer yet / no X-PAYMENT header -> 402 with PaymentRequirements. + * - X-PAYMENT header present -> verify, settle, run fairness selection, return the + * opened resource with an X-PAYMENT-RESPONSE header carrying the settlement receipt. + * + * Defaults to TestNet; MainNet requires ALGORAND_NETWORK=mainnet AND + * ALGORAND_MAINNET_ENABLED=true (validated in src/server/env.ts at process start) and is + * never initiated automatically by this endpoint. + */ + +const openBodySchema = z.object({ + tierKey: z.enum([ + "spark", + "starter", + "scout", + "bronze", + "silver", + "gold", + "prism", + "platinum", + "obsidian", + "mythic", + "crown", + "vault", + "grail", + "genesis", + ]), + network: z.enum(["testnet", "mainnet"]).default("testnet"), +}); + +async function requireUserId(req: NextRequest): Promise { + const token = req.cookies.get(SESSION_COOKIE_NAME)?.value; + if (!token) return null; + const session = await validateSessionToken(token); + return session?.userId ?? null; +} + +async function loadRipWithImage(ripId: string) { + const [rip] = await db.select().from(rips).where(eq(rips.id, ripId)).limit(1); + if (!rip) return null; + + const [joined] = await db + .select({ + cardGame: poolEntries.cardGame, + supplierListingId: supplierListings.id, + supplierImageUsePermitted: supplierListings.imageUsePermitted, + }) + .from(poolEntries) + .leftJoin(supplierListings, eq(supplierListings.id, poolEntries.supplierListingId)) + .where(eq(poolEntries.id, rip.poolEntryId)) + .limit(1); + + const resolvedImage = await resolveCardImage({ + cardGame: joined?.cardGame ?? "other", + cardName: rip.cardName, + setName: rip.setName, + cardNumber: rip.cardNumber, + supplierListingId: joined?.supplierListingId ?? null, + supplierImageUsePermitted: joined?.supplierImageUsePermitted ?? false, + certificationNumber: rip.gradeLabel, + }); + + return { + card: { + name: rip.cardName, + setName: rip.setName, + cardNumber: rip.cardNumber, + finish: rip.finish, + condition: rip.condition, + gradeLabel: rip.gradeLabel, + }, + resolvedImage, + }; +} + +export async function POST(req: NextRequest) { + const userId = await requireUserId(req); + if (!userId) { + return NextResponse.json({ error: "authentication_required" }, { status: 401 }); + } + + const url = new URL(req.url); + const offerIdParam = url.searchParams.get("offerId"); + const xPayment = req.headers.get("X-PAYMENT"); + + // Phase 2: payment attached to an existing offer. + if (offerIdParam && xPayment) { + try { + const result = await settleOfferAndOpen({ offerId: offerIdParam, xPaymentHeader: xPayment }); + const [proof] = await db + .select() + .from(fairnessProofs) + .where(eq(fairnessProofs.ripId, result.ripId)) + .limit(1); + + const primary = await loadRipWithImage(result.ripId); + const bonus = result.bonusRip ? await loadRipWithImage(result.bonusRip.ripId) : null; + + const res = NextResponse.json({ + ripId: result.ripId, + card: primary?.card ?? null, + resolvedImage: primary?.resolvedImage ?? null, + // Bonus-flip mechanic: a fixed 4% chance, evaluated server-side from the same + // committed seed as the primary pull (see deriveBonusFlipHit in + // src/server/fairness/engine.ts) — never client-side randomness. `hit` always + // reflects the real, already-determined outcome; the client only animates it. + bonusFlip: { + hit: bonus !== null, + card: bonus?.card ?? null, + resolvedImage: bonus?.resolvedImage ?? null, + }, + fairnessProof: proof + ? { + serverSeedCommitment: proof.serverSeedCommitment, + revealedServerSeed: proof.revealedServerSeed, + clientNonce: proof.clientNonce, + paymentIdentifier: proof.paymentIdentifier, + chainRandomnessInput: proof.chainRandomnessInput, + poolHash: proof.poolHash, + combinedSeedHash: proof.combinedSeedHash, + selectionRoll: proof.selectionRoll, + } + : null, + }); + res.headers.set( + "X-PAYMENT-RESPONSE", + Buffer.from(JSON.stringify({ ripId: result.ripId })).toString("base64"), + ); + return res; + } catch (err) { + if (err instanceof PaymentSettlementError) { + const status = err.code === "duplicate_payment" ? 409 : 402; + return NextResponse.json({ error: err.code, message: err.message }, { status }); + } + throw err; + } + } + + // Phase 1: no payment yet — create (or reuse) an offer and return 402 + requirements. + const body = openBodySchema.safeParse(await req.json().catch(() => ({}))); + if (!body.success) { + return NextResponse.json( + { error: "invalid_request", issues: body.error.issues }, + { status: 400 }, + ); + } + + try { + const { offerId, paymentRequirements, expiresAt } = await createPackOffer({ + userId, + tierKey: body.data.tierKey, + chain: "algorand", + network: body.data.network, + }); + + return NextResponse.json( + { + x402Version: 1, + error: "payment_required", + accepts: [paymentRequirements], + offerId, + expiresAt: expiresAt.toISOString(), + }, + { status: 402 }, + ); + } catch (err) { + if (err instanceof OfferCreationError) { + return NextResponse.json({ error: err.code, message: err.message }, { status: 422 }); + } + throw err; + } +} + +export function GET() { + return NextResponse.json( + { + tiers: PACK_TIERS.map((t) => ({ + key: t.key, + name: t.name, + priceUsdcBaseUnits: t.priceUsdcBaseUnits, + })), + }, + { status: 200 }, + ); +} diff --git a/src/app/collection/page.tsx b/src/app/collection/page.tsx new file mode 100644 index 0000000..ff9eb68 --- /dev/null +++ b/src/app/collection/page.tsx @@ -0,0 +1,125 @@ +import { cookies } from "next/headers"; +import Link from "next/link"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { db } from "@/server/db/client"; +import { rips, packOffers, packTiers } from "@/server/db/schema"; +import { desc, eq } from "drizzle-orm"; +import { usdcBaseUnitsToDisplayString } from "@/shared/money"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; + +export const revalidate = 0; + +async function getMyOpenings(userId: string) { + return db + .select({ + ripId: rips.id, + kind: rips.kind, + cardName: rips.cardName, + setName: rips.setName, + cardNumber: rips.cardNumber, + finish: rips.finish, + condition: rips.condition, + referenceValueUsdcBaseUnits: rips.referenceValueUsdcBaseUnits, + openedAt: rips.openedAt, + tierKey: packTiers.key, + tierName: packTiers.name, + }) + .from(rips) + .innerJoin(packOffers, eq(packOffers.id, rips.packOfferId)) + .innerJoin(packTiers, eq(packTiers.id, packOffers.packTierId)) + .where(eq(packOffers.userId, userId)) + .orderBy(desc(rips.openedAt)); +} + +/** + * A user's own pack-opening history — deliberately scoped server-side to the + * authenticated session's userId (see the same scoping in GET /api/packs/openings). + * Never shows another user's openings, regardless of any `isPublic` flag on a rip (that + * flag governs social/showcase display elsewhere, not this personal page). + */ +export default async function CollectionPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + + if (!session) { + return ( +
+

My Openings

+

Sign in to see your pack-opening history.

+ +
+ ); + } + + const openings = await getMyOpenings(session.userId).catch(() => []); + + return ( +
+

My Openings

+

+ Every card you've pulled, visible only to you. Each opening links to its + independently verifiable fairness proof. Manage where cards ship in{" "} + + shipping addresses + + . +

+ + {openings.length === 0 ? ( +

+ No pack openings yet —{" "} + + browse the marketplace + {" "} + to open your first pack. +

+ ) : ( +
+ + + + + + + + + + + + + {openings.map((o) => ( + + + + + + + + + ))} + +
CardSetPackValueOpenedVerify
+ {o.cardName} + {o.kind === "bonus_flip" && ( + Bonus flip + )} + {o.setName}{o.tierName} + {o.referenceValueUsdcBaseUnits != null + ? `$${usdcBaseUnitsToDisplayString(o.referenceValueUsdcBaseUnits)}` + : "—"} + + {new Date(o.openedAt).toLocaleDateString()} + + + Verify + +
+
+ )} +
+ ); +} diff --git a/src/app/dev/rip-preview/page.tsx b/src/app/dev/rip-preview/page.tsx new file mode 100644 index 0000000..bc58520 --- /dev/null +++ b/src/app/dev/rip-preview/page.tsx @@ -0,0 +1,22 @@ +import { InteractivePackDemo } from "@/components/pack-art/InteractivePackDemo"; +import { ConnectPeraButton } from "@/components/wallet/ConnectPeraButton"; + +/** + * No-database preview of the full carousel-select -> rip -> spin -> reveal sequence, for + * local demoing when Postgres isn't running (the real /packs/[tierKey]/open page 404s + * without a live DB — this page never touches it). Dev-only, not linked from anywhere in + * the real app. The same InteractivePackDemo also appears on the real landing page. + */ +export default function RipPreviewPage() { + return ( +
+

Rip-open animation preview

+ +
+ +
+ + +
+ ); +} diff --git a/src/app/eligibility/page.tsx b/src/app/eligibility/page.tsx new file mode 100644 index 0000000..5ecd1cf --- /dev/null +++ b/src/app/eligibility/page.tsx @@ -0,0 +1,44 @@ +import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; +import { EligibilityForm } from "@/components/auth/EligibilityForm"; + +export const revalidate = 0; + +/** + * Collects the DOB/country/18+ acknowledgment that neither Google sign-in nor a wallet + * signature captures at account-creation time (see submitOAuthEligibility in + * auth-service.ts and the "eligibility_required" rejection in offer-service.ts's + * createPackOffer). Linked to from anywhere a pack-offer creation fails with that error. + */ +export default async function EligibilityPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + + if (!session) { + return ( +
+

Sign in to continue

+

+ Sign in first — eligibility is tied to your account. +

+ +
+ ); + } + + return ( +
+

Before you open a pack

+

+ PackX402 requires everyone to confirm they're 18+ and tell us where they're + purchasing from — this only takes a moment. +

+ +
+ ); +} diff --git a/src/app/fairness/VerifierForm.tsx b/src/app/fairness/VerifierForm.tsx new file mode 100644 index 0000000..29c8b3e --- /dev/null +++ b/src/app/fairness/VerifierForm.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +interface VerifyResponse { + ripId: string; + valid: boolean; + failures: string[]; + proof: Record; +} + +export function VerifierForm() { + const searchParams = useSearchParams(); + const [ripId, setRipId] = useState(() => searchParams.get("ripId") ?? ""); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const autoVerifiedRef = useRef(false); + + async function verify(id: string) { + setLoading(true); + setError(null); + setResult(null); + try { + const res = await fetch("/api/fairness/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ripId: id.trim() }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error ?? "verification_failed"); + return; + } + setResult(await res.json()); + } catch { + setError("network_error"); + } finally { + setLoading(false); + } + } + + // Prefill and auto-verify when arriving via a "Verify" link (e.g. from /collection) + // with ?ripId=... already known — saves a manual paste-and-click round trip. + useEffect(() => { + const fromQuery = searchParams.get("ripId"); + if (fromQuery && !autoVerifiedRef.current) { + autoVerifiedRef.current = true; + void verify(fromQuery); + } + }, [searchParams]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + await verify(ripId); + } + + return ( +
+
+
+ + setRipId(e.target.value)} + placeholder="e.g. 3fa85f64-5717-4562-b3fc-2c963f66afa6" + className="border-border-subtle bg-surface-raised focus:border-accent w-full rounded-md border px-3 py-2 text-sm outline-none" + /> +
+ +
+ + {error && ( +

+ Could not verify: {error} +

+ )} + + {result && ( +
+

+ {result.valid ? "Selection verified ✓" : "Verification failed"} +

+ {result.failures.length > 0 && ( +
    + {result.failures.map((f) => ( +
  • {f}
  • + ))} +
+ )} +
+ {Object.entries(result.proof).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+ +
+ )} +
+ ); +} diff --git a/src/app/fairness/page.tsx b/src/app/fairness/page.tsx new file mode 100644 index 0000000..fbc03ae --- /dev/null +++ b/src/app/fairness/page.tsx @@ -0,0 +1,74 @@ +import { Suspense } from "react"; +import { VerifierForm } from "./VerifierForm"; + +export const metadata = { title: "Provably Fair Center — PackX402" }; + +export default function FairnessPage() { + return ( +
+

Provably Fair Center

+

+ Every pack opening is determined by a deterministic algorithm you can reproduce yourself — + no trust in PackX402 required. +

+ +
+

Plain-language explanation

+
    +
  1. + 1. Before you pay: PackX402 generates a + secret server seed and publishes only its hash (a “commitment”) along with + the pool's hash and odds hash. PackX402 cannot change the seed after this point + without the commitment no longer matching. +
  2. +
  3. + 2. You pay: your payment settles on-chain + and produces a payment identifier and a piece of post-settlement chain randomness (for + example, the hash of the block that confirmed your transaction) that nobody — including + PackX402 — could have predicted beforehand. +
  4. +
  5. + 3. The seed is revealed: PackX402 publishes + the server seed. Combined with your client nonce, the payment identifier, the chain + randomness, and the pool hash, a single sha256 hash determines your card by walking the + pool's published probability weights. +
  6. +
  7. + 4. You verify: recompute the same hash + yourself (or use the verifier below) and confirm it selects the same card PackX402 + showed you. +
  8. +
+
+ +
+

Technical explanation

+

+ + combinedSeedHash = sha256(serverSeed | clientNonce | paymentId | chainRandomness | + poolHash) + + . The first 64 bits of that digest, reduced modulo the pool's total weight, is the{" "} + selectionRoll. Pool entries are walked in ascending{" "} + id order, accumulating weight, until the roll falls + inside an entry's band. The full reference implementation and fixed test vectors are + published in{" "} + + docs/FAIRNESS_PROTOCOL.md + + . +

+
+ +
+

Verify a completed pull

+ + + +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..023d159 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,257 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #0b0d10; + --surface: #14171c; + --surface-raised: #1b1f26; + --border-subtle: #2a2f38; + --foreground: #eef1f5; + --muted: #9aa3af; + --accent: #d4af6a; + --accent-strong: #e8c988; + --accent-foreground: #14171c; + --danger: #e8697a; + --success: #6fcf97; } @theme inline { --color-background: var(--background); + --color-surface: var(--surface); + --color-surface-raised: var(--surface-raised); + --color-border-subtle: var(--border-subtle); --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-accent-strong: var(--accent-strong); + --color-accent-foreground: var(--accent-foreground); + --color-danger: var(--danger); + --color-success: var(--success); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + z-index: 100; + padding: 0.75rem 1rem; + background: var(--accent); + color: var(--accent-foreground); + border-radius: 0 0 0.5rem 0; +} +.skip-link:focus { + left: 0; +} + +/* ---------------------------------------------------------------------- */ +/* PackArt fallback face — see src/components/pack-art/PackArt.tsx */ +/* ---------------------------------------------------------------------- */ + +.pack-art-fallback { + border: 1px solid rgb(255 255 255 / 0.08); +} + +.pack-art-sheen { + background: linear-gradient( + 115deg, + transparent 30%, + color-mix(in srgb, var(--sheen-color) 35%, transparent) 48%, + transparent 62% + ); + background-size: 250% 250%; + background-position: 0% 0%; + animation: pack-art-sheen-sweep 6s ease-in-out infinite; + mix-blend-mode: screen; +} + +@keyframes pack-art-sheen-sweep { + 0% { + background-position: 120% 0%; + } + 50% { + background-position: -20% 100%; + } + 100% { + background-position: 120% 0%; + } +} + +.pack-art-crest { + filter: drop-shadow(0 0 6px color-mix(in srgb, currentColor 40%, transparent)); +} + +.pack-art-opening { + animation: pack-art-opening-pulse 1.2s ease-in-out infinite; +} + +@keyframes pack-art-opening-pulse { + 0%, + 100% { + transform: scale(1); + filter: brightness(1); + } + 50% { + transform: scale(1.015); + filter: brightness(1.08); + } +} + +.pack-art-skeleton { + background: linear-gradient( + 100deg, + var(--surface) 30%, + var(--surface-raised) 50%, + var(--surface) 70% + ); + background-size: 300% 100%; + animation: pack-art-skeleton-shimmer 1.6s ease-in-out infinite; +} + +@keyframes pack-art-skeleton-shimmer { + 0% { + background-position: 150% 0%; + } + 100% { + background-position: -50% 0%; + } +} + +/* ---------------------------------------------------------------------- */ +/* ResultEffect placeholders — src/components/pack-art/ResultEffect.tsx */ +/* Placeholder CSS only; replaced by real VFX clips per docs/ASSET_MANIFEST.md */ +/* ---------------------------------------------------------------------- */ + +.result-effect-ring { + width: 40%; + height: 40%; + border: 1px solid; + opacity: 0; + animation: result-effect-ring-expand 1.8s ease-out infinite; +} + +@keyframes result-effect-ring-expand { + 0% { + transform: scale(0.6); + opacity: 0.65; + } + 100% { + transform: scale(2.2); + opacity: 0; + } +} + +.result-effect-eclipse { + width: 30%; + height: 30%; + background: radial-gradient( + circle, + color-mix(in srgb, var(--accent) 55%, transparent) 0%, + transparent 70% + ); + animation: result-effect-eclipse-pulse 2.4s ease-in-out infinite; +} + +@keyframes result-effect-eclipse-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(1.15); + } +} + +/* ---------------------------------------------------------------------- */ +/* PackCarousel ambient background — src/components/pack-art/PackCarousel.tsx */ +/* ---------------------------------------------------------------------- */ + +.carousel-ambient { + background: + radial-gradient( + circle at 30% 40%, + color-mix(in srgb, var(--accent) 18%, transparent) 0%, + transparent 55% + ), + radial-gradient(circle at 70% 60%, rgba(63, 168, 140, 0.14) 0%, transparent 55%); + background-size: + 140% 140%, + 140% 140%; + animation: carousel-ambient-drift 18s ease-in-out infinite alternate; +} + +@keyframes carousel-ambient-drift { + 0% { + background-position: + 20% 30%, + 80% 70%; + } + 100% { + background-position: + 35% 55%, + 65% 45%; + } +} + +/* ---------------------------------------------------------------------- */ +/* Pack3DTilt idle float — src/components/pack-art/Pack3DTilt.tsx */ +/* ---------------------------------------------------------------------- */ + +.pack-3d-idle-float { + animation: pack-3d-idle-float 4.5s ease-in-out infinite; +} + +.pack-3d-idle-float:active { + animation-play-state: paused; +} + +@keyframes pack-3d-idle-float { + 0%, + 100% { + transform: translateY(0) rotateZ(-0.6deg); + } + 50% { + transform: translateY(-10px) rotateZ(0.6deg); + } +} + +.result-effect-column { + top: 0; + width: 2px; + height: 100%; + background: linear-gradient( + to bottom, + transparent 0%, + color-mix(in srgb, var(--accent) 60%, transparent) 45%, + transparent 100% + ); + animation: result-effect-column-rise 2.6s ease-in-out infinite; +} + +@keyframes result-effect-column-rise { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 0.9; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..6f011dd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,10 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import Link from "next/link"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; +import { LogoutButton } from "@/components/auth/LogoutButton"; +import { AuthAwareHeaderActions, AuthAwareNavLink } from "@/components/auth/AuthAwareHeaderActions"; +import { WalletManagerProvider } from "@/components/wallet/WalletManagerProvider"; import "./globals.css"; const geistSans = Geist({ @@ -13,8 +18,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "PackX402 — Open. Verify. Collect.", + description: + "A provably fair, supplier-backed trading-card pack platform powered by Algorand x402.", }; export default function RootLayout({ @@ -23,11 +29,150 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + + + Skip to main content + + + +
+ {children} +
+ +
+ ); } + +function SiteHeader() { + return ( +
+
+ + + P4 + + + PACK402 + + + +
+ } + signedOutSlot={ + + } + /> +
+
+
+ ); +} + +function SiteFooter() { + return ( +
+
+
+

PackX402

+

Open. Verify. Collect.

+
+
+

Legal

+
    +
  • + + Terms of Service + +
  • +
  • + + Privacy Policy + +
  • +
  • + + Responsible Purchasing Policy + +
  • +
  • + + Official Pack Rules + +
  • +
+
+
+

Trust

+
    +
  • + + Provably Fair Center + +
  • +
  • + + Odds Library + +
  • +
  • + + System Status + +
  • +
  • + + Security & Vulnerability Disclosure + +
  • +
+
+
+

Support

+
    +
  • + + Help Center + +
  • +
  • + + Responsible Purchasing + +
  • +
+
+
+
+

+ PackX402 packs contain randomized physical trading cards. Card values can change and are + not guaranteed. PackX402 is not an investment platform. Beta software — see + docs/LEGAL_REVIEW_REQUIRED.md. +

+
+
+ ); +} diff --git a/src/app/odds/page.tsx b/src/app/odds/page.tsx new file mode 100644 index 0000000..8c43ae4 --- /dev/null +++ b/src/app/odds/page.tsx @@ -0,0 +1,91 @@ +import { db } from "@/server/db/client"; +import { packTiers, poolVersions } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; + +export const metadata = { title: "Odds Library — PackX402" }; +export const revalidate = 60; + +async function getPools() { + try { + return await db + .select({ + id: poolVersions.id, + versionLabel: poolVersions.versionLabel, + poolHash: poolVersions.poolHash, + oddsHash: poolVersions.oddsHash, + totalWeight: poolVersions.totalWeight, + cardCountTotal: poolVersions.cardCountTotal, + publishedAt: poolVersions.publishedAt, + archivedAt: poolVersions.archivedAt, + isImmutable: poolVersions.isImmutable, + tierName: packTiers.name, + tierKey: packTiers.key, + }) + .from(poolVersions) + .innerJoin(packTiers, eq(poolVersions.packTierId, packTiers.id)); + } catch { + return []; + } +} + +export default async function OddsLibraryPage() { + const pools = await getPools(); + + return ( +
+

Published Odds Library

+

+ Once a paid opening uses a pool version, that version becomes immutable — its published + hashes can never change retroactively. +

+ +
+ + + + + + + + + + + + + + {pools.map((p) => ( + + + + + + + + + + ))} + +
TierVersionCardsPool hashOdds hashStatusJSON
{p.tierName}{p.versionLabel}{p.cardCountTotal} + {p.poolHash} + + {p.oddsHash} + + {p.archivedAt ? "Archived" : p.isImmutable ? "Immutable" : "Active"} + + + Download JSON + +
+ {pools.length === 0 && ( +

No pool versions published yet.

+ )} +
+
+ ); +} diff --git a/src/app/packs/[tierKey]/open/OpenPackClient.tsx b/src/app/packs/[tierKey]/open/OpenPackClient.tsx new file mode 100644 index 0000000..c0a19d0 --- /dev/null +++ b/src/app/packs/[tierKey]/open/OpenPackClient.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { PackShelf, type PackShelfItem } from "@/components/pack-art/PackShelf"; +import { OpeningStage, type OpeningPhase } from "@/components/pack-art/OpeningStage"; +import { RIP_VIDEO_BY_TIER } from "@/components/pack-art/rip-video-map"; +import { CoinFlip } from "@/components/pack-art/CoinFlip"; +import { CardOverlaySlot } from "@/components/pack-art/CardOverlaySlot"; +import type { SpinPossibleCard } from "@/components/pack-art/CardRevealWheel"; +import { PaymentMethodPanel } from "@/components/payments/PaymentMethodPanel"; +import type { PackTierKey } from "@/server/config/pack-tiers"; +import type { ResolvedCardImage } from "@/shared/card-image"; + +export interface OpenPackClientProps { + tiers: PackShelfItem[]; + initialTierKey: PackTierKey; + initialLocked: boolean; + /** Rendered when a 401 requires sign-in — a Server Component (GoogleSignInButton uses a + * server action), so it's passed down from the server page rather than imported here. */ + signInSlot?: React.ReactNode; +} + +interface PendingPayment { + offerId: string; + amountBaseUnits: string; + payTo: string; + asset: string; + network: string; +} + +interface BonusFlipResult { + hit: boolean; + cardName: string | null; + resolvedImage: ResolvedCardImage | null; +} + +/** + * Ties the pack-browsing shelf to the actual opening flow: pick a pack, drag-rip it open, + * then either the real x402 payment requirement (no wallet connect UI exists yet — see + * PROJECT_STATUS.md — so this is shown rather than faked) or, once a wallet flow supplies + * a real X-PAYMENT header, settlement succeeds and the reveal wheel spins down to the + * actual fairness-selected card plus the server's already-determined bonus-flip outcome + * (a fixed 4% chance — see deriveBonusFlipHit in src/server/fairness/engine.ts). Never + * fabricates a card outcome or a bonus-flip result client-side. + */ +export function OpenPackClient({ + tiers, + initialTierKey, + initialLocked, + signInSlot, +}: OpenPackClientProps) { + const [selected, setSelected] = useState( + tiers.find((t) => t.tierKey === initialTierKey) ?? tiers[0], + ); + const [locked, setLocked] = useState(initialLocked); + const [hasSelectedPack, setHasSelectedPack] = useState(false); + const [phase, setPhase] = useState("idle"); + const [pendingPayment, setPendingPayment] = useState(null); + const [error, setError] = useState(null); + const [needsEligibility, setNeedsEligibility] = useState(false); + const [wheelKey, setWheelKey] = useState(0); + const [possibleCards, setPossibleCards] = useState([]); + const [cardName, setCardName] = useState(null); + const [resolvedImage, setResolvedImage] = useState(null); + const [bonusFlip, setBonusFlip] = useState(null); + const [showCoinFlip, setShowCoinFlip] = useState(false); + const [showBonusCard, setShowBonusCard] = useState(false); + + async function handleRipped() { + setPhase("tearing"); + setError(null); + setNeedsEligibility(false); + try { + const res = await fetch("/api/x402/algorand/v1/packs/open", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tierKey: selected.tierKey, network: "testnet" }), + }); + const data = await res.json(); + + if (res.status === 401) { + setError("Sign in to open a pack."); + setPhase("idle"); + return; + } + if (res.status === 422 && data.error === "eligibility_required") { + setError("Confirm your age and location before opening a pack."); + setNeedsEligibility(true); + setPhase("idle"); + return; + } + if (res.status === 402 && data.accepts?.[0]) { + setPendingPayment({ + offerId: data.offerId, + amountBaseUnits: data.accepts[0].amountBaseUnits ?? data.accepts[0].amount, + payTo: data.accepts[0].payTo, + asset: data.accepts[0].asset, + network: data.accepts[0].network, + }); + setPhase("idle"); // payment now happens via PaymentMethodPanel's connected wallet + return; + } + if (res.ok && data.card) { + applySettledResult(data); + return; + } + setError(data.message ?? "Could not start this pack opening."); + setPhase("idle"); + } catch { + setError("Network error creating pack offer."); + setPhase("idle"); + } + } + + /** Shared by both the (rare) case where handleRipped's own request already settled and + * PayWithWalletButton's onSettled callback after a real signed payment completes. Only + * ever called with a genuine server response — never fabricates a card or bonus-flip + * outcome client-side. */ + function applySettledResult(data: Record) { + const card = data.card as { name: string } | null | undefined; + const bonusFlipData = data.bonusFlip as + | { hit?: boolean; card?: { name: string }; resolvedImage?: ResolvedCardImage } + | undefined; + setCardName(card?.name ?? null); + setResolvedImage((data.resolvedImage as ResolvedCardImage | null) ?? null); + setBonusFlip({ + hit: Boolean(bonusFlipData?.hit), + cardName: bonusFlipData?.card?.name ?? null, + resolvedImage: bonusFlipData?.resolvedImage ?? null, + }); + setPendingPayment(null); + // Always show the torn-pack art for a beat before the reveal wheel spins — settling + // via the deferred-payment path would otherwise jump straight to "revealing" and the + // torn art would never appear at all; settling immediately would flash past it too + // fast on a fast local network to actually register. + setPhase("tearing"); + window.setTimeout(() => setPhase("revealing"), 900); + } + + function handleRevealSettled() { + setPhase("resolved"); + // Only ever show the coin-flip flourish on the small chance the bonus flip actually + // hit (4% of opens, server-determined) — it must not appear on every spin. + if (bonusFlip?.hit) { + window.setTimeout(() => setShowCoinFlip(true), 500); + } + } + + function handleCoinFlipComplete() { + setShowCoinFlip(false); + setShowBonusCard(true); + } + + function resetOpeningState() { + setPendingPayment(null); + setError(null); + setNeedsEligibility(false); + setPhase("idle"); + setWheelKey((k) => k + 1); + setCardName(null); + setResolvedImage(null); + setBonusFlip(null); + setShowCoinFlip(false); + setShowBonusCard(false); + } + + async function loadPossibleCards(tierKey: string) { + setPossibleCards([]); + try { + const res = await fetch(`/api/packs/${tierKey}/spin-preview`); + if (!res.ok) return; + const data = await res.json(); + setPossibleCards(Array.isArray(data.cards) ? data.cards : []); + } catch { + // Non-critical — the wheel falls back to generic card backs when this is empty. + } + } + + return ( +
+

Open a pack

+ + {hasSelectedPack && !locked ? ( + <> +

+ Drag across the top to rip {selected.tierName} open. +

+ +
+ + + {showCoinFlip && bonusFlip && ( + + )} + + {showBonusCard && bonusFlip?.hit && ( +
+

+ Bonus flip hit! You also got: +

+ +
+ )} + + {error && ( +
+

{error}

+ {error.startsWith("Sign in") && signInSlot} + {needsEligibility && ( + + Confirm eligibility → + + )} +
+ )} + + {pendingPayment && ( + + )} + + +
+ + ) : ( +

+ Spin to a pack below, then tap it to select it. +

+ )} + + {hasSelectedPack && locked && ( +
+ {selected.tierName} is locked during beta and cannot be opened. +
+ )} + + { + setSelected(item); + setLocked(item.locked ?? false); + setHasSelectedPack(true); + resetOpeningState(); + void loadPossibleCards(item.tierKey); + }} + className="mb-6" + /> + +

+ + View pack details & odds + +

+
+ ); +} diff --git a/src/app/packs/[tierKey]/open/page.tsx b/src/app/packs/[tierKey]/open/page.tsx new file mode 100644 index 0000000..0a5f8fc --- /dev/null +++ b/src/app/packs/[tierKey]/open/page.tsx @@ -0,0 +1,41 @@ +import { notFound } from "next/navigation"; +import { db } from "@/server/db/client"; +import { packTiers } from "@/server/db/schema"; +import { asc } from "drizzle-orm"; +import { serverEnv } from "@/server/env"; +import type { PackTierKey } from "@/server/config/pack-tiers"; +import { OpenPackClient } from "./OpenPackClient"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; + +export const revalidate = 0; + +async function getData(tierKey: string) { + const allTiers = await db.select().from(packTiers).orderBy(asc(packTiers.sortOrder)); + const tier = allTiers.find((t) => t.key === tierKey); + return { allTiers, tier }; +} + +export default async function OpenPackPage({ params }: { params: Promise<{ tierKey: string }> }) { + const { tierKey } = await params; + const { allTiers, tier } = await getData(tierKey).catch(() => ({ allTiers: [], tier: undefined })); + if (!tier) notFound(); + + const isGated = tier.requiresHighValueReleaseGate && !serverEnv.FEATURE_HIGH_VALUE_PACKS_ENABLED; + const isLocked = tier.locked || isGated; + + return ( +
+ ({ + tierKey: t.key as PackTierKey, + tierName: t.name, + price: t.priceUsdcBaseUnits, + locked: t.locked || (t.requiresHighValueReleaseGate && !serverEnv.FEATURE_HIGH_VALUE_PACKS_ENABLED), + }))} + initialTierKey={tier.key as PackTierKey} + initialLocked={isLocked} + signInSlot={} + /> +
+ ); +} diff --git a/src/app/packs/[tierKey]/page.tsx b/src/app/packs/[tierKey]/page.tsx new file mode 100644 index 0000000..7b6f14f --- /dev/null +++ b/src/app/packs/[tierKey]/page.tsx @@ -0,0 +1,176 @@ +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { db } from "@/server/db/client"; +import { packTiers, poolEntries, poolVersions } from "@/server/db/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import { usdcBaseUnitsToDisplayString } from "@/shared/money"; +import { serverEnv } from "@/server/env"; +import { PackArt } from "@/components/pack-art/PackArt"; +import { Pack3DTilt } from "@/components/pack-art/Pack3DTilt"; +import type { PackTierKey } from "@/server/config/pack-tiers"; + +export const revalidate = 30; + +async function getTierData(tierKey: string) { + const [tier] = await db.select().from(packTiers).where(eq(packTiers.key, tierKey)).limit(1); + if (!tier) return null; + + const [pool] = await db + .select() + .from(poolVersions) + .where( + and( + eq(poolVersions.packTierId, tier.id), + eq(poolVersions.isPromotional, false), + isNull(poolVersions.archivedAt), + ), + ) + .orderBy(poolVersions.publishedAt) + .limit(1); + + const entries = pool + ? await db.select().from(poolEntries).where(eq(poolEntries.poolVersionId, pool.id)) + : []; + + return { tier, pool, entries }; +} + +export default async function PackDetailPage({ params }: { params: Promise<{ tierKey: string }> }) { + const { tierKey } = await params; + const data = await getTierData(tierKey).catch(() => null); + if (!data || !data.tier) notFound(); + + const { tier, pool, entries } = data; + const isGated = tier.requiresHighValueReleaseGate && !serverEnv.FEATURE_HIGH_VALUE_PACKS_ENABLED; + const isLocked = tier.locked || isGated; + + return ( +
+
+
+ + + +
+
+

{tier.name} Pack

+

+ ${usdcBaseUnitsToDisplayString(tier.priceUsdcBaseUnits)}{" "} + USDC +

+ +
+
Shipping
+
+ {tier.shippingTreatment === "included" + ? "Included" + : `~$${usdcBaseUnitsToDisplayString(tier.estimatedShippingUsdcBaseUnits)} estimated`} +
+
Card categories
+
{tier.cardGames.join(", ") || "Pokémon, Yu-Gi-Oh"}
+
Minimum condition
+
{tier.minDisclosedCondition.replaceAll("_", " ")}
+
Pool version
+
{pool ? pool.versionLabel : "Not yet published"}
+
Published
+
{pool ? new Date(pool.publishedAt).toLocaleDateString() : "—"}
+
Estimated delivery
+
7–14 business days after supplier confirmation
+
Max obtainable card value
+
${usdcBaseUnitsToDisplayString(tier.procurementPriceCapUsdcBaseUnits)}
+
Bonus flip
+
4% chance of a second card on every opening
+
+ + {isLocked ? ( +
+ This tier is currently locked during beta + {isGated + ? " pending legal, inventory, financial, security, and responsible-purchasing release review" + : ""} + . It cannot be purchased regardless of client state — this is enforced server-side. +
+ ) : ( + + Open Pack + + )} + +

+ No profit is guaranteed. Reference values are market estimates, not resale guarantees.{" "} + + How fairness works + + {" · "} + + Pack rules + +

+
+
+ +
+

Published probability bands

+ {entries.length === 0 ? ( +

Pool not yet published for this tier.

+ ) : ( +
+ + + + + + + + + + + + {entries.map((e) => ( + + + + + + + + ))} + +
Example cardSetConditionReference valueProbability band
{e.cardName}{e.setName} + {e.minCondition.replaceAll("_", " ")}+ + + {e.referenceValueUsdcBaseUnits != null + ? `$${usdcBaseUnitsToDisplayString(e.referenceValueUsdcBaseUnits)}` + : "—"} + {e.probabilityBandLabel}
+
+ )} +
+ +
+

Supplier fulfillment

+

+ The card you receive is sourced from approved supplier inventory at the moment of purchase + and shipped directly from the supplier to your verified address. If the selected listing + becomes unavailable before purchase, PackX402 follows a documented substitution process — + see{" "} + + refund & unavailable-listing procedure + + . +

+
+
+ ); +} diff --git a/src/app/packs/page.tsx b/src/app/packs/page.tsx new file mode 100644 index 0000000..ba62e1c --- /dev/null +++ b/src/app/packs/page.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import { db } from "@/server/db/client"; +import { packTiers } from "@/server/db/schema"; +import { asc } from "drizzle-orm"; +import { usdcBaseUnitsToDisplayString } from "@/shared/money"; +import { serverEnv } from "@/server/env"; +import { PackArt } from "@/components/pack-art/PackArt"; +import type { PackTierKey } from "@/server/config/pack-tiers"; + +export const revalidate = 60; + +async function getTiers() { + try { + return await db.select().from(packTiers).orderBy(asc(packTiers.sortOrder)); + } catch { + return []; + } +} + +export default async function MarketplacePage() { + const tiers = await getTiers(); + const highValueGateEnabled = serverEnv.FEATURE_HIGH_VALUE_PACKS_ENABLED; + + return ( +
+
+

Pack marketplace

+

+ Every tier below is server-gated by network and value: locked tiers cannot be purchased + regardless of what a client sends, and no pack outcome is guaranteed or profit-implying. +

+
+ + {tiers.length === 0 ? ( +

+ No tiers loaded — run npm run db:seed against a local + database (see README.md). +

+ ) : ( +
    + {tiers.map((tier) => { + const isGated = tier.requiresHighValueReleaseGate && !highValueGateEnabled; + const isLocked = tier.locked || isGated; + return ( +
  • + +
    + + {tier.cardGames.join(" · ") || "Pokémon · Yu-Gi-Oh"} + + {isLocked && ( + + Locked + + )} +
    + +

    {tier.name}

    +

    + ${usdcBaseUnitsToDisplayString(tier.priceUsdcBaseUnits)} · shipping{" "} + {tier.shippingTreatment === "included" ? "included" : "separate"} +

    +

    + {tier.weeklyFreePackEligible + ? "Eligible for weekly free pack" + : "Not free-pack eligible"} +

    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..f82923a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,210 @@ -import Image from "next/image"; +import Link from "next/link"; +import { db } from "@/server/db/client"; +import { packTiers } from "@/server/db/schema"; +import { asc, eq } from "drizzle-orm"; +import { FeaturedPacksCarousel } from "./FeaturedPacksCarousel"; +import { InteractivePackDemo } from "@/components/pack-art/InteractivePackDemo"; +import type { PackTierKey } from "@/server/config/pack-tiers"; + +export const revalidate = 60; + +async function getFeaturedTiers() { + try { + return await db + .select() + .from(packTiers) + .where(eq(packTiers.locked, false)) + .orderBy(asc(packTiers.sortOrder)) + .limit(4); + } catch { + // DB not reachable (e.g. this page rendered before `docker compose up` + seed). + return []; + } +} + +export default async function HomePage() { + const featured = await getFeaturedTiers(); -export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. +
+
+
+

+ Supplier-backed · Provably fair · Wallet-native +

+

+ Open. Verify. Collect.

-

- Looking for a starting point or more instructions? Head over to{" "} - + PackX402 pairs Algorand x402 payments with a cryptographically provable pack-opening + engine. Every card is sourced from approved supplier inventory and shipped directly to + you — PackX402 never warehouses cards during beta. +

+
+ - Templates - {" "} - or the{" "} - + - Learning - {" "} - center. + How fairness works + +
+
+
+ +
+
+
+

+ Try it right now +

+

Rip a pack. No wallet needed.

+

+ This is the exact drag-to-rip, spin, and reveal sequence every real pack goes + through — just without a real card on the other end. +

+
+ +
+ + +
+

How the algorithm decides your card

+
    + + + + +
+ + Full technical breakdown → + +
+
+
+
+ +
+
+

Featured pack tiers

+ + View all tiers → + +
+ {featured.length === 0 ? ( +

+ Pack catalog is not loaded yet — run{" "} + docker compose up -d,{" "} + npm run db:migrate, and{" "} + npm run db:seed to populate tiers.

+ ) : ( + ({ + tierKey: tier.key as PackTierKey, + tierName: tier.name, + price: tier.priceUsdcBaseUnits, + locked: tier.locked, + }))} + /> + )} +
+ +
+
+ + +
-
+ +
+
+

Responsible purchasing

+

+ Packs are randomized physical products. Card values can change and are not guaranteed — + PackX402 is not an investment platform. Set daily, weekly, and monthly purchase limits, + or pause your account any time in your{" "} + + Responsible Purchasing controls + + . +

-

+ +
+ ); +} + +function InfoCard({ title, body }: { title: string; body: string }) { + return ( +
+

{title}

+

{body}

); } + +function AlgorithmStep({ + number, + title, + body, + code, +}: { + number: string; + title: string; + body: string; + code: string; +}) { + return ( +
  • + + {number} + +
    +

    {title}

    +

    {body}

    + + {code} + +
    +
  • + ); +} diff --git a/src/app/responsible-purchasing/page.tsx b/src/app/responsible-purchasing/page.tsx new file mode 100644 index 0000000..76e5e63 --- /dev/null +++ b/src/app/responsible-purchasing/page.tsx @@ -0,0 +1,52 @@ +export const metadata = { title: "Responsible Purchasing — PackX402" }; + +export default function ResponsiblePurchasingPage() { + return ( +
    +

    Responsible Purchasing

    +

    + PackX402 packs are randomized physical products. Card values can change and are not + guaranteed — PackX402 is not an investment platform. These controls are always available + from your account. +

    + +
    + + + + +
    + +

    + Need help? Visit{" "} + + Support + {" "} + or contact a responsible-gambling resource such as the National Council on Problem Gambling + (1-800-522-4700) — while PackX402 is not gambling, the same support resources can help with + compulsive-spending patterns. +

    +
    + ); +} + +function Control({ title, body }: { title: string; body: string }) { + return ( +
    +

    {title}

    +

    {body}

    +
    + ); +} diff --git a/src/app/shipping-addresses/page.tsx b/src/app/shipping-addresses/page.tsx new file mode 100644 index 0000000..620d686 --- /dev/null +++ b/src/app/shipping-addresses/page.tsx @@ -0,0 +1,44 @@ +import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME, validateSessionToken } from "@/server/auth/session"; +import { GoogleSignInButton } from "@/components/auth/GoogleSignInButton"; +import { ShippingAddressManager } from "@/components/shipping/ShippingAddressManager"; + +export const revalidate = 0; + +/** + * Where a user manages the address(es) their pulled cards ship to — the piece the + * supplier-purchase worker (src/server/suppliers/purchase-worker.ts) needs on file + * before it can complete a real purchase (it fails clearly with + * `no_shipping_address_on_file` otherwise). + */ +export default async function ShippingAddressesPage() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + const session = token ? await validateSessionToken(token) : null; + + if (!session) { + return ( +
    +

    Sign in to continue

    +

    + Shipping addresses are tied to your account. +

    + +
    + ); + } + + return ( +
    +

    Shipping addresses

    +

    + Where PackX402 ships the cards you pull. Your default address is used + automatically — no address on file means an opened pack can't be shipped yet. +

    + +
    + ); +} diff --git a/src/components/auth/AuthAwareHeaderActions.tsx b/src/components/auth/AuthAwareHeaderActions.tsx new file mode 100644 index 0000000..b8bfc09 --- /dev/null +++ b/src/components/auth/AuthAwareHeaderActions.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; + +/** + * Checks auth state client-side via GET /api/auth/session after mount, rather than in the + * root layout via cookies() — that would force every page in the app to render + * dynamically (no static generation at all) just for a header link/button. Trades a brief + * flash of the signed-out state on first paint for keeping the rest of the site statically + * generated. + */ +function useAuthenticated(): boolean { + const [authenticated, setAuthenticated] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch("/api/auth/session") + .then((res) => res.json()) + .then((data) => { + if (!cancelled) setAuthenticated(Boolean(data.authenticated)); + }) + .catch(() => { + // Network error — stay in the signed-out default rather than block the header. + }); + return () => { + cancelled = true; + }; + }, []); + + return authenticated; +} + +/** "My Openings" / "Account" nav links, shown only once signed in. */ +export function AuthAwareNavLink() { + const authenticated = useAuthenticated(); + if (!authenticated) return null; + return ( + <> + + My Openings + + + Account + + + ); +} + +export interface AuthAwareHeaderActionsProps { + /** Rendered once we know the visitor is signed out — a Server Component + * (GoogleSignInButton uses a server action), so it's passed down as a slot rather than + * imported here. */ + signedOutSlot: React.ReactNode; + /** Rendered once we know the visitor is signed in. */ + signedInSlot: React.ReactNode; +} + +/** The header's right-side sign-in/sign-out action. */ +export function AuthAwareHeaderActions({ + signedOutSlot, + signedInSlot, +}: AuthAwareHeaderActionsProps) { + const authenticated = useAuthenticated(); + return <>{authenticated ? signedInSlot : signedOutSlot}; +} diff --git a/src/components/auth/EligibilityForm.tsx b/src/components/auth/EligibilityForm.tsx new file mode 100644 index 0000000..6b0a651 --- /dev/null +++ b/src/components/auth/EligibilityForm.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { COUNTRIES, US_STATES } from "@/shared/countries"; + +const CSRF_COOKIE_NAME = "packx402_csrf"; + +function readCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +const DENIAL_REASON_COPY: Record = { + under_18_by_dob: "You must be 18 or older to use PackX402.", + age_not_acknowledged: "You must confirm you're 18 or older to continue.", + location_blocked_country: "PackX402 isn't available in your country yet.", + location_blocked_state: "PackX402 isn't available in your state yet.", +}; + +export interface EligibilityFormProps { + /** Where to send the user after eligibility passes. */ + nextUrl?: string; +} + +/** + * Collects the DOB/country/18+ acknowledgment that neither Google sign-in nor a wallet + * signature captures on their own (see submitOAuthEligibility in auth-service.ts). This is + * a UX convenience only — the real gate is server-side in createPackOffer(), which + * rejects any offer for a user with no passing eligibility record regardless of what this + * form does or doesn't submit. + */ +export function EligibilityForm({ nextUrl = "/packs" }: EligibilityFormProps) { + const router = useRouter(); + const [dateOfBirth, setDateOfBirth] = useState(""); + const [ageAcknowledged, setAgeAcknowledged] = useState(false); + const [country, setCountry] = useState("US"); + const [stateOrProvince, setStateOrProvince] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState([]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setErrors([]); + setSubmitting(true); + try { + const csrfToken = readCookie(CSRF_COOKIE_NAME); + const res = await fetch("/api/auth/oauth/complete-eligibility", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(csrfToken ? { "x-csrf-token": csrfToken } : {}), + }, + body: JSON.stringify({ + dateOfBirth, + ageAcknowledged18Plus: ageAcknowledged, + country, + stateOrProvince: country === "US" && stateOrProvince ? stateOrProvince : undefined, + }), + }); + + if (res.status === 401) { + setErrors(["Your session expired — sign in again and retry."]); + return; + } + + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data.eligible) { + const reasons: string[] = Array.isArray(data.reasons) ? data.reasons : []; + setErrors( + reasons.length > 0 + ? reasons.map((r) => DENIAL_REASON_COPY[r] ?? r) + : ["Could not verify eligibility. Please check your details and try again."], + ); + return; + } + + router.push(nextUrl); + router.refresh(); + } catch { + setErrors(["Network error — please try again."]); + } finally { + setSubmitting(false); + } + } + + return ( +
    +
    + + setDateOfBirth(e.target.value)} + className="border-border-subtle bg-surface w-full rounded-md border px-3 py-2 text-sm" + /> +
    + +
    + + +
    + + {country === "US" && ( +
    + + +
    + )} + + + + {errors.length > 0 && ( +
    + {errors.map((err) => ( +

    {err}

    + ))} +
    + )} + + + +

    + Required once per account before opening a pack. Packs are randomized physical + products — see our{" "} + + Responsible Purchasing Policy + + . +

    +
    + ); +} diff --git a/src/components/auth/GoogleSignInButton.tsx b/src/components/auth/GoogleSignInButton.tsx new file mode 100644 index 0000000..5058621 --- /dev/null +++ b/src/components/auth/GoogleSignInButton.tsx @@ -0,0 +1,34 @@ +import { nextAuthSignIn } from "@/server/auth/google-oauth"; + +export interface GoogleSignInButtonProps { + callbackUrl?: string; + className?: string; +} + +/** + * Server-action sign-in trigger — the recommended Auth.js v5 App Router pattern. Calls + * our own exported `signIn` (bound to the Google-only config in google-oauth.ts, mounted + * at /api/oauth), not the next-auth/react client hooks, so there's no basePath mismatch + * to keep in sync. This is one of exactly two ways into a PackX402 account — the other is + * a direct wallet signature (see src/server/auth/auth-service.ts's completeWalletAuth). + */ +export function GoogleSignInButton({ callbackUrl, className }: GoogleSignInButtonProps) { + return ( +
    { + "use server"; + await nextAuthSignIn("google", { redirectTo: callbackUrl ?? "/" }); + }} + > + +
    + ); +} diff --git a/src/components/auth/LogoutButton.tsx b/src/components/auth/LogoutButton.tsx new file mode 100644 index 0000000..d9095a1 --- /dev/null +++ b/src/components/auth/LogoutButton.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +const CSRF_COOKIE_NAME = "packx402_csrf"; + +function readCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +export interface LogoutButtonProps { + className?: string; +} + +/** Double-submit CSRF pattern (see src/server/security/csrf.ts): reads the + * client-readable CSRF cookie and echoes it back as a header. */ +export function LogoutButton({ className }: LogoutButtonProps) { + const router = useRouter(); + + async function handleLogout() { + const csrfToken = readCookie(CSRF_COOKIE_NAME); + await fetch("/api/auth/logout", { + method: "POST", + headers: csrfToken ? { "x-csrf-token": csrfToken } : {}, + }); + router.refresh(); + } + + return ( + + ); +} diff --git a/src/components/auth/SessionsManager.tsx b/src/components/auth/SessionsManager.tsx new file mode 100644 index 0000000..5f73162 --- /dev/null +++ b/src/components/auth/SessionsManager.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +const CSRF_COOKIE_NAME = "packx402_csrf"; + +function readCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +interface SessionRow { + id: string; + authMethod: string; + userAgent: string | null; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + isCurrent: boolean; +} + +/** Lists the signed-in user's own active sessions (never another user's) and lets them + * sign out everywhere at once — a real security control, not decoration: if a wallet + * signature or Google token is ever compromised, this is how a user cuts it off. */ +export function SessionsManager() { + const router = useRouter(); + const [sessions, setSessions] = useState(null); + const [revoking, setRevoking] = useState(false); + + useEffect(() => { + let cancelled = false; + fetch("/api/auth/sessions") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!cancelled) setSessions(data && Array.isArray(data.sessions) ? data.sessions : []); + }) + .catch(() => { + if (!cancelled) setSessions([]); + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleRevokeAll() { + setRevoking(true); + try { + const csrfToken = readCookie(CSRF_COOKIE_NAME); + await fetch("/api/auth/sessions/revoke-all", { + method: "POST", + headers: csrfToken ? { "x-csrf-token": csrfToken } : {}, + }); + router.push("/"); + router.refresh(); + } finally { + setRevoking(false); + } + } + + if (sessions === null) { + return

    Loading…

    ; + } + + return ( +
    +
      + {sessions.map((s) => ( +
    • +
      +

      + {s.authMethod === "google" ? "Google sign-in" : "Wallet sign-in"} + {s.isCurrent && ( + This device + )} +

      +

      + Last active {new Date(s.lastSeenAt).toLocaleString()} +

      +
      +
    • + ))} +
    + +
    + ); +} diff --git a/src/components/pack-art/CardBack.tsx b/src/components/pack-art/CardBack.tsx new file mode 100644 index 0000000..b065da8 --- /dev/null +++ b/src/components/pack-art/CardBack.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; + +export interface CardBackProps { + /** Explicit override. Defaults to `/cards/pack402-card-back.png` per docs/ASSET_MANIFEST.md. */ + imageSrc?: string; + angle?: "front" | "three-quarter"; + className?: string; + priority?: boolean; +} + +/** + * The generic PACK402 card back shown during a reveal before the authentic card is known, + * and as the fallback face whenever a resolved card image is unavailable (see + * src/server/card-images/resolver.ts — a missing image never changes the selected card, + * it only changes what's rendered for it). + */ +export function CardBack({ + imageSrc, + angle = "front", + className, + priority = false, +}: CardBackProps) { + const [failed, setFailed] = useState(false); + const resolvedSrc = imageSrc ?? "/cards/pack402-card-back.png"; + + return ( +
    + {!failed ? ( + PACK402 card back setFailed(true)} + /> + ) : ( + + )} +
    + ); +} + +function CardBackFallback() { + return ( +
    + {/* Four verification corners */} + {[ + "top-2 left-2 border-t border-l", + "top-2 right-2 border-t border-r", + "bottom-2 left-2 border-b border-l", + "bottom-2 right-2 border-b border-r", + ].map((pos) => ( + + ))} + +

    PACK402

    +
    + ); +} diff --git a/src/components/pack-art/CardOverlaySlot.tsx b/src/components/pack-art/CardOverlaySlot.tsx new file mode 100644 index 0000000..6833d4a --- /dev/null +++ b/src/components/pack-art/CardOverlaySlot.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { CardBack } from "./CardBack"; +import { cardImageDisclaimer, type ResolvedCardImage } from "@/shared/card-image"; + +export interface CardOverlaySlotProps { + cardName: string; + /** + * Result of the server-side CardImageResolver (see src/server/card-images/resolver.ts). + * `null`/`undefined` while still loading or resolving — renders the PACK402 card back, + * never a blank void. A resolution failure NEVER changes which card was won; it only + * changes what image represents it (falls back to the card back). + */ + resolved?: ResolvedCardImage | null; + loading?: boolean; + className?: string; +} + +/** + * The blank card-overlay target used during and after the opening animation. Per the + * production-compositing rule, the animation itself always ends on a blank card face — + * this component is what actually paints the resolved authentic card image on top of + * that blank target once fairness has completed. + */ +export function CardOverlaySlot({ + cardName, + resolved, + loading = false, + className, +}: CardOverlaySlotProps) { + const [imageFailed, setImageFailed] = useState(false); + + if (loading || !resolved) { + return ( +
    + +
    + ); + } + + const showFallback = imageFailed || resolved.imageType === "PLACEHOLDER"; + + return ( +
    +
    + {!showFallback ? ( + {`${cardName} setImageFailed(true)} + /> + ) : ( + + )} +
    + {!showFallback && ( +

    + {cardImageDisclaimer(resolved)} + {resolved.attribution ? ` ${resolved.attribution}` : ""} +

    + )} +
    + ); +} diff --git a/src/components/pack-art/CardRevealWheel.tsx b/src/components/pack-art/CardRevealWheel.tsx new file mode 100644 index 0000000..ca4efc3 --- /dev/null +++ b/src/components/pack-art/CardRevealWheel.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { motion, useMotionValue, animate, useReducedMotion } from "motion/react"; +import { CardBack } from "./CardBack"; +import { CardOverlaySlot } from "./CardOverlaySlot"; +import type { ResolvedCardImage } from "@/shared/card-image"; + +export interface SpinPossibleCard { + cardName: string; + imageUrl: string; +} + +export interface CardRevealWheelProps { + cardName: string; + resolvedImage: ResolvedCardImage | null; + /** Real cards this pack's pool could actually contain, cycled through as the non-winner + * spin slots — shows genuine possible pulls instead of a blank card back. Falls back to + * generic card backs when empty (e.g. no images resolved yet). Never includes or implies + * which one is the actual winner — that's `resolvedImage`/`cardName` alone. */ + possibleCards?: SpinPossibleCard[]; + /** How many slots spin past before landing on the winner. */ + spinCount?: number; + onSpinComplete?: () => void; + className?: string; +} + +const SLOT_WIDTH = 200; + +/** + * After the pack is ripped open, this spins a horizontal strip of generic card backs past + * the viewport — fast at first, decelerating — until it comes to rest exactly on the final + * slot, which then flips over (3D rotateY) to reveal the actual resolved card. The + * deceleration is purely a presentation animation; the winning card was already determined + * by the fairness engine before this component ever mounts (see docs/FAIRNESS_PROTOCOL.md) + * — nothing here influences which card is won. + */ +export function CardRevealWheel({ + cardName, + resolvedImage, + possibleCards = [], + spinCount = 10, + onSpinComplete, + className, +}: CardRevealWheelProps) { + const reducedMotion = useReducedMotion(); + const x = useMotionValue(0); + const [spinning, setSpinning] = useState(!reducedMotion); + const [flipped, setFlipped] = useState(reducedMotion); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + if (reducedMotion) { + onSpinComplete?.(); + return; + } + + const target = -(spinCount * SLOT_WIDTH); + const controls = animate(x, target, { + duration: 2.4, + ease: [0.1, 0.55, 0.15, 1], // fast start, long decelerating tail — settles, doesn't overshoot + }); + controls.then(() => { + setSpinning(false); + onSpinComplete?.(); + window.setTimeout(() => setFlipped(true), 150); + }); + }, [x, spinCount, reducedMotion, onSpinComplete]); + + const slots = Array.from({ length: spinCount + 1 }); + + return ( +
    +
    +
    + + + {slots.map((_, i) => { + const isWinnerSlot = i === spinCount; + return ( +
    + {isWinnerSlot ? ( +
    + + {flipped ? ( + + ) : ( + + )} + +
    + ) : possibleCards.length > 0 ? ( + + ) : ( + + )} +
    + ); + })} +
    + + {spinning && ( + + Spinning through cards… + + )} +
    + ); +} diff --git a/src/components/pack-art/CoinFlip.tsx b/src/components/pack-art/CoinFlip.tsx new file mode 100644 index 0000000..d5f4ac9 --- /dev/null +++ b/src/components/pack-art/CoinFlip.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; +import { motion, useReducedMotion } from "motion/react"; + +export interface CoinFlipProps { + /** The already-determined outcome from the server (see deriveBonusFlipHit in + * src/server/fairness/engine.ts) — this component only animates it, it never decides + * the outcome itself. `true` means the 4% bonus-flip hit and a second real card was + * awarded alongside the primary pull. */ + hit: boolean; + /** Fires once the flip animation finishes. */ + onComplete: () => void; + className?: string; +} + +/** + * Animates the bonus-flip result the server already determined (a fixed 4% chance, + * derived from the same committed fairness seed as the primary pull — see + * offer-service.ts's settleOfferAndOpen). This component has no randomness of its own; + * `hit` is real data, not a client-side coin toss. + */ +export function CoinFlip({ hit, onComplete, className }: CoinFlipProps) { + const reducedMotion = useReducedMotion(); + + useEffect(() => { + if (reducedMotion) { + onComplete(); + return; + } + const timeout = window.setTimeout(onComplete, 1100); + return () => window.clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps -- onComplete is expected to be stable per mount + }, [reducedMotion]); + + return ( +
    + +

    + Bonus flip… +

    +
    + ); +} diff --git a/src/components/pack-art/FourPackStage.tsx b/src/components/pack-art/FourPackStage.tsx new file mode 100644 index 0000000..7c5d87f --- /dev/null +++ b/src/components/pack-art/FourPackStage.tsx @@ -0,0 +1,66 @@ +"use client"; + +import type { PackTierKey } from "@/server/config/pack-tiers"; +import { PackArt } from "./PackArt"; +import { CardOverlaySlot } from "./CardOverlaySlot"; +import { ResultEffect, type ResultIntensity } from "./ResultEffect"; +import type { ResolvedCardImage } from "@/shared/card-image"; +import type { OpeningPhase } from "./OpeningStage"; + +export interface FourPackSlot { + tierKey: PackTierKey; + tierName: string; + price: number; + phase: OpeningPhase; + cardName?: string; + resolvedImage?: ResolvedCardImage | null; + resultIntensity?: ResultIntensity; +} + +export interface FourPackStageProps { + slots: [FourPackSlot, FourPackSlot, FourPackSlot, FourPackSlot]; + className?: string; +} + +/** + * Synchronized four-pack opening grid: an exact 2x2 formation with equal spacing and + * identical scale, per the creative brief. Each slot independently tracks its own phase + * so one card can resolve into a "major" result while the other three stay standard, + * without covering or resizing any slot. + */ +export function FourPackStage({ slots, className }: FourPackStageProps) { + return ( +
    + {slots.map((slot, i) => ( +
    + {slot.phase === "idle" || slot.phase === "tearing" ? ( + + ) : ( + <> + + + + )} +
    + ))} +
    + ); +} diff --git a/src/components/pack-art/InteractivePackDemo.tsx b/src/components/pack-art/InteractivePackDemo.tsx new file mode 100644 index 0000000..d2aaf02 --- /dev/null +++ b/src/components/pack-art/InteractivePackDemo.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState } from "react"; +import { PackShelf, type PackShelfItem } from "./PackShelf"; +import { OpeningStage, type OpeningPhase } from "./OpeningStage"; +import { CoinFlip } from "./CoinFlip"; +import { CardOverlaySlot } from "./CardOverlaySlot"; +import { RIP_VIDEO_BY_TIER } from "./rip-video-map"; + +// Only Spark and Starter are unlocked right now (see pack-tiers.ts's TESTNET_CEILING) — +// the demo mirrors real availability rather than showcasing locked tiers. +const DEMO_TIERS: PackShelfItem[] = [ + { tierKey: "spark", tierName: "Spark", price: 500_000 }, + { tierKey: "starter", tierName: "Starter", price: 1_000_000 }, +]; + +// Real Pokemon TCG API images (same ones resolveCardImage would actually resolve in +// production) — this demo never touches the DB, so these stand in for a tier's real pool +// preview from /api/packs/[tierKey]/spin-preview. +const DEMO_POSSIBLE_CARDS = [ + { cardName: "Charmander", imageUrl: "https://images.pokemontcg.io/base1/46_hires.png" }, + { cardName: "Pikachu", imageUrl: "https://images.pokemontcg.io/base1/58_hires.png" }, + { cardName: "Squirtle", imageUrl: "https://images.pokemontcg.io/base1/63_hires.png" }, + { cardName: "Blastoise", imageUrl: "https://images.pokemontcg.io/base1/2_hires.png" }, + { cardName: "Venusaur", imageUrl: "https://images.pokemontcg.io/base1/15_hires.png" }, + { cardName: "Charizard", imageUrl: "https://images.pokemontcg.io/base1/4_hires.png" }, +]; + +const DEMO_RESOLVED_IMAGE = { + imageUrl: "https://images.pokemontcg.io/base1/4_hires.png", + imageType: "CATALOG_RENDER" as const, + provider: "pokemon_tcg" as const, + attribution: "Card image via the Pokémon TCG API (pokemontcg.io).", + isExactItem: false, + fallbackUsed: false, +}; + +const DEMO_BONUS_IMAGE = { + ...DEMO_RESOLVED_IMAGE, + imageUrl: "https://images.pokemontcg.io/base1/58_hires.png", +}; + +export interface InteractivePackDemoProps { + className?: string; +} + +/** + * A no-database, no-purchase demo of the real rip → spin → reveal sequence — same + * components the actual opening theater uses (PackShelf, RipToOpen via OpeningStage, + * CardRevealWheel, CoinFlip), just with a fixed placeholder outcome instead of a real + * fairness-selected card. Used on the landing page and at /dev/rip-preview. Always clearly + * labeled as a demo — never implies a real card was won or a real payment happened. + */ +export function InteractivePackDemo({ className }: InteractivePackDemoProps) { + const [selected, setSelected] = useState(DEMO_TIERS[0]); + const [hasSelectedPack, setHasSelectedPack] = useState(false); + const [phase, setPhase] = useState("idle"); + const [showCoinFlip, setShowCoinFlip] = useState(false); + const [bonusHit, setBonusHit] = useState(false); + const [showBonusCard, setShowBonusCard] = useState(false); + const [wheelKey, setWheelKey] = useState(0); + + function reset() { + setPhase("idle"); + setShowCoinFlip(false); + setShowBonusCard(false); + setWheelKey((k) => k + 1); + } + + function handleRipped() { + // Mirrors the real opening flow (OpenPackClient.handleRipped): show the torn-pack art + // for a beat before the reveal wheel spins, rather than jumping straight to + // "revealing" — long enough to actually register, not just flash past. + setPhase("tearing"); + window.setTimeout(() => setPhase("revealing"), 1100); + } + + function handleRevealSettled() { + setPhase("resolved"); + // Demo-only: real odds are a fixed 4% derived server-side from the committed + // fairness seed (see deriveBonusFlipHit) — this ~40% just makes the flourish easier to + // catch while demoing, not a claim about the real trigger rate. Whatever the rate, + // the coin-flip UI itself must only ever appear on a hit — never on every spin. + const hit = Math.random() < 0.4; + setBonusHit(hit); + if (hit) { + window.setTimeout(() => setShowCoinFlip(true), 500); + } + } + + return ( +
    +

    + Demo only — no purchase, no database, no real card outcome. See it live at{" "} + /packs. +

    + + {hasSelectedPack ? ( + <> +

    + Drag across the top to rip {selected.tierName} open. +

    +
    + + + {showCoinFlip && ( + { + setShowCoinFlip(false); + if (bonusHit) setShowBonusCard(true); + }} + className="mt-4" + /> + )} + + {showBonusCard && ( +
    +

    + Bonus flip hit! You also got: +

    + +
    + )} + + +
    + + ) : ( +

    + Spin to a pack below, then tap it to select it. +

    + )} + + { + setSelected(item); + setHasSelectedPack(true); + reset(); + }} + className="mt-6" + /> +
    + ); +} diff --git a/src/components/pack-art/OpeningStage.tsx b/src/components/pack-art/OpeningStage.tsx new file mode 100644 index 0000000..50461b1 --- /dev/null +++ b/src/components/pack-art/OpeningStage.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { PackTierKey } from "@/server/config/pack-tiers"; +import { PackArt } from "./PackArt"; +import { CardOverlaySlot } from "./CardOverlaySlot"; +import { CardRevealWheel, type SpinPossibleCard } from "./CardRevealWheel"; +import { RipToOpen } from "./RipToOpen"; +import { RipToOpenVideo } from "./RipToOpenVideo"; +import { ResultEffect, type ResultIntensity } from "./ResultEffect"; +import type { ResolvedCardImage } from "@/shared/card-image"; + +export type OpeningPhase = "idle" | "tearing" | "revealing" | "resolved"; + +export interface OpeningStageProps { + tierKey: PackTierKey; + tierName: string; + price: number; + phase: OpeningPhase; + cardName?: string; + resolvedImage?: ResolvedCardImage | null; + /** Real possible pulls from this pack's pool, shown cycling during the spin — see + * CardRevealWheel. */ + possibleCards?: SpinPossibleCard[]; + resultIntensity?: ResultIntensity; + /** Future hooks — see docs/ASSET_MANIFEST.md (pack402_idle_*, pack402_open_single_*). */ + idleVideoSrc?: string; + openingVideoSrc?: string; + /** A real Higgsfield-generated tear-open video for this tier (see + * docs/HIGGSFIELD_PROMPTS.md) — when set, the drag gesture scrubs directly through it + * (RipToOpenVideo) instead of the CSS clip-path illusion (RipToOpen). Takes priority + * over idleVideoSrc. `ripOpenStillSrc` is the still shown during the brief "tearing" + * phase right after the video finishes — should match the video's own last frame. */ + ripVideoSrc?: string; + ripOpenStillSrc?: string; + onSkipReveal?: () => void; + /** Fires once the user's drag-to-rip gesture commits (see RipToOpen). */ + onRipped?: () => void; + /** Fires once the card-reveal wheel finishes spinning and lands on the winning card. */ + onSpinComplete?: () => void; + className?: string; +} + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const listener = (e: MediaQueryListEvent) => setReduced(e.matches); + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + }, []); + return reduced; +} + +/** + * Single-pack opening stage: composes PackArt (idle/opening), the CardOverlaySlot + * reveal, and a ResultEffect layer, always keeping the card-overlay position stable so + * swapping in real Higgsfield video later never requires a layout change. Honors + * reduced-motion by collapsing straight to the resolved state. + */ +export function OpeningStage({ + tierKey, + tierName, + price, + phase, + cardName, + resolvedImage, + possibleCards, + resultIntensity = "standard", + idleVideoSrc, + openingVideoSrc, + ripVideoSrc, + ripOpenStillSrc, + onSkipReveal, + onRipped, + onSpinComplete, + className, +}: OpeningStageProps) { + const prefersReducedMotion = usePrefersReducedMotion(); + const effectivePhase = prefersReducedMotion && phase !== "idle" ? "resolved" : phase; + + return ( +
    + {effectivePhase === "idle" ? ( + ripVideoSrc ? ( + onRipped?.()} + /> + ) : idleVideoSrc ? ( +
    + ); +} diff --git a/src/components/pack-art/Pack3DTilt.tsx b/src/components/pack-art/Pack3DTilt.tsx new file mode 100644 index 0000000..281fd97 --- /dev/null +++ b/src/components/pack-art/Pack3DTilt.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useRef } from "react"; +import { + motion, + useMotionValue, + useSpring, + useReducedMotion, + type PanInfo, +} from "motion/react"; + +export interface Pack3DTiltProps { + children: React.ReactNode; + className?: string; +} + +const DRAG_TO_DEGREES = 0.35; // px of drag -> degrees of rotation +const MAX_TILT_DEGREES = 55; + +/** + * Wraps a single PackArt so it behaves like a floating 3D object: dragging/touching it + * spins it around the Y axis (and nudges X tilt from vertical drag), and it springs back + * toward a gentle idle float when released. Distinct from PackCarousel, which spins + * between different packs — this spins one pack in place for a tactile "hold it" feel. + */ +export function Pack3DTilt({ children, className }: Pack3DTiltProps) { + const reducedMotion = useReducedMotion(); + const rotateY = useMotionValue(0); + const rotateX = useMotionValue(0); + const springY = useSpring(rotateY, { stiffness: 120, damping: 14 }); + const springX = useSpring(rotateX, { stiffness: 120, damping: 14 }); + const dragStartY = useRef(0); + const dragStartX = useRef(0); + + if (reducedMotion) { + return
    {children}
    ; + } + + function handleDrag(_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) { + const nextY = dragStartY.current + info.offset.x * DRAG_TO_DEGREES; + const nextX = dragStartX.current - info.offset.y * DRAG_TO_DEGREES; + rotateY.set(Math.max(-MAX_TILT_DEGREES, Math.min(MAX_TILT_DEGREES, nextY))); + rotateX.set(Math.max(-MAX_TILT_DEGREES, Math.min(MAX_TILT_DEGREES, nextX))); + } + + function handleDragStart() { + dragStartY.current = rotateY.get(); + dragStartX.current = rotateX.get(); + } + + function handleDragEnd() { + // Spring back to a neutral, front-facing rest pose. + rotateY.set(0); + rotateX.set(0); + } + + return ( +
    + + {children} + +
    + ); +} diff --git a/src/components/pack-art/PackArt.tsx b/src/components/pack-art/PackArt.tsx new file mode 100644 index 0000000..a0d67a8 --- /dev/null +++ b/src/components/pack-art/PackArt.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import type { PackTierKey } from "@/server/config/pack-tiers"; +import { getTierTreatment } from "./tier-treatments"; +import { usdcBaseUnitsToDisplayString } from "@/shared/money"; + +export type PackArtSize = "thumbnail" | "card" | "hero" | "detail"; + +export interface PackArtProps { + tierKey: PackTierKey; + tierName: string; + /** Price in integer USDC base units — never a pre-formatted string, per project money rules. */ + price: number; + accentColor?: string; + material?: string; + /** Explicit override. Defaults to `/packs/{tierKey}.png` per docs/ASSET_MANIFEST.md. */ + imageSrc?: string; + priority?: boolean; + size?: PackArtSize; + locked?: boolean; + featured?: boolean; + animationState?: "idle" | "opening" | "opened"; + /** Shows the tier's torn-open art variant (`/packs/{tierKey}-torn.png`) instead of the + * closed-pack art — used for the brief moment right after the rip gesture commits, before + * the reveal wheel spins. Falls back to the closed-pack art if no torn variant exists yet + * for this tier (see docs/ASSET_MANIFEST.md — not every tier has one). */ + torn?: boolean; + className?: string; +} + +const SIZE_SIZES_ATTR: Record = { + thumbnail: "(max-width: 640px) 45vw, 180px", + card: "(max-width: 640px) 90vw, (max-width: 1024px) 45vw, 320px", + hero: "(max-width: 1024px) 90vw, 480px", + detail: "(max-width: 1024px) 90vw, 560px", +}; + +/** + * Locked 2:3 vertical pack face, used consistently across the marketplace, landing page, + * pack detail, opening screen, collection, and social posts. Attempts to load the real + * asset at `/packs/{tierKey}.png` (see docs/ASSET_MANIFEST.md); on any load failure or + * when no real asset exists yet, renders a polished tier-branded CSS placeholder instead + * of a broken image or an empty box. Swapping in real Higgsfield renders requires no code + * changes — just adding the file at the documented path. + */ +export function PackArt({ + tierKey, + tierName, + price, + accentColor, + material, + imageSrc, + priority = false, + size = "card", + locked = false, + featured = false, + animationState = "idle", + torn = false, + className, +}: PackArtProps) { + const [imageFailed, setImageFailed] = useState(false); + const [tornImageFailed, setTornImageFailed] = useState(false); + const treatment = getTierTreatment(tierKey); + const resolvedAccent = accentColor ?? treatment.accentColor; + const baseSrc = imageSrc ?? `/packs/${tierKey}.png`; + const showTorn = torn && !tornImageFailed; + const resolvedSrc = showTorn ? `/packs/${tierKey}-torn.png` : baseSrc; + const showRealImage = !imageFailed; + + const altText = `${tierName} pack — PACK402, Only the Best Packx${locked ? " (locked)" : ""}`; + + return ( +
    + {showRealImage && ( + {altText} (showTorn ? setTornImageFailed(true) : setImageFailed(true))} + /> + )} + + {!showRealImage && ( + + )} + + {locked && ( +
    + + Locked + +
    + )} +
    + ); +} + +function PackArtFallback({ + tierKey, + tierName, + price, + material, + treatment, +}: { + tierKey: PackTierKey; + tierName: string; + price: number; + material: string; + treatment: ReturnType; +}) { + return ( +
    + {/* Seams */} +
    +
    + {/* Foil sheen sweep */} +
    + + {/* Crest */} +
    + +
    + + {/* Brand lockup — the "Only the Best Packx" slogan is a site-level tagline (see + SiteHeader/landing hero), deliberately not repeated on every individual pack face. */} +
    +

    + PACK402 +

    +
    + + {/* Tier + price */} +
    +

    {tierName}

    +

    ${usdcBaseUnitsToDisplayString(price)}

    +
    + + + Development placeholder artwork for the {tierKey} tier — not final Higgsfield-generated art. + +
    + ); +} + +function PackCrest({ color }: { color: string }) { + return ( + + ); +} diff --git a/src/components/pack-art/PackArtSkeleton.tsx b/src/components/pack-art/PackArtSkeleton.tsx new file mode 100644 index 0000000..fd3616e --- /dev/null +++ b/src/components/pack-art/PackArtSkeleton.tsx @@ -0,0 +1,13 @@ +/** + * Loading placeholder matching PackArt's locked 2:3 footprint, for use while tier data is + * still being fetched (e.g. a marketplace grid's initial server round-trip boundary). + */ +export function PackArtSkeleton({ className }: { className?: string }) { + return ( +
    + ); +} diff --git a/src/components/pack-art/PackCarousel.tsx b/src/components/pack-art/PackCarousel.tsx new file mode 100644 index 0000000..c0b1744 --- /dev/null +++ b/src/components/pack-art/PackCarousel.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { + motion, + useMotionValue, + useTransform, + animate, + useReducedMotion, + type PanInfo, + type MotionValue, +} from "motion/react"; +import { PackArt } from "./PackArt"; +import type { PackTierKey } from "@/server/config/pack-tiers"; + +export interface PackCarouselItem { + tierKey: PackTierKey; + tierName: string; + price: number; + locked?: boolean; +} + +export interface PackCarouselProps { + items: PackCarouselItem[]; + initialIndex?: number; + /** Fires whenever the centered pack changes, however it changed (drag, spin, keyboard). */ + onSelect?: (item: PackCarouselItem, index: number) => void; + /** Fires when the user taps/clicks the pack currently centered. */ + onActivate?: (item: PackCarouselItem, index: number) => void; + className?: string; +} + +const CARD_WIDTH = 180; // px spacing between adjacent pack centers in the strip +const VELOCITY_PER_INDEX = 500; // px/s of flick velocity per extra step of spin +const MAX_FLICK_STEPS = 14; // a hard flick can carry a full lap around the circle + +function mod(n: number, m: number): number { + return ((n % m) + m) % m; +} + +/** + * Infinite circular pack strip: every pack has a fixed slot on an endless ring, so + * neighboring packs are always visible sliding in from both sides — there is no + * clamped start or end. A slow drag nudges one step; a fast flick spins through several + * packs at once (driven by release velocity) and always wraps around smoothly. Pure + * catalog-browsing navigation — no outcome is ever randomized or selected here. + */ +export function PackCarousel({ + items, + initialIndex = 0, + onSelect, + onActivate, + className, +}: PackCarouselProps) { + const n = items.length; + const trackWidth = n * CARD_WIDTH; + const x = useMotionValue(-mod(initialIndex, n) * CARD_WIDTH); + const reducedMotion = useReducedMotion(); + const currentIndexRef = useRef(mod(initialIndex, n)); + + const settle = useCallback( + (targetX: number) => { + const controls = animate( + x, + targetX, + reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 30 }, + ); + controls.then(() => { + const idx = mod(Math.round(-targetX / CARD_WIDTH), n); + if (idx !== currentIndexRef.current) { + currentIndexRef.current = idx; + onSelect?.(items[idx], idx); + } + }); + }, + [x, reducedMotion, n, items, onSelect], + ); + + function step(delta: number) { + const nearestSnap = Math.round(x.get() / CARD_WIDTH) * CARD_WIDTH; + settle(nearestSnap - delta * CARD_WIDTH); + } + + function handleDragEnd(_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) { + const velocitySteps = Math.round(-info.velocity.x / VELOCITY_PER_INDEX); + const nearestSnap = Math.round(x.get() / CARD_WIDTH) * CARD_WIDTH; + const extra = Math.max(-MAX_FLICK_STEPS, Math.min(MAX_FLICK_STEPS, velocitySteps)); + settle(nearestSnap - extra * CARD_WIDTH); + } + + function handleItemClick(i: number) { + if (i === currentIndexRef.current) { + onActivate?.(items[i], i); + return; + } + // Shortest circular distance so clicking a neighbor rotates the short way around. + let delta = i - currentIndexRef.current; + if (delta > n / 2) delta -= n; + if (delta < -n / 2) delta += n; + step(delta); + } + + // Fixed height sized to the largest (fully-scaled, thumbnail-size) card plus breathing + // room — children are absolutely positioned, so the container can't size itself from them. + const containerHeight = CARD_WIDTH * 1.5 + 96; + + return ( +
    { + if (e.key === "ArrowRight") step(1); + else if (e.key === "ArrowLeft") step(-1); + }} + > + + ); +} + +function CarouselSlot({ + item, + index, + x, + trackWidth, + onClick, +}: { + item: PackCarouselItem; + index: number; + x: MotionValue; + trackWidth: number; + onClick: () => void; +}) { + // Wraps this slot's screen position into (-trackWidth/2, trackWidth/2], so exactly one + // instance of each pack is always near the visible center, regardless of how far `x` + // has drifted from repeated drags — the core of the infinite-circular-strip effect. + const wrapped = useTransform(x, (xv) => { + const raw = index * CARD_WIDTH + xv; + return mod(raw + trackWidth / 2, trackWidth) - trackWidth / 2; + }); + const units = useTransform(wrapped, (w) => w / CARD_WIDTH); + const scale = useTransform(units, (u) => Math.max(0.62, 1 - Math.abs(u) * 0.16)); + const opacity = useTransform(units, (u) => Math.max(0, 1 - Math.abs(u) * 0.32)); + const zIndex = useTransform(units, (u) => Math.round(10 - Math.abs(u))); + // 3D-drum illusion: packs off-center rotate away and recede in depth, like they're + // mounted on a rotating cylinder rather than a flat sliding strip. + const rotateY = useTransform(units, (u) => Math.max(-42, Math.min(42, u * -16))); + const z = useTransform(units, (u) => -Math.min(220, Math.abs(u) * 55)); + + return ( + + + + ); +} diff --git a/src/components/pack-art/PackShelf.tsx b/src/components/pack-art/PackShelf.tsx new file mode 100644 index 0000000..be0e2fc --- /dev/null +++ b/src/components/pack-art/PackShelf.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { PackArt } from "./PackArt"; +import type { PackTierKey } from "@/server/config/pack-tiers"; +import { usdcBaseUnitsToDisplayString } from "@/shared/money"; + +export interface PackShelfItem { + tierKey: PackTierKey; + tierName: string; + price: number; + locked?: boolean; +} + +export interface PackShelfProps { + items: PackShelfItem[]; + selectedTierKey?: PackTierKey; + onSelect: (item: PackShelfItem) => void; + className?: string; +} + +/** + * Shop-style pack shelf: every pack renders at the same size (no depth/scale falloff — + * that's PackCarousel's job elsewhere), laid out left-to-right in ascending price order + * (cheapest on the far left, priciest on the far right), each with its own price/pay + * button underneath. A plain horizontally-scrollable row with CSS scroll-snap — no + * infinite wrap, no centered-item illusion, just a shelf you scroll along. + */ +export function PackShelf({ items, selectedTierKey, onSelect, className }: PackShelfProps) { + const sorted = [...items].sort((a, b) => a.price - b.price); + + return ( +
    + {sorted.map((item) => ( +
    + + +
    + ))} +
    + ); +} diff --git a/src/components/pack-art/ResultEffect.tsx b/src/components/pack-art/ResultEffect.tsx new file mode 100644 index 0000000..8009bba --- /dev/null +++ b/src/components/pack-art/ResultEffect.tsx @@ -0,0 +1,85 @@ +"use client"; + +export type ResultIntensity = "standard" | "rare" | "major" | "genesis"; + +export interface ResultEffectProps { + intensity: ResultIntensity; + /** Future hook: once a real Higgsfield VFX clip exists, pass its path here to play it + * instead of the CSS placeholder — see docs/ASSET_MANIFEST.md (pack402_vfx_*). */ + videoSrc?: string; + active?: boolean; + className?: string; +} + +const INTENSITY_RING_COUNT: Record = { + standard: 1, + rare: 2, + major: 3, + genesis: 4, +}; + +const INTENSITY_COLOR: Record = { + standard: "#D4AF6A", + rare: "#D4AF6A", + major: "#3FA88C", + genesis: "#D4AF6A", +}; + +/** + * CSS placeholder for the four result-intensity VFX layers described in the creative + * brief (Standard / Rare / Major / Genesis). Renders BEHIND the card overlay slot — never + * covers or obstructs the card area, matching the "production compositing rule." Swap in + * a real rendered clip via `videoSrc` once available; the component keeps the same + * centered-card-safe layout either way. + */ +export function ResultEffect({ intensity, videoSrc, active = true, className }: ResultEffectProps) { + if (!active) return null; + + if (videoSrc) { + return ( +