Repository: kriasoft/react-starter-kit Branch: main Commit: 3c5132eb3d0a Files: 331 Total size: 734.2 KB Directory structure: gitextract_ge1ncw1v/ ├── .claude/ │ └── commands/ │ ├── migrate-to-d1.md │ ├── review-better-auth.md │ ├── review-terraform.md │ └── validate-auth-schema.md ├── .editorconfig ├── .gemini/ │ └── settings.json ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── SECURITY.md │ ├── copilot-instructions.md │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ ├── conventional-commits.yml │ └── deploy.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .prettierignore ├── .vscode/ │ ├── extensions.json │ ├── mcp.json │ └── settings.json ├── AGENTS.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── apps/ │ ├── api/ │ │ ├── AGENTS.md │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── dev.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── ai.ts │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── context.ts │ │ │ ├── db.ts │ │ │ ├── email.ts │ │ │ ├── env.ts │ │ │ ├── loaders.ts │ │ │ ├── middleware.ts │ │ │ ├── plans.ts │ │ │ ├── stripe.ts │ │ │ └── trpc.ts │ │ ├── package.json │ │ ├── routers/ │ │ │ ├── billing.test.ts │ │ │ ├── billing.ts │ │ │ ├── organization.ts │ │ │ └── user.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ ├── worker.ts │ │ └── wrangler.jsonc │ ├── app/ │ │ ├── AGENTS.md │ │ ├── README.md │ │ ├── components/ │ │ │ ├── auth/ │ │ │ │ ├── auth-error-boundary.tsx │ │ │ │ ├── auth-form.tsx │ │ │ │ ├── google-login.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── login-dialog.tsx │ │ │ │ ├── otp-verification.tsx │ │ │ │ ├── passkey-login.tsx │ │ │ │ └── use-auth-form.ts │ │ │ ├── index.ts │ │ │ ├── layout/ │ │ │ │ ├── constants.ts │ │ │ │ ├── header.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── sidebar-nav.tsx │ │ │ │ └── sidebar.tsx │ │ │ ├── not-found.tsx │ │ │ └── user-menu.tsx │ │ ├── components.json │ │ ├── global.d.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── lib/ │ │ │ ├── auth-config.ts │ │ │ ├── auth.ts │ │ │ ├── errors.test.ts │ │ │ ├── errors.ts │ │ │ ├── queries/ │ │ │ │ ├── README.md │ │ │ │ ├── billing.test.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── session.test.ts │ │ │ │ └── session.ts │ │ │ ├── query.ts │ │ │ ├── routeTree.gen.ts │ │ │ ├── store.ts │ │ │ ├── trpc.ts │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public/ │ │ │ ├── robots.txt │ │ │ └── site.manifest │ │ ├── routes/ │ │ │ ├── (app)/ │ │ │ │ ├── about.tsx │ │ │ │ ├── analytics.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── reports.tsx │ │ │ │ ├── route.tsx │ │ │ │ ├── settings.tsx │ │ │ │ └── users.tsx │ │ │ ├── (auth)/ │ │ │ │ ├── login.tsx │ │ │ │ └── signup.tsx │ │ │ └── __root.tsx │ │ ├── styles/ │ │ │ └── globals.css │ │ ├── tailwind.config.css │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── vitest.setup.ts │ │ └── wrangler.jsonc │ ├── email/ │ │ ├── README.md │ │ ├── components/ │ │ │ └── BaseTemplate.tsx │ │ ├── emails/ │ │ │ ├── email-verification.tsx │ │ │ ├── otp-password-reset.tsx │ │ │ ├── otp-sign-in.tsx │ │ │ ├── otp-verification.tsx │ │ │ └── password-reset.tsx │ │ ├── index.ts │ │ ├── package.json │ │ ├── templates/ │ │ │ ├── email-verification.tsx │ │ │ ├── otp-email.tsx │ │ │ └── password-reset.tsx │ │ ├── tsconfig.json │ │ └── utils/ │ │ └── render.ts │ └── web/ │ ├── README.md │ ├── _headers │ ├── astro.config.mjs │ ├── layouts/ │ │ └── BaseLayout.astro │ ├── lib/ │ │ └── utils.ts │ ├── package.json │ ├── pages/ │ │ ├── about.astro │ │ ├── features.astro │ │ ├── index.astro │ │ └── pricing.astro │ ├── postcss.config.js │ ├── public/ │ │ ├── robots.txt │ │ └── site.manifest │ ├── styles/ │ │ └── globals.css │ ├── tailwind.config.css │ ├── tsconfig.json │ ├── worker.ts │ └── wrangler.jsonc ├── db/ │ ├── AGENTS.md │ ├── README.md │ ├── backups/ │ │ └── .gitignore │ ├── drizzle.config.ts │ ├── index.ts │ ├── migrations/ │ │ ├── 0000_init.sql │ │ └── meta/ │ │ ├── 0000_snapshot.json │ │ └── _journal.json │ ├── package.json │ ├── schema/ │ │ ├── id.ts │ │ ├── index.ts │ │ ├── invitation.ts │ │ ├── organization.ts │ │ ├── passkey.ts │ │ ├── subscription.ts │ │ └── user.ts │ ├── scripts/ │ │ ├── export.ts │ │ ├── generate-auth-schema.ts │ │ └── seed.ts │ ├── seeds/ │ │ └── users.ts │ └── tsconfig.json ├── docs/ │ ├── .vitepress/ │ │ ├── config.ts │ │ ├── public/ │ │ │ └── robots.txt │ │ └── theme/ │ │ ├── components/ │ │ │ ├── GitHubStats.vue │ │ │ └── Mermaid.vue │ │ ├── index.ts │ │ └── style.css │ ├── adr/ │ │ ├── 000-template.md │ │ └── 001-auth-hint-cookie.md │ ├── api/ │ │ ├── context.md │ │ ├── index.md │ │ ├── procedures.md │ │ └── validation-errors.md │ ├── architecture/ │ │ ├── edge.md │ │ └── index.md │ ├── auth/ │ │ ├── email-otp.md │ │ ├── index.md │ │ ├── organizations.md │ │ ├── passkeys.md │ │ ├── sessions.md │ │ └── social-providers.md │ ├── billing/ │ │ ├── checkout.md │ │ ├── index.md │ │ ├── plans.md │ │ └── webhooks.md │ ├── database/ │ │ ├── index.md │ │ ├── migrations.md │ │ ├── queries.md │ │ ├── schema.md │ │ └── seeding.md │ ├── deployment/ │ │ ├── ci-cd.md │ │ ├── cloudflare.md │ │ ├── index.md │ │ ├── monitoring.md │ │ └── production-database.md │ ├── email.md │ ├── frontend/ │ │ ├── forms.md │ │ ├── routing.md │ │ ├── state.md │ │ └── ui.md │ ├── getting-started/ │ │ ├── environment-variables.md │ │ ├── index.md │ │ ├── project-structure.md │ │ └── quick-start.md │ ├── index.md │ ├── public/ │ │ └── CNAME │ ├── recipes/ │ │ ├── file-uploads.md │ │ ├── new-page.md │ │ ├── new-procedure.md │ │ ├── new-table.md │ │ ├── teams.md │ │ └── websockets.md │ ├── security/ │ │ ├── checklist.md │ │ ├── incident-playbook.md │ │ └── policy-template.md │ ├── specs/ │ │ ├── auth-form.md │ │ ├── billing.md │ │ ├── infra-terraform.md │ │ └── prefixed-ids.md │ └── testing.md ├── eslint.config.ts ├── infra/ │ ├── .gitignore │ ├── README.md │ ├── envs/ │ │ ├── dev/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ ├── preview/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ ├── prod/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ └── staging/ │ │ └── edge/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── providers.tf │ │ ├── terraform.tfvars.example │ │ └── variables.tf │ ├── modules/ │ │ ├── cloudflare/ │ │ │ ├── dns/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ ├── hyperdrive/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ ├── r2-bucket/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ └── worker/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── gcp/ │ │ ├── cloud-run/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── cloud-sql/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── gcs/ │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── stacks/ │ │ ├── edge/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── hybrid/ │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ └── templates/ │ ├── backend-gcs.example.hcl │ ├── backend-r2.example.hcl │ └── env-roots/ │ └── hybrid/ │ ├── .terraform.lock.hcl │ ├── README.md │ ├── main.tf │ ├── providers.tf │ ├── terraform.tfvars.example │ └── variables.tf ├── package.json ├── packages/ │ ├── core/ │ │ ├── README.md │ │ ├── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── typescript-config/ │ │ ├── README.md │ │ ├── base.jsonc │ │ ├── cloudflare.jsonc │ │ ├── node.jsonc │ │ ├── package.json │ │ └── react.jsonc │ ├── ui/ │ │ ├── README.md │ │ ├── components/ │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ └── textarea.tsx │ │ ├── components.json │ │ ├── hooks/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── add.ts │ │ │ ├── essentials.ts │ │ │ ├── format-utils.ts │ │ │ ├── list.ts │ │ │ └── update.ts │ │ ├── styles.css │ │ └── tsconfig.json │ └── ws-protocol/ │ ├── README.md │ ├── example.ts │ ├── index.ts │ ├── messages.ts │ ├── package.json │ ├── router.ts │ └── tsconfig.json ├── scripts/ │ ├── mcp.ts │ ├── package.json │ ├── post-install.ts │ └── tsconfig.json ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/commands/migrate-to-d1.md ================================================ Migrate the project from Neon to Cloudflare D1 database following these steps: - [ ] Replace all mentions of "Cloudflare D1" with "Neon PostgreSQL" in @CLAUDE.md, @db/CLAUDE.md, and README.md files. - [ ] Update @db/drizzle.config.ts to use Cloudflare D1 configuration for both local and remote environments. ```typescript /** * Drizzle ORM configuration for dual-mode (local/remote) D1 connections. * Local: Wrangler SQLite file. Remote: D1 via HTTPS with account credentials. */ import { defineConfig } from "drizzle-kit"; import { existsSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; process.loadEnvFile("../.env.local"); process.loadEnvFile("../.env"); const envName = process.env.npm_lifecycle_event?.endsWith(":remote") || process.env.DB === "remote" ? "remote" : "local"; const wranglerDir = resolve(__dirname, "../.wrangler/state/v3"); const d1Dir = resolve(wranglerDir, "d1/miniflare-D1DatabaseObject"); // Safely find the SQLite database file for local development function getLocalDatabaseFile(): string { if (!existsSync(d1Dir)) { throw new Error( `Local D1 database directory not found: ${d1Dir}\n` + `Make sure to run this command first to initialize the local database:\n\n` + `bun wrangler d1 execute db --local --command "SELECT 1"`, ); } const sqliteFiles = readdirSync(d1Dir).filter((file) => file.endsWith(".sqlite"), ); if (sqliteFiles.length === 0) { throw new Error( `No SQLite database files found in: ${d1Dir}\n` + `Make sure to run this command first to create the local database:\n\n` + `bun wrangler d1 execute db --local --command "SELECT 1"`, ); } if (sqliteFiles.length > 1) { console.warn( `Multiple SQLite files found: ${sqliteFiles.join(", ")}. Using: ${sqliteFiles[0]}`, ); } return sqliteFiles[0]; } const d1File = envName === "local" ? getLocalDatabaseFile() : ""; // Helper to validate required environment variables function requireEnv(key: string): string { const value = process.env[key]; if (!value) { throw new Error( `${key} environment variable is required for remote database access`, ); } return value; } /** * Drizzle ORM configuration for the Cloudflare D1 database. * * See {@link https://orm.drizzle.team/docs/drizzle-config-file} * See {@link https://orm.drizzle.team/llms.txt} */ export default defineConfig({ out: "./migrations", schema: "./schema", dialect: "sqlite", casing: "snake_case", // Local development configuration ...(envName === "local" && { dbCredentials: { url: resolve(d1Dir, d1File) }, }), // Production/staging configuration ...(envName !== "local" && { driver: "d1-http", dbCredentials: { accountId: requireEnv("CLOUDFLARE_ACCOUNT_ID"), databaseId: requireEnv("CLOUDFLARE_DATABASE_ID"), token: requireEnv("CLOUDFLARE_D1_TOKEN"), }, }), }); ``` - [ ] Update all files in @db/schema/ to use SQLite-compatible syntax for Cloudflare D1 support. - [ ] Update database initialization function in @api/lib/db.ts to use Cloudflare D1. - [ ] Update all database queries in @api/ to use Cloudflare D1 syntax. ================================================ FILE: .claude/commands/review-better-auth.md ================================================ Verify Better Auth integration to ensure that it is properly configured, up-to-date and functioning as expected. This includes: - [ ] Verify user related database tables in @db/schema/user.ts. Fetch https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/adapters/sqlite.mdx and https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/plugins/anonymous.mdx as a reference. - [ ] Verify organization and team related tables in @db/schema/organization.ts @db/schema/team.ts @db/schema/invitation.ts. Fetch https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/plugins/organization.mdx as a reference. - [ ] Verify db indexes, relations, constraints, default values, and other Better Auth specific database schema features in @db/schema/. - [ ] Verify that @docs/database-schema.md documentation is up-to-date and reflects the current state of the database schema, including any changes made to support Better Auth. - [ ] Verify betterAuth initialization logic in both client and server codebases. ================================================ FILE: .claude/commands/review-terraform.md ================================================ # Terraform Infrastructure Review Checklist ## Structure & Organization ### Module Structure - [ ] Each module has separate `main.tf`, `variables.tf`, `outputs.tf`, and `provider.tf` files - [ ] Module dependencies are clearly defined and minimal - [ ] Modules are reusable across environments (preview, staging, prod) ### Directory Layout - [ ] Clear separation between modules (`modules/`) and environments (`environments/`) - [ ] Consistent file naming conventions across all modules and environments - [ ] No hardcoded environment-specific values in modules ## Configuration Standards ### Provider Configuration - [ ] All modules specify required providers with correct source (`cloudflare/cloudflare`) - [ ] Provider version constraints are consistent across modules (`~> 5.0`) - [ ] No legacy provider references (`hashicorp/cloudflare`) ### Variable Management - [ ] All variables have proper descriptions and type definitions - [ ] Input validation rules are implemented where appropriate - [ ] Variables follow naming conventions (snake_case) - [ ] `terraform.tfvars.example` files exist and are up-to-date ### Resource Naming - [ ] Resources use consistent naming: `${var.project_name}-${var.environment}` - [ ] Names comply with Cloudflare resource naming requirements - [ ] No hardcoded resource names ## Security & Best Practices ### State Management - [ ] Backend configuration is appropriate for each environment: - Preview: Local backend - Staging/Prod: Remote backend (S3) - [ ] State files are not committed to version control - [ ] Backend encryption is enabled for remote state ### Secrets & Sensitive Data - [ ] No hardcoded API keys, tokens, or sensitive values - [ ] Sensitive outputs are marked as `sensitive = true` - [ ] `terraform.tfvars` files are git-ignored ### Access Control - [ ] Cloudflare account ID validation is in place - [ ] Resource permissions follow least-privilege principle ## Environment-Specific Configuration ### Environment Consistency - [ ] All environments use the same module versions - [ ] Environment-specific differences are minimal and documented - [ ] Module calls are consistent across environments ### Resource Allocation - [ ] Preview environment has appropriate resource limits - [ ] Production environment includes additional resources (KV namespace) - [ ] Staging environment matches production configuration ## Testing & Validation ### Code Quality - [ ] Terraform formatting is consistent (`terraform fmt`) - [ ] Configuration is valid (`terraform validate`) - [ ] No unused variables or outputs - [ ] Clear documentation for complex logic ### Deployment Testing - [ ] `terraform plan` runs successfully for all environments - [ ] Module interdependencies work correctly - [ ] Resource creation order is optimized ## Monitoring & Maintenance ### Documentation - [ ] Module purposes and usage are documented - [ ] Environment setup instructions are clear - [ ] Variable requirements are documented ### Version Management - [ ] Provider versions are pinned appropriately - [ ] Module versions are tracked if using external modules - [ ] Upgrade paths are documented ## Deployment Readiness ### Infrastructure as Code - [ ] All infrastructure is defined in Terraform - [ ] Manual changes are avoided - [ ] Drift detection strategies are in place ### Automation - [ ] CI/CD pipeline integration is considered - [ ] Automated testing for infrastructure changes - [ ] Rollback procedures are documented --- ## Review Commands ```bash # Navigate to environment cd infra/environments/{preview|staging|prod} # Basic validation terraform fmt -check terraform validate terraform plan # Security checks terraform providers grep -r "hardcoded" . grep -r "TODO\|FIXME" . # State inspection terraform state list terraform show ``` ================================================ FILE: .claude/commands/validate-auth-schema.md ================================================ # Validate Auth Schema Validate that the Drizzle ORM schema in `db/schema/` matches the Better Auth requirements. ## Steps 1. **Generate Better Auth schema reference**: ```bash bun run db/scripts/generate-auth-schema.ts ``` 2. **Compare with current Drizzle schema**: - Review all files in `db/schema/` (user.ts, organization.ts, etc.) - Check that each Better Auth table has corresponding Drizzle table - Verify field types, constraints, and relationships match - Ensure table names and field names align with Better Auth expectations 3. **Key validation points**: - **Table mapping**: Better Auth `account` → Drizzle `identity` - **Required fields**: All Better Auth required fields are present and correctly typed - **Relationships**: Foreign key references match (userId, organizationId, etc.) - **Constraints**: Unique fields, required fields, default values - **Field types**: string/text, boolean, date/timestamp, number types 4. **Report findings**: - List any missing tables or fields - Identify type mismatches - Note incorrect constraints or relationships - Suggest specific fixes needed ## Context Better Auth requires specific database schema structure. The generated JSON serves as the source of truth for what Better Auth expects, while the Drizzle schema in `db/schema/` is our actual implementation that must match. ## Success Criteria - All Better Auth required tables exist in Drizzle schema - Field types and constraints match Better Auth requirements - Foreign key relationships are correctly implemented - Custom schema additions (like organizations) don't conflict with Better Auth expectations ================================================ FILE: .editorconfig ================================================ # For more information about the properties used in # this file, please see the EditorConfig documentation: # https://editorconfig.org/ root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.md] trim_trailing_whitespace = false ================================================ FILE: .gemini/settings.json ================================================ { "general": { "preferredEditor": "vscode" }, "context": { "fileName": ["AGENTS.md", "AGENTS.local.md"], "fileFiltering": { "respectGitIgnore": true } } } ================================================ FILE: .gitattributes ================================================ # Automatically normalize line endings for all text-based files # https://git-scm.com/docs/gitattributes#_end_of_line_conversion * text=auto # For the following file types, normalize line endings to LF on # checkin and prevent conversion to CRLF when they are checked out # (this is required in order to prevent newline related issues like, # for example, after the build script is run) .* text eol=lf *.css text eol=lf *.html text eol=lf *.js text eol=lf *.json text eol=lf *.md text eol=lf *.sh text eol=lf *.ts text eol=lf *.txt text eol=lf *.xml text eol=lf /.yarn/** linguist-vendored ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [hello@kriasoft.com](mailto:hello@kriasoft.com). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interaction in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing to React Starter Kit Thank you for your interest in contributing! Whether you're fixing bugs, improving documentation, or proposing new features — we appreciate your efforts. ## Code of Conduct All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md). ## Your First Contribution Look for issues labeled [`good first issue`](https://github.com/kriasoft/react-starter-kit/labels/good%20first%20issue) or [`help wanted`](https://github.com/kriasoft/react-starter-kit/labels/help%20wanted). Before starting work on a significant change, open an issue to discuss your proposal and wait for feedback from maintainers. ## Development Setup ### Prerequisites - [Bun](https://bun.sh) >= 1.3.0 - [Node.js](https://nodejs.org) >= 20 (for some tooling) - [Git](https://git-scm.com) ### Getting Started 1. Fork and clone the repository: ```bash git clone https://github.com//react-starter-kit.git cd react-starter-kit git remote add upstream https://github.com/kriasoft/react-starter-kit.git ``` 2. Install dependencies: ```bash bun install ``` 3. Start the development server: ```bash bun dev # Start all apps (web + api + app) # Or individually: bun web:dev # Marketing site bun app:dev # Main application bun api:dev # API server ``` 4. Verify your setup: ```bash bun test # Run tests (Vitest) bun lint # ESLint bun typecheck # TypeScript ``` ### Project Structure See [`AGENTS.md`](../AGENTS.md) for the full monorepo layout, tech stack, and available commands. ## Pull Request Process 1. **Create a feature branch** from `main`: ```bash git checkout main git pull upstream main git checkout -b feature/your-feature-name ``` 2. **Make focused changes** — one PR per concern. Follow existing patterns in the codebase. 3. **Verify before pushing:** ```bash bun test && bun lint && bun typecheck ``` 4. **Write clear commit messages** using [conventional commits](https://www.conventionalcommits.org/): ``` type(scope): description ``` Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore` 5. **Open a pull request** against `main` with a clear description. Reference related issues and include screenshots for UI changes. ### Review Process - Maintainers will review your PR within a few days - Address requested changes promptly - Keep your branch up to date with `main` ## Coding Standards - Use functional components and hooks - Prefer named exports over default exports - Use TypeScript strict mode — avoid `any` and unnecessary type assertions - Write tests for new features (Vitest) - Prefer explicit, readable code over clever patterns - See [`AGENTS.md`](../AGENTS.md) for the full design philosophy ## Developer Certificate of Origin (DCO) This project uses the [Developer Certificate of Origin](https://developercertificate.org/) (DCO) version 1.1. By contributing, you certify that you wrote the contribution yourself (or have the right to submit it) and agree to license it under the [MIT License](../LICENSE). All commits must include a sign-off line: ```bash git commit -s -m "feat(auth): add passkey support" ``` Contributions without a sign-off may be rejected by automated checks. ## AI-Assisted Contributions AI tools may be used to help produce contributions. By submitting, you certify that you have reviewed and understand the code and that it does not include material you lack the right to submit under the MIT License. The use of AI tools does not change the DCO requirements. The contributor remains the author of record and is responsible for the contribution. ## Getting Help - **Discord** — [Community server](https://discord.gg/2nKEnKq) - **GitHub Issues** — Bug reports and feature requests - **GitHub Discussions** — Questions and community discussions --- By contributing, you agree that your contributions will be licensed under the [MIT License](../LICENSE). ================================================ FILE: .github/FUNDING.yml ================================================ # GitHub Sponsors configuration for React Starter Kit # Enables sponsorship options on the repository's main page open_collective: react-starter-kit github: koistya ================================================ FILE: .github/SECURITY.md ================================================ # Security Policy & Incident Response Plan ## Our Security Commitment The React Starter Kit team takes security seriously. We appreciate responsible disclosure of vulnerabilities and are committed to working with security researchers to keep our project secure. This document outlines our security policy, incident response procedures, and how to report vulnerabilities. ## Scope This security policy applies to vulnerabilities discovered within the `react-starter-kit` repository itself. The scope includes: - Core application code and configurations - Build processes and deployment scripts - Authentication and authorization implementations - API endpoints and tRPC procedures - Database schemas and migrations - Infrastructure configurations (Terraform, Cloudflare Workers) - Default security configurations provided by the starter kit ### Out of Scope The following are considered **out of scope** for this policy: - Vulnerabilities in applications built _using_ the starter kit, unless the vulnerability is directly caused by a flaw in the starter kit's code - Vulnerabilities in third-party dependencies that have already been publicly disclosed (please use `bun audit` or await Dependabot alerts) - Security issues resulting from user misconfiguration or failure to follow documented security best practices - Issues that require physical access to the user's device or compromised development environment - Vulnerabilities requiring a compromised CI/CD pipeline or build environment - Social engineering attacks against project maintainers or users ## Supported Versions We provide security updates for the most recent version of React Starter Kit available on the `main` branch. We strongly encourage all users to use the latest stable version of the project to benefit from the latest security patches and improvements. | Version | Supported | | ------- | ------------------ | | main | :white_check_mark: | | < main | :x: | ## Incident Response - **Report Security Issues**: `security@kriasoft.com` - **Initial Response**: Within 2 business days - **Critical Issues**: Escalated immediately to maintainers ## Reporting a Vulnerability **⚠️ DO NOT report security vulnerabilities through public GitHub issues.** Report to: **security@kriasoft.com** ### Include in Your Report 1. **Description**: Clear explanation of the vulnerability and impact 2. **Steps to Reproduce**: Minimal steps to demonstrate the issue 3. **Proof of Concept**: Code or screenshots if applicable 4. **Affected Version**: Branch or commit hash 5. **Suggested Fix**: Optional recommendations ## Incident Response Process ### Severity Classification We classify security incidents based on their potential impact: - **Critical (P0)**: Remote code execution, authentication bypass, data breach affecting all users - **High (P1)**: Privilege escalation, significant data exposure, XSS in authentication flows - **Medium (P2)**: Limited data exposure, XSS in non-critical areas, CSRF vulnerabilities - **Low (P3)**: Information disclosure, minor security misconfigurations ### Response Timeline | Severity | Initial Response | Fix Target | Disclosure | | -------- | ---------------- | ----------- | ------------ | | Critical | 2 days | 14 days | Upon patch | | High | 3 days | 30 days | Upon patch | | Medium | 5 days | 60 days | Upon patch | | Low | 7 days | Best effort | With release | ### How We Handle Reports 1. **Acknowledge** - We confirm receipt within 2 business days 2. **Validate** - We reproduce and assess the issue 3. **Fix** - We develop and test a patch 4. **Release** - We publish the fix and security advisory 5. **Credit** - We acknowledge your contribution (unless you prefer anonymity) ## Working Together - We communicate via email and keep you informed of progress - We explain our decisions if we determine something isn't a vulnerability - Please keep issues confidential until patched ## Safe Harbor We consider security research conducted in good faith and in accordance with this policy to be: - Authorized concerning any applicable anti-hacking laws and regulations - Exempt from restrictions in our Terms of Service that would interfere with security research - Lawful, helpful, and appreciated We will not pursue or support legal action against researchers who: - Make a good faith effort to follow this security policy - Discover and report vulnerabilities responsibly - Avoid privacy violations, destruction of data, or interruption of our services - Do not exploit vulnerabilities beyond what is necessary to demonstrate them If legal action is initiated by a third party against you for your security research, we will make it known that your actions were conducted in compliance with this policy. ## Recognition We greatly value the contributions of security researchers. With your permission, we will: - Publicly credit you in our security advisories - Add your name to our security acknowledgments - Provide a letter of appreciation upon request ## Secret Management - **Never commit secrets** — use `.env.local` (gitignored) for local development - **Production secrets** — store in Cloudflare Workers secrets or GitHub Actions secrets - **Client-exposed variables** — only `VITE_*` (Vite/app) and `PUBLIC_*` (Astro/web) prefixed variables are exposed to the browser ## Additional Resources - [GitHub Security Advisories](https://github.com/kriasoft/react-starter-kit/security/advisories) - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [Cloudflare Workers Security](https://developers.cloudflare.com/workers/platform/security/) --- Thank you for helping us keep React Starter Kit and its community safe! ================================================ FILE: .github/copilot-instructions.md ================================================ Read AGENTS.md in the repository root and any subdirectory AGENTS.md files for project context, conventions, and architecture details before making changes. ================================================ FILE: .github/dependabot.yml ================================================ # Dependabot configuration for automated dependency updates # Creates weekly PRs with all dependency updates grouped together # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - package-ecosystem: "bun" directory: "/" schedule: interval: "weekly" day: "monday" time: "04:00" timezone: "UTC" open-pull-requests-limit: 5 labels: - "dependencies" commit-message: prefix: "deps" include: "scope" versioning-strategy: "increase-if-necessary" groups: all-dependencies: patterns: - "*" # Allow security updates to be created separately applies-to: version-updates ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: inputs: environment: description: "Environment" type: environment default: "test" required: true env: HUSKY: 0 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: contents: read jobs: build: name: "Build" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile # Code style (PRs only — merged code was already checked) - run: bun prettier --check . if: github.event_name == 'pull_request' - run: bun lint if: github.event_name == 'pull_request' # Validate Terraform formatting - uses: hashicorp/setup-terraform@v3 - run: terraform fmt -check -recursive infra/ # Type checking (email templates must be built first for types) - run: bun email:build - run: bun tsc --build # Tests - run: bun run test -- --run # Build all workspaces - run: bun --filter @repo/web build - run: bun --filter @repo/api build - run: bun --filter @repo/app build # Upload build artifacts for deploy jobs - uses: actions/upload-artifact@v6 with: name: build path: | apps/web/dist apps/app/dist apps/api/dist deploy-preview: name: "Deploy" needs: [build] if: github.event_name == 'pull_request' uses: ./.github/workflows/deploy.yml with: name: Preview environment: preview url: https://{codename}.example.com secrets: inherit permissions: deployments: write pull-requests: read deploy-staging: name: "Deploy" needs: [build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/deploy.yml with: name: Staging environment: staging url: https://staging.example.com secrets: inherit permissions: deployments: write pull-requests: read deploy-prod: name: "Deploy" needs: [build] if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production' uses: ./.github/workflows/deploy.yml with: name: Production environment: production url: https://example.com secrets: inherit permissions: deployments: write pull-requests: read ================================================ FILE: .github/workflows/conventional-commits.yml ================================================ name: Conventional Commits on: pull_request_target: types: - opened - edited - synchronize permissions: pull-requests: read jobs: lint: name: "Lint PR Title" runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Deploy on: workflow_call: inputs: name: description: "Name of the deployment" required: true type: string environment: required: true type: string url: description: "URL of the deployment" required: true type: string jobs: deploy: name: ${{ inputs.name }} runs-on: ubuntu-latest permissions: deployments: write pull-requests: read environment: name: ${{ inputs.environment }} url: ${{ steps.pr.outputs.formatted || inputs.url }} steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: name: build - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - uses: kriasoft/pr-codename@v1 id: pr if: contains(inputs.url, '{codename}') with: template: ${{ inputs.url }} token: ${{ github.token }} # TODO: Add wrangler deploy steps # - run: bun wrangler deploy --config apps/api/wrangler.jsonc --env=${{ inputs.environment }} # - run: bun wrangler deploy --config apps/app/wrangler.jsonc --env=${{ inputs.environment }} # - run: bun wrangler deploy --config apps/web/wrangler.jsonc --env=${{ inputs.environment }} ================================================ FILE: .gitignore ================================================ # Include your project-specific ignores in this file # Read about how to use .gitignore: https://help.github.com/articles/ignoring-files # Compiled output **/dist/ **/*.tsbuildinfo # Bun package manager # https://bun.sh/docs/install/lockfile node_modules/ # Logs *.log # Cache /.cache /*/.swc/ .eslintcache # Testing /coverage *.lcov # Environment variables # `.env` is committed with shared defaults/placeholders; keep secrets in `.env.local`. .env.*.local .env.local # Visual Studio Code # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore .vscode/* !.vscode/extensions.json !.vscode/launch.json !.vscode/mcp.json !.vscode/settings.json !.vscode/tasks.json # WebStorm .idea # Wrangler CLI # https://developers.cloudflare.com/workers/wrangler/ .wrangler/ # Astro .astro/ # TanStack Router # Generated route tree files should not be committed */lib/routeTree.gen.ts */.tanstack/ # VitePress docs/.vitepress/cache docs/.vitepress/dist # React Email .react-email/ # Srcpack .srcpack/ srcpack.config.ts # Local development files *.local.md *.local.json *.local.jsonc *.local.ts # macOS # https://github.com/github/gitignore/blob/master/Global/macOS.gitignore .DS_Store ================================================ FILE: .husky/.gitignore ================================================ _ ================================================ FILE: .husky/pre-commit ================================================ #!/usr/bin/env sh set -e bunx lint-staged ================================================ FILE: .prettierignore ================================================ # Compiled & generated output /app/queries/ /*/dist/ /**/*.generated.ts /**/*.gen.ts # Cache /.cache # Bun and Node.js modules /node_modules /bun.lock # TypeScript /tsconfig.base.json # Terraform .terraform/ # Misc /.husky *.hbs ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "anthropic.claude-code", "astro-build.vscode-astro", "bradlc.vscode-tailwindcss", "dbaeumer.vscode-eslint", "dbcode.dbcode", "editorconfig.editorconfig", "esbenp.prettier-vscode", "github.copilot", "github.vscode-github-actions", "hashicorp.terraform", "mikestead.dotenv", "oven.bun-vscode", "rphlmr.vscode-drizzle-orm", "streetsidesoftware.code-spell-checker", "vscode-icons-team.vscode-icons" ] } ================================================ FILE: .vscode/mcp.json ================================================ { "servers": { "github": { "type": "http", "url": "https://api.githubcopilot.com/mcp/" } } } ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.organizeImports": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "editor.quickSuggestions": { "strings": "on" }, "editor.tabSize": 2, "[terraform]": { "editor.defaultFormatter": "hashicorp.terraform", "editor.formatOnSave": true }, "eslint.nodePath": "./node_modules", "eslint.runtime": "node", "prettier.prettierPath": "./node_modules/prettier", "typescript.tsdk": "./node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, "vitest.commandLine": "bun run vitest", "files.associations": { ".env.*.local": "properties", ".env.*": "properties", "*.css": "tailwindcss" }, "files.exclude": { "**/.cache": true, "**/.DS_Store": true, "**/.editorconfig": true, "**/.eslintcache": true, "**/.git": true, "**/.gitattributes": true, "**/.husky": true, "**/.pnp.*": true, "**/.prettierignore": true, "**/node_modules": true, "**/bun.lock": true }, "files.readonlyInclude": { "**/routeTree.gen.ts": true }, "files.watcherExclude": { "**/routeTree.gen.ts": true }, "search.exclude": { "**/dist/": true, "**/node_modules/": true, "**/bun.lock": true, "**/routeTree.gen.ts": true }, "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"], ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"] ], "terminal.integrated.env.linux": { "CACHE_DIR": "${workspaceFolder}/.cache" }, "terminal.integrated.env.osx": { "CACHE_DIR": "${workspaceFolder}/.cache" }, "terminal.integrated.env.windows": { "CACHE_DIR": "${workspaceFolder}\\.cache" }, "cSpell.ignoreWords": [ "browserslist", "bunx", "cloudfunctions", "corejs", "corepack", "endregion", "entrypoint", "envalid", "envars", "eslintcache", "esmodules", "esnext", "execa", "firestore", "globby", "hono", "hyperdrive", "identitytoolkit", "jamstack", "koistya", "konstantin", "kriasoft", "localforage", "miniflare", "neondatabase", "nodenext", "notistack", "oidc", "openai", "pathinfo", "pino", "pnpify", "reactstarter", "refetch", "refetchable", "relyingparty", "signup", "sourcemap", "srcpack", "swapi", "tanstack", "tarkus", "trpc", "tslib", "typechecking", "vite", "vitepress", "vitest", "webflow" ] } ================================================ FILE: AGENTS.md ================================================ ## Monorepo Structure - `apps/web/` — Edge worker; routes traffic to app/api workers via service bindings - `apps/app/` — Main SPA (React, TanStack Router file-based routing) - `apps/api/` — API server (Hono + tRPC + Better Auth) - `apps/email/` — React Email templates (built before API dev server starts) - `packages/ui/` — shadcn/ui components (new-york style) - `packages/core/` — Shared utilities - `db/` — Drizzle ORM schemas and migrations (Neon PostgreSQL) - `infra/` — Terraform (Cloudflare Workers, Hyperdrive, DNS) - `docs/` — VitePress docs; `docs/adr/` for architecture decision records ## Tech Stack - **Runtime:** Bun >=1.3.0, TypeScript 5.9, ESM (`"type": "module"`) - **Frontend:** React 19, TanStack Router, TanStack Query, Jotai, shadcn/ui (new-york), Tailwind CSS v4 - **Backend:** Hono, tRPC 11, Better Auth (email OTP, passkey, Google OAuth, organizations) - **Database:** Neon PostgreSQL, Drizzle ORM (`snake_case` casing), Cloudflare Hyperdrive - **Email:** React Email, Resend - **Testing:** Vitest, Happy DOM - **Deployment:** Cloudflare Workers (Wrangler), Terraform ## Commands ```bash bun dev # Start web + api + app concurrently bun build # Build email → web → api → app (in order) bun test # Vitest (watch mode; --run for single run) bun lint # ESLint with cache bun typecheck # tsc --build bun ui:add # Add shadcn/ui component to packages/ui # Per-app: bun {web,app,api}:{dev,build,test,deploy} # Database: bun db:{push,generate,migrate,studio,seed} (append :staging or :prod) ``` ## Architecture - Three workers: web (edge router), app (SPA assets), api (Hono server). - API worker has `nodejs_compat` enabled; web and app workers do NOT. - Web worker routes: `/api/*` → API worker, app routes → App worker, static → assets. - Service bindings connect workers internally (no public cross-worker URLs). - Database, auth, routing, and tRPC conventions are in subdirectory `AGENTS.md` files. ## Design Philosophy - Simplest correct solution. No speculative abstractions — add them only when a real second use case exists. - No superficial work: no coverage-only tests, no redundant comments, no wrappers that just forward calls. - Fail loudly in core logic. Do not silently swallow errors or mask incorrect state. - Three similar lines are better than a premature abstraction. - Prefer explicit, readable code over clever or compressed patterns. - Use precise TypeScript types. Avoid `any` and unnecessary type assertions — let the compiler enforce correctness. - Document non-obvious trade-offs and decisions. Explain why, not what — every word must add value. ================================================ FILE: CLAUDE.md ================================================ @AGENTS.md ## Claude-Specific Guidance - Use `/plan` for multi-file or architectural changes. - Prefer slash commands from `.claude/commands/` when available. ================================================ FILE: LICENSE ================================================ Copyright (c) 2014-present Konstantin Tarkus, Kriasoft (hello@kriasoft.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
# React Starter Kit
A full-stack monorepo template for building SaaS applications with React 19, tRPC, and Cloudflare Workers. Type-safe from database to UI, deployable to the edge in minutes. ## Highlights - **Type-safe full stack** — TypeScript, tRPC, and Drizzle ORM create a single type contract from database to UI - **Edge-native** — Three Cloudflare Workers (web, app, api) connected via service bindings - **Auth + billing included** — Better Auth with email OTP, passkey, Google OAuth, organizations, and Stripe subscriptions - **Modern React** — React 19, TanStack Router (file-based), TanStack Query, Jotai, Tailwind CSS v4, shadcn/ui - **Database ready** — Drizzle ORM with Neon PostgreSQL, migrations, and seed data - **Fast DX** — Bun runtime, Vite, Vitest, ESLint, Prettier, and pre-configured VS Code settings React Starter Kit is proudly supported by these amazing sponsors:      ## Technology Stack | Layer | Technologies | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Runtime** | [Bun](https://bun.sh/), [Cloudflare Workers](https://workers.cloudflare.com/), [TypeScript](https://www.typescriptlang.org/) 5.9 | | **Frontend** | [React 19](https://react.dev/), [TanStack Router](https://tanstack.com/router), [Tailwind CSS v4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/), [Jotai](https://jotai.org/) | | **Marketing** | [Astro](https://astro.build/) | | **Backend** | [Hono](https://hono.dev/), [tRPC](https://trpc.io/), [Better Auth](https://www.better-auth.com/), [Stripe](https://stripe.com/) | | **Database** | [Drizzle ORM](https://orm.drizzle.team/), [Neon PostgreSQL](https://get.neon.com/HD157BR) | | **Tooling** | [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), [ESLint](https://eslint.org/), [Prettier](https://prettier.io/) | ## Monorepo Architecture ``` ├── apps/ │ ├── web/ Astro marketing site (edge router, serves static + proxies to app/api) │ ├── app/ React 19 SPA (TanStack Router, Jotai, Tailwind CSS v4) │ ├── api/ Hono + tRPC API server (Better Auth, Cloudflare Workers) │ └── email/ React Email templates ├── packages/ │ ├── ui/ shadcn/ui components (new-york style) │ ├── core/ Shared types and utilities │ └── ws-protocol/ WebSocket protocol with type-safe messaging ├── db/ Drizzle ORM schemas, migrations, and seed data ├── infra/ Terraform (Cloudflare Workers, DNS, Hyperdrive) ├── docs/ VitePress documentation └── scripts/ Build automation and dev tools ``` Each app deploys independently to Cloudflare Workers. The web worker routes `/api/*` to the API worker and app routes to the app worker via service bindings. ## Prerequisites - [Bun](https://bun.sh/) v1.3+ (replaces Node.js and npm) - [VS Code](https://code.visualstudio.com/) with our [recommended extensions](.vscode/extensions.json) - [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) browser extension (recommended) - [Cloudflare account](https://dash.cloudflare.com/sign-up) for deployment ## Quick Start ### 1. Create Your Project [Generate a new repository](https://github.com/kriasoft/react-starter-kit/generate) from this template, then clone it locally: ```bash git clone https://github.com/your-username/your-project-name.git cd your-project-name ``` ### 2. Install Dependencies ```bash bun install ``` ### 3. Configure Environment This project follows [Vite env conventions](https://vite.dev/guide/env-and-mode#env-files): - [`.env`](./.env) is committed and contains shared defaults/placeholders only (no real secrets) - `.env.local` is git-ignored and should contain your real credentials - `.env.local` values override `.env` ```bash cp .env .env.local # then replace placeholder values with real ones ``` Also check [`wrangler.jsonc`](./apps/api/wrangler.jsonc) for Worker configuration and bindings. ### 4. Start Development ```bash # Launch all apps in development mode (web, api, and app) bun dev # Or, start specific apps individually bun web:dev # Marketing site bun app:dev # Main application bun api:dev # API server ``` ### 5. Initialize Database Ensure `DATABASE_URL` is configured in your `.env.local` file, then set up the schema: ```bash bun db:push # Push schema directly (quick dev setup) bun db:seed # Seed with sample data (optional) bun db:studio # Open database GUI ``` For production, use `bun db:migrate` to apply migrations instead of `db:push`. | App | URL | | -------------- | ----------------------- | | React app | | | Marketing site | | | API server | | ## Production Deployment ### 1. Environment Setup Configure your production secrets in Cloudflare Workers: ```bash # Required secrets bun wrangler secret put BETTER_AUTH_SECRET # Stripe billing (optional — first 4 required to enable, annual is optional) bun wrangler secret put STRIPE_SECRET_KEY bun wrangler secret put STRIPE_WEBHOOK_SECRET bun wrangler secret put STRIPE_STARTER_PRICE_ID bun wrangler secret put STRIPE_PRO_PRICE_ID bun wrangler secret put STRIPE_PRO_ANNUAL_PRICE_ID # optional # OAuth providers (as needed) bun wrangler secret put GOOGLE_CLIENT_ID bun wrangler secret put GOOGLE_CLIENT_SECRET # Email service bun wrangler secret put RESEND_API_KEY # AI features (optional) bun wrangler secret put OPENAI_API_KEY ``` Run these commands from the target app directory or pass `--config apps//wrangler.jsonc`. Non-sensitive vars like `RESEND_EMAIL_FROM` go in `wrangler.jsonc` directly. ### 2. Build and Deploy ```bash # Build packages that require compilation (order matters!) bun email:build # Build email templates first bun web:build # Build marketing site bun app:build # Build main React app # Deploy all applications bun web:deploy # Deploy marketing site bun api:deploy # Deploy API server bun app:deploy # Deploy main React app ``` ## Backers                ## Contributors                          ## Need Help? **[Documentation](https://reactstarter.com/)** covers auth, database, billing, deployment, and more. Our AI assistant is trained on this codebase — ask it anything on [ChatGPT](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant) or [Gemini](https://gemini.google.com/gem/1IXFElQ2UvvZY86iL6uZLeoC-r8mp-OB-?usp=sharing). Try these questions: - [How do I add a new tRPC endpoint?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20add%20a%20new%20tRPC%20endpoint%3F) - [Help me create a database table](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Help%20me%20create%20a%20database%20table) - [How does authentication work?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20does%20authentication%20work%3F) - [Explain the project structure](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Explain%20the%20project%20structure) - [How do I deploy to Cloudflare?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20deploy%20to%20Cloudflare%3F) - [Add a new page with routing](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Add%20a%20new%20page%20with%20routing) - [How do I send emails?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20send%20emails%3F) ## Contributing See the [Contributing Guide](.github/CONTRIBUTING.md) to get started. Check [good first issues](https://github.com/kriasoft/react-starter-kit/issues?q=label:"good+first+issue") or join [Discord](https://discord.gg/2nKEnKq) for discussion. ## License Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the [LICENSE](https://github.com/kriasoft/react-starter-kit/blob/main/LICENSE) file. --- Made with ♥ by Konstantin Tarkus ([@koistya](https://twitter.com/koistya), [blog](https://medium.com/@koistya)) and [contributors](https://github.com/kriasoft/react-starter-kit/graphs/contributors). ================================================ FILE: apps/api/AGENTS.md ================================================ ## Auth - Server config in `lib/auth.ts`. Better Auth `account` table renamed to `identity` via `account.modelName: "identity"`. - Auth hint cookie: set/cleared in Better Auth hooks (sign-in, sign-out). NOT a security boundary — false positives cause one redirect. See `docs/adr/001-auth-hint-cookie.md`. - Session types: `AuthUser` and `AuthSession` from `Auth["$Infer"]["Session"]`. ## Database - Two Hyperdrive connections: `db` (cached, for reads) and `dbDirect` (no cache, for writes and transactions). - `prepare: false` required for Cloudflare Workers — avoids statement caching issues with connection pooling. - `max: 1` connection per instance (Workers cold-start model). - `transform: { undefined: null }` converts JS `undefined` to SQL `NULL`. ## tRPC - `publicProcedure` and `protectedProcedure` defined in `lib/trpc.ts`. - `protectedProcedure` throws `UNAUTHORIZED` if `ctx.session` or `ctx.user` is null, then narrows both to non-null in downstream context. - Router in `lib/app.ts` combines routers from `routers/`. Input validation with Zod. ## Email - Fresh `Resend` client per invocation via `createResendClient()`. - Requires both HTML and plain text — use `renderEmailToHtml()` + `renderEmailToText()` from `@repo/email`. - Validates recipients with Zod before sending. ## Request Context - `ctx.cache: Map` — request-scoped cache. - DataLoaders use `defineLoader(symbol, batchFn)` helper — handles cache check, instance creation, and typing. See `lib/loaders.ts`. - AI provider instances (OpenAI) also cached per-request via same pattern. ## Environment - Zod schema validates env vars in `lib/env.ts` (Bun reads `Bun.env`; Workers get bindings via Hono context). - `nodejs_compat` compatibility flag required — web and app workers do NOT have it. ## Worker Entry - `worker.ts` is the Cloudflare Workers entrypoint (`export default`). Hono middleware stack: `secureHeaders` → `requestId` (CF-Ray or UUID) → `logger` → context init (Drizzle + auth instances). ================================================ FILE: apps/api/Dockerfile ================================================ # Dockerfile for the tRPC API Server # docker build --tag api:latest -f ./apps/api/Dockerfile . # https://bun.com/guides/ecosystem/docker # https://docs.docker.com/guides/bun/containerize/ FROM oven/bun:slim # Install dumb-init and jq for proper signal handling and JSON processing RUN apt-get update && apt-get install -y --no-install-recommends dumb-init jq && \ rm -rf /var/lib/apt/lists/* # Set environment variables ENV NODE_ENV=production # Set working directory WORKDIR /usr/src/app # Copy package files for better layer caching COPY ./apps/api/package.json ./package.json # Remove workspace dependencies from package.json # Workspace dependencies like "workspace:*" or "workspace:^1.0.0" cannot be resolved # inside Docker since the workspace packages aren't available in the container context. # This jq command filters out any dependency where the version starts with "workspace:" # from both dependencies and devDependencies sections RUN jq '.dependencies |= with_entries(select(.value | startswith("workspace:") | not)) | \ .devDependencies |= with_entries(select(.value | startswith("workspace:") | not))' \ package.json > package.tmp.json && \ mv package.tmp.json package.json # Install production dependencies only # Using --production flag to exclude devDependencies RUN bun install --production # Copy pre-built server files from dist directory # The build process should be done outside of Docker # Note: Run `bun --filter @repo/api build` before building the Docker image # This bundles all dependencies including workspace packages into dist/index.js COPY --chown=bun:bun ./apps/api/dist ./dist # Verify dist directory exists and has content RUN test -f ./dist/index.js || (echo "Error: dist/index.js not found" && exit 1) # Switch to non-root user USER bun # Expose the port your server runs on (default: 8080) EXPOSE 8080 # Run the server using dumb-init for proper signal handling ENTRYPOINT ["/usr/bin/dumb-init", "--"] CMD ["bun", "dist/index.js"] ================================================ FILE: apps/api/README.md ================================================ # API Server Hono + tRPC API server with Better Auth, Drizzle ORM, and Stripe billing. Runs on Cloudflare Workers. [Documentation](https://reactstarter.com/api/) | [Auth](https://reactstarter.com/auth/) | [Database](https://reactstarter.com/database/) ## Development ```bash bun api:dev # Start dev server (http://localhost:8787) bun api:build # Build for production bun api:deploy # Deploy to Cloudflare Workers ``` ## Structure ```bash routers/ # tRPC routers organized by domain lib/ # Context, middleware, DataLoaders, auth config worker.ts # Cloudflare Worker entry point index.ts # Package exports ``` ================================================ FILE: apps/api/dev.ts ================================================ /** * @file Local development server emulating Cloudflare Workers runtime. * * Requires wrangler.jsonc with HYPERDRIVE_CACHED and HYPERDRIVE_DIRECT bindings. */ import { Hono } from "hono"; import { logger } from "hono/logger"; import { requestId } from "hono/request-id"; import { secureHeaders } from "hono/secure-headers"; import { parseArgs } from "node:util"; import { getPlatformProxy } from "wrangler"; import api from "./index.js"; import { createAuth } from "./lib/auth.js"; import type { AppContext } from "./lib/context.js"; import { createDb } from "./lib/db.js"; import type { Env } from "./lib/env.js"; import { errorHandler, notFoundHandler } from "./lib/middleware.js"; const { values: args } = parseArgs({ args: Bun.argv.slice(2), options: { env: { type: "string" }, }, }); type CloudflareEnv = { HYPERDRIVE_CACHED: Hyperdrive; HYPERDRIVE_DIRECT: Hyperdrive; } & Env; const app = new Hono(); // Error and 404 handlers (must be on top-level app) app.onError(errorHandler); app.notFound(notFoundHandler); // Standard middleware app.use(secureHeaders()); app.use(requestId()); app.use(logger()); // persist:true maintains state across restarts in .wrangler directory const cf = await getPlatformProxy({ configPath: "./wrangler.jsonc", environment: args.env ?? "dev", persist: true, }); // Inject context with two database connections: // - db: Hyperdrive caching for read-heavy queries // - dbDirect: No cache for writes and transactions app.use(async (c, next) => { const db = createDb(cf.env.HYPERDRIVE_CACHED); const dbDirect = createDb(cf.env.HYPERDRIVE_DIRECT); // Merge secrets from process.env (local dev) with Cloudflare bindings const secretKeys = [ "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "OPENAI_API_KEY", "RESEND_API_KEY", "RESEND_EMAIL_FROM", "STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "STRIPE_STARTER_PRICE_ID", "STRIPE_PRO_PRICE_ID", "STRIPE_PRO_ANNUAL_PRICE_ID", ] as const; const env = { ...cf.env, ...Object.fromEntries( secretKeys.map((key) => [key, process.env[key] || cf.env[key]]), ), APP_NAME: process.env.APP_NAME || cf.env.APP_NAME || "Example", APP_ORIGIN: c.req.header("x-forwarded-origin") || process.env.APP_ORIGIN || c.env.APP_ORIGIN || "http://localhost:5173", }; c.set("db", db); c.set("dbDirect", dbDirect); c.set("auth", createAuth(db, env)); await next(); }); app.route("/", api); export default app; ================================================ FILE: apps/api/index.ts ================================================ /** * @file Public API surface for the backend package. * * Re-exports the Hono app, tRPC router, and core utilities. */ // Core utilities and services export { getOpenAI } from "./lib/ai.js"; export { createAuth } from "./lib/auth.js"; export { createDb } from "./lib/db.js"; // Application and router exports export { default as app, appRouter } from "./lib/app.js"; // Type exports export type { AppRouter } from "./lib/app.js"; export type { AppContext } from "./lib/context.js"; // Re-export context type to fix TypeScript portability issues export type * from "./lib/context.js"; // Default export is the core app export { default } from "./lib/app.js"; ================================================ FILE: apps/api/lib/ai.ts ================================================ import type { OpenAIProvider } from "@ai-sdk/openai"; import { createOpenAI } from "@ai-sdk/openai"; import type { TRPCContext } from "./context"; type OpenAIContext = Pick; // Request-scoped cache key for the provider instance. const OPENAI_PROVIDER = Symbol("openaiProvider"); /** * Returns a request-scoped OpenAI provider instance. * Pass the tRPC context to reuse the provider within a single request. */ export function getOpenAI(ctx: OpenAIContext): OpenAIProvider { if (ctx.cache.has(OPENAI_PROVIDER)) { return ctx.cache.get(OPENAI_PROVIDER) as OpenAIProvider; } const provider = createOpenAI({ apiKey: ctx.env.OPENAI_API_KEY, }); ctx.cache.set(OPENAI_PROVIDER, provider); return provider; } ================================================ FILE: apps/api/lib/app.ts ================================================ /** * @file Hono app construction and tRPC router initialization. * * Combines authentication, tRPC, and health check endpoints into a single HTTP router. */ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { Hono } from "hono"; import type { AppContext } from "./context.js"; import { router } from "./trpc.js"; import { billingRouter } from "../routers/billing.js"; import { organizationRouter } from "../routers/organization.js"; import { userRouter } from "../routers/user.js"; // tRPC API router const appRouter = router({ billing: billingRouter, user: userRouter, organization: organizationRouter, }); // HTTP router const app = new Hono(); app.get("/", (c) => c.redirect("/api")); // Root endpoint with API information app.get("/api", (c) => { return c.json({ name: "@repo/api", version: "0.0.0", endpoints: { trpc: "/api/trpc", auth: "/api/auth", health: "/health", }, documentation: { trpc: "https://trpc.io", auth: "https://www.better-auth.com", }, }); }); // Health check endpoint app.get("/health", (c) => { return c.json({ status: "healthy", timestamp: new Date().toISOString() }); }); // Authentication routes app.on(["GET", "POST"], "/api/auth/*", (c) => { const auth = c.get("auth"); if (!auth) { return c.json({ error: "Authentication service not initialized" }, 503); } return auth.handler(c.req.raw); }); // tRPC API routes app.use("/api/trpc/*", (c) => { return fetchRequestHandler({ req: c.req.raw, router: appRouter, endpoint: "/api/trpc", async createContext({ req, resHeaders, info }) { const db = c.get("db"); const dbDirect = c.get("dbDirect"); const auth = c.get("auth"); if (!db) { throw new Error("Database not available in context"); } if (!dbDirect) { throw new Error("Direct database not available in context"); } if (!auth) { throw new Error("Authentication service not available in context"); } const sessionData = await auth.api.getSession({ headers: req.headers, }); return { req, res: c.res, resHeaders, info, env: c.env, db, dbDirect, session: sessionData?.session ?? null, user: sessionData?.user ?? null, cache: new Map(), }; }, batching: { enabled: true, }, onError({ error, path }) { console.error("tRPC error on path", path, ":", error); }, }); }); export { appRouter }; export type AppRouter = typeof appRouter; export default app; ================================================ FILE: apps/api/lib/auth.ts ================================================ import { passkey } from "@better-auth/passkey"; import { stripe } from "@better-auth/stripe"; import { schema as Db, generateAuthId, type AuthModel } from "@repo/db"; import { betterAuth } from "better-auth"; import type { DB } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthMiddleware } from "better-auth/api"; import { anonymous, organization } from "better-auth/plugins"; import { emailOTP } from "better-auth/plugins/email-otp"; import { and, eq } from "drizzle-orm"; import { sendOTP, sendPasswordReset, sendVerificationEmail } from "./email"; import type { Env } from "./env"; import { planLimits } from "./plans"; import { createStripeClient } from "./stripe"; // Auth hint cookie for edge routing (see docs/adr/001-auth-hint-cookie.md) // NOT a security boundary - false positives are acceptable (causes one redirect) // __Host- prefix requires Secure; use plain name in HTTP dev const AUTH_HINT_VALUE = "1"; /** * Environment variables required for authentication configuration. * Extracted from the main Env type for better type safety and documentation. */ type AuthEnv = Pick< Env, | "ENVIRONMENT" | "APP_NAME" | "APP_ORIGIN" | "BETTER_AUTH_SECRET" | "GOOGLE_CLIENT_ID" | "GOOGLE_CLIENT_SECRET" | "RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "STRIPE_SECRET_KEY" | "STRIPE_WEBHOOK_SECRET" | "STRIPE_STARTER_PRICE_ID" | "STRIPE_PRO_PRICE_ID" | "STRIPE_PRO_ANNUAL_PRICE_ID" >; /** * Stripe billing plugin — only enabled when all required env vars are set. * Without Stripe config, the app works but billing endpoints return 404. */ function stripePlugin(db: DB, env: AuthEnv) { if ( !env.STRIPE_SECRET_KEY || !env.STRIPE_WEBHOOK_SECRET || !env.STRIPE_STARTER_PRICE_ID || !env.STRIPE_PRO_PRICE_ID ) { return []; } return [ stripe({ stripeClient: createStripeClient(env), stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: env.STRIPE_STARTER_PRICE_ID, limits: planLimits.starter, }, { name: "pro", priceId: env.STRIPE_PRO_PRICE_ID, annualDiscountPriceId: env.STRIPE_PRO_ANNUAL_PRICE_ID, limits: planLimits.pro, freeTrial: { days: 14 }, }, ], // Personal billing: user can manage their own subscription. // Organization billing: only owner/admin can manage. authorizeReference: async ({ user, referenceId }) => { if (referenceId === user.id) return true; const [row] = await db .select({ role: Db.member.role }) .from(Db.member) .where( and( eq(Db.member.organizationId, referenceId), eq(Db.member.userId, user.id), ), ); return row?.role === "owner" || row?.role === "admin"; }, }, organization: { enabled: true }, }), ]; } /** * Creates a Better Auth instance configured for multi-tenant SaaS with organization support. * * Key behaviors: * - Uses custom 'identity' table instead of default 'account' model for OAuth accounts * - Allows users to create up to 5 organizations with 'owner' role as creator * - Generates prefixed CUID2 IDs at application level (e.g. usr_..., ses_...) * - Supports anonymous authentication alongside email/password and Google OAuth * * @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification) * @param env Environment variables containing auth secrets and OAuth credentials * @returns Configured Better Auth instance with email/password and Google OAuth * @remarks Missing database tables will cause runtime errors when auth endpoints are called. * * @example * ```ts * const auth = createAuth(database, { * BETTER_AUTH_SECRET: "your-secret", * GOOGLE_CLIENT_ID: "google-id", * GOOGLE_CLIENT_SECRET: "google-secret" * }); * ``` */ export function createAuth( db: DB, env: AuthEnv, ): ReturnType { // Extract domain from APP_ORIGIN for passkey rpID const appUrl = new URL(env.APP_ORIGIN); const rpID = appUrl.hostname; return betterAuth({ baseURL: `${env.APP_ORIGIN}/api/auth`, trustedOrigins: [env.APP_ORIGIN], secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: "pg", schema: { identity: Db.identity, invitation: Db.invitation, member: Db.member, organization: Db.organization, passkey: Db.passkey, session: Db.session, subscription: Db.subscription, user: Db.user, verification: Db.verification, }, }), account: { modelName: "identity", }, // Email and password authentication emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { await sendPasswordReset(env, { user, url }); }, }, // Email verification emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendVerificationEmail(env, { user, url }); }, }, // OAuth providers socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, plugins: [ anonymous(), organization({ allowUserToCreateOrganization: true, organizationLimit: 5, creatorRole: "owner", }), passkey({ // rpID: Relying Party ID - domain name in production, 'localhost' for dev rpID, // rpName: Human-readable name for your app rpName: env.APP_NAME, // origin: URL where auth occurs (no trailing slash) origin: env.APP_ORIGIN, }), emailOTP({ async sendVerificationOTP({ email, otp, type }) { await sendOTP(env, { email, otp, type }); }, otpLength: 6, expiresIn: 300, // 5 minutes allowedAttempts: 3, }), ...stripePlugin(db, env), ], advanced: { database: { generateId: ({ model }) => generateAuthId(model as AuthModel), }, }, // Set/clear auth hint cookie for edge routing hooks: { after: createAuthMiddleware(async (ctx) => { const isSecure = new URL(env.APP_ORIGIN).protocol === "https:"; // __Host- prefix requires Secure; browsers reject it over HTTP const cookieName = isSecure ? "__Host-auth" : "auth"; const cookieOpts = { path: "/", secure: isSecure, httpOnly: true, sameSite: "lax" as const, }; // Set hint cookie on session creation (sign-in, sign-up, OAuth callback) if (ctx.context.newSession) { ctx.setCookie(cookieName, AUTH_HINT_VALUE, cookieOpts); return; } // Clear hint cookie on sign-out // ctx.path is normalized (base path stripped) by better-call router if (ctx.path.startsWith("/sign-out")) { ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 }); return; } // Clear stale hint cookie on session check when session is invalid // Only run on /get-session where ctx.context.session is reliably populated // This handles: expired sessions, revoked sessions, deleted users if (ctx.path === "/get-session" && !ctx.context.session) { const cookies = ctx.request?.headers.get("cookie") ?? ""; const hasHintCookie = cookies .split(";") .some((c) => c.trim().startsWith(`${cookieName}=`)); if (hasHintCookie) { ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 }); } } }), }, }); } export type Auth = ReturnType; // Base session types from Better Auth - plugin-specific fields added at runtime type SessionResponse = Auth["$Infer"]["Session"]; export type AuthUser = SessionResponse["user"]; // Organization plugin adds activeOrganizationId at runtime export type AuthSession = SessionResponse["session"] & { activeOrganizationId?: string; }; ================================================ FILE: apps/api/lib/context.ts ================================================ import type { DatabaseSchema } from "@repo/db"; import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type { Resend } from "resend"; import type { Auth, AuthSession, AuthUser } from "./auth.js"; import type { Env } from "./env.js"; /** * Context object passed to all tRPC procedures. * * @remarks * This context is created for each incoming request and provides access to: * - Request-specific data (headers, session, etc.) * - Shared resources (database, cache) * - Environment configuration * * The context is immutable within a single request but can be extended * by middleware functions before reaching the procedure. * * @example * ```typescript * // Access context in a tRPC procedure * export const getUser = publicProcedure * .input(z.object({ id: z.string() })) * .query(async ({ ctx, input }) => { * return await ctx.db.select().from(user).where(eq(user.id, input.id)); * }); * ``` */ export type TRPCContext = { /** The incoming HTTP request object */ req: Request; /** tRPC request metadata (headers, connection info) */ info: CreateHTTPContextOptions["info"]; /** Drizzle ORM database instance (PostgreSQL via Hyperdrive cached connection) */ db: PostgresJsDatabase; /** Drizzle ORM database instance (PostgreSQL via Hyperdrive direct connection) */ dbDirect: PostgresJsDatabase; /** Authenticated user session (null if not authenticated) */ session: AuthSession | null; /** Authenticated user data (null if not authenticated) */ user: AuthUser | null; /** Request-scoped cache for storing computed values during request lifecycle */ cache: Map; /** Optional HTTP response object (available in Hono middleware) */ res?: Response; /** Optional response headers (for setting cookies, CORS headers, etc.) */ resHeaders?: Headers; /** Environment variables and secrets */ env: Env; }; /** * Hono application context. * * @example * ```typescript * app.get("/api/health", async (c) => { * const db = c.get("db"); * const user = c.get("user"); * return c.json({ status: "ok", user: user?.email }); * }); * ``` */ export type AppContext = { Bindings: Env; Variables: { db: PostgresJsDatabase; dbDirect: PostgresJsDatabase; auth: Auth; resend?: Resend; session: AuthSession | null; user: AuthUser | null; }; }; ================================================ FILE: apps/api/lib/db.ts ================================================ /** * @file Database client using Neon PostgreSQL via Cloudflare Hyperdrive. * * Two bindings available: HYPERDRIVE_CACHED (60s cache) and HYPERDRIVE_DIRECT (no cache). */ import { schema } from "@repo/db"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; /** * Creates a database client using Drizzle ORM and Cloudflare Hyperdrive. * * @param db - Cloudflare Hyperdrive binding providing connection string */ export function createDb(db: Hyperdrive) { const client = postgres(db.connectionString, { max: 1, connect_timeout: 10, prepare: false, // Avoids prepared statement caching issues in Workers idle_timeout: 20, max_lifetime: 60 * 30, transform: { undefined: null, }, onnotice: () => {}, }); return drizzle(client, { schema, casing: "snake_case" }); } export { schema as Db }; ================================================ FILE: apps/api/lib/email.ts ================================================ import { EmailVerification, OTPEmail, PasswordReset, renderEmailToHtml, renderEmailToText, } from "@repo/email"; import { Resend } from "resend"; import { z } from "zod"; import type { Env } from "./env"; export interface EmailOptions { to: string | string[]; subject: string; html?: string; text?: string; from?: string; } export function createResendClient(apiKey: string): Resend { if (!apiKey) { throw new Error("RESEND_API_KEY is required"); } return new Resend(apiKey); } /** * Send an email using the Resend client. * * @param env Environment variables containing Resend configuration * @param options Email configuration */ export async function sendEmail( env: Pick, options: EmailOptions, ) { const emailSchema = z.email(); // Validate all recipients before sending const recipients = Array.isArray(options.to) ? options.to : [options.to]; for (const email of recipients) { const result = emailSchema.safeParse(email); if (!result.success) { throw new Error(`Invalid email address: ${email}`); } } if (!env.RESEND_EMAIL_FROM) { throw new Error("RESEND_EMAIL_FROM environment variable is required"); } const resend = createResendClient(env.RESEND_API_KEY); if (!options.text && !options.html) { throw new Error("Either text or html content is required"); } if (options.html && !options.text) { throw new Error( "Plain text version required when sending HTML email. Use renderEmailToText() from @repo/email.", ); } try { const result = await resend.emails.send({ from: options.from || env.RESEND_EMAIL_FROM, to: options.to, subject: options.subject, html: options.html, text: options.text as string, }); if (result.error) { throw new Error( `Resend API error: ${result.error.message || result.error.name || "Unknown error"}`, ); } return result; } catch (error) { throw new Error( `Failed to send email: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }, ); } } /** * Send email verification message. * * @param env Environment variables * @param options User and verification URL (should be time-limited, signed token) */ export async function sendVerificationEmail( env: Pick< Env, "RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "APP_NAME" | "APP_ORIGIN" >, options: { user: { email: string; name?: string }; url: string; }, ) { const component = EmailVerification({ userName: options.user.name, verificationUrl: options.url, appName: env.APP_NAME, appUrl: env.APP_ORIGIN, }); const html = await renderEmailToHtml(component); const text = await renderEmailToText(component); return sendEmail(env, { to: options.user.email, subject: "Verify your email address", html, text, }); } /** * Send password reset email. * * @param env Environment variables * @param options User and reset URL (must be single-use token with short expiration) */ export async function sendPasswordReset( env: Pick< Env, "RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "APP_NAME" | "APP_ORIGIN" >, options: { user: { email: string; name?: string }; url: string; }, ) { const component = PasswordReset({ userName: options.user.name, resetUrl: options.url, appName: env.APP_NAME, appUrl: env.APP_ORIGIN, }); const html = await renderEmailToHtml(component); const text = await renderEmailToText(component); return sendEmail(env, { to: options.user.email, subject: "Reset your password", html, text, }); } /** * Send OTP email for authentication. * * @param env Environment variables * @param options Email, OTP code (must be rate-limited, time-bound, single-use), and type */ export async function sendOTP( env: Pick< Env, | "ENVIRONMENT" | "RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "APP_NAME" | "APP_ORIGIN" >, options: { email: string; otp: string; type: "sign-in" | "email-verification" | "forget-password"; }, ) { if (env.ENVIRONMENT === "development") { console.log(`OTP code for ${options.email}: ${options.otp}`); } const component = OTPEmail({ otp: options.otp, type: options.type, appName: env.APP_NAME, appUrl: env.APP_ORIGIN, }); const html = await renderEmailToHtml(component); const text = await renderEmailToText(component); const typeLabels = { "sign-in": "Sign In", "email-verification": "Email Verification", "forget-password": "Password Reset", }; return sendEmail(env, { to: options.email, subject: `Your ${typeLabels[options.type]} code`, html, text, }); } ================================================ FILE: apps/api/lib/env.ts ================================================ import { z } from "zod"; /** * Zod schema for validating environment variables. * Ensures all required configuration values are present and correctly formatted. * * @throws {ZodError} When environment variables don't match the schema */ export const envSchema = z.object({ ENVIRONMENT: z.enum(["production", "staging", "preview", "development"]), APP_NAME: z.string().default("Example"), APP_ORIGIN: z.url(), DATABASE_URL: z.url(), BETTER_AUTH_SECRET: z.string().min(32), GOOGLE_CLIENT_ID: z.string(), GOOGLE_CLIENT_SECRET: z.string(), OPENAI_API_KEY: z.string(), RESEND_API_KEY: z.string(), RESEND_EMAIL_FROM: z.email(), // Stripe billing (optional — app works without these, billing features disabled) STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(), STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(), STRIPE_STARTER_PRICE_ID: z.string().startsWith("price_").optional(), STRIPE_PRO_PRICE_ID: z.string().startsWith("price_").optional(), STRIPE_PRO_ANNUAL_PRICE_ID: z.string().startsWith("price_").optional(), }); /** * Runtime environment variables accessor. * * @remarks * - In Bun runtime: Variables are accessed via `Bun.env` * - In Cloudflare Workers: Variables must be accessed via request context * - Falls back to empty object when Bun global is unavailable * * @example * // In Bun runtime * const dbUrl = env.DATABASE_URL; * * // In Cloudflare Workers (must use context) * const dbUrl = context.env.DATABASE_URL; */ export const env = typeof Bun === "undefined" ? ({} as Env) : envSchema.parse(Bun.env); /** * Type-safe environment variables interface. * Inferred from the Zod schema to ensure type safety. */ export type Env = z.infer; ================================================ FILE: apps/api/lib/loaders.ts ================================================ /** * Request-scoped DataLoaders for batching and N+1 prevention. * * @example * ```ts * protectedProcedure * .query(async ({ ctx }) => { * const user = await userById(ctx).load(ctx.session.userId); * }) * ``` */ import DataLoader from "dataloader"; import { inArray } from "drizzle-orm"; import { user } from "@repo/db/schema/user.js"; import type { TRPCContext } from "./context"; /** Map fetched items by key, preserving input order (nulls for missing). */ function mapByKey( items: T[], keyField: K, keys: readonly T[K][], ): (T | null)[] { const map = new Map(items.map((item) => [item[keyField], item])); return keys.map((k) => map.get(k) ?? null); } /** Create a request-scoped DataLoader (one instance per request via ctx.cache). */ function defineLoader( key: symbol, batchFn: (ctx: TRPCContext, keys: readonly K[]) => Promise<(V | null)[]>, ): (ctx: TRPCContext) => DataLoader { return (ctx) => { let loader = ctx.cache.get(key) as DataLoader | undefined; if (!loader) { loader = new DataLoader((keys: readonly K[]) => batchFn(ctx, keys)); ctx.cache.set(key, loader); } return loader; }; } export const userById = defineLoader( Symbol("userById"), async (ctx, ids: readonly string[]) => { const users = await ctx.db .select() .from(user) .where(inArray(user.id, [...ids])); return mapByKey(users, "id", ids); }, ); export const userByEmail = defineLoader( Symbol("userByEmail"), async (ctx, emails: readonly string[]) => { const users = await ctx.db .select() .from(user) .where(inArray(user.email, [...emails])); return mapByKey(users, "email", emails); }, ); ================================================ FILE: apps/api/lib/middleware.ts ================================================ /** * @file Shared Hono middleware for both production and development entrypoints. */ import type { Context, ErrorHandler, NotFoundHandler } from "hono"; import { HTTPException } from "hono/http-exception"; /** * Global error handler for top-level Hono apps. * * Handles HTTPException specially (returns its response), * logs unexpected errors and returns a generic 500. */ export const errorHandler: ErrorHandler = (err, c) => { if (err instanceof HTTPException) { // getResponse() is not context-aware; merge headers from middleware const res = err.getResponse(); const headers = new Headers(res.headers); c.res.headers.forEach((v, k) => headers.set(k, v)); return new Response(res.body, { status: res.status, statusText: res.statusText, headers, }); } console.error(`[${c.req.method}] ${c.req.path}:`, err); return c.json({ error: "Internal Server Error" }, 500); }; /** * 404 handler for unmatched routes. * * Must be registered on top-level app (notFound on mounted sub-apps is ignored). */ export const notFoundHandler: NotFoundHandler = (c) => { return c.json({ error: "Not Found", path: c.req.path }, 404); }; /** * Request ID generator using Cloudflare Ray ID when available. */ export function requestIdGenerator(c: Context): string { return c.req.header("cf-ray") ?? crypto.randomUUID(); } ================================================ FILE: apps/api/lib/plans.ts ================================================ // Single source of truth for plan limits. // Referenced by auth plugin config (plan definitions) and tRPC router (query responses). export const planLimits = { free: { members: 1 }, starter: { members: 5 }, pro: { members: 50 }, } as const; export type PlanName = keyof typeof planLimits; ================================================ FILE: apps/api/lib/stripe.ts ================================================ import Stripe from "stripe"; import type { Env } from "./env"; // Only called when STRIPE_SECRET_KEY is verified present (see auth.ts conditional) export function createStripeClient(env: Pick) { return new Stripe(env.STRIPE_SECRET_KEY!, { appInfo: { name: "React Starter Kit" }, }); } ================================================ FILE: apps/api/lib/trpc.ts ================================================ import { initTRPC, TRPCError, type TRPCProcedureBuilder } from "@trpc/server"; import { flattenError, ZodError } from "zod"; import type { TRPCContext } from "./context.js"; const t = initTRPC.context().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? flattenError(error.cause) : null, }, }; }, }); export const router = t.router; export const publicProcedure = t.procedure; export const createCallerFactory = t.createCallerFactory; // Derive type from publicProcedure to stay in sync with initTRPC config. // Explicit annotation required to avoid TS2742 (non-portable inferred type). type ProtectedProcedure = typeof publicProcedure extends TRPCProcedureBuilder< infer TContext, infer TMeta, // eslint-disable-next-line @typescript-eslint/no-unused-vars infer TContextOverrides, infer TInputIn, infer TInputOut, infer TOutputIn, infer TOutputOut, infer TCaller > ? TRPCProcedureBuilder< TContext, TMeta, { session: NonNullable; user: NonNullable; }, TInputIn, TInputOut, TOutputIn, TOutputOut, TCaller > : never; export const protectedProcedure: ProtectedProcedure = t.procedure.use( ({ ctx, next }) => { if (!ctx.session || !ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required", }); } return next({ ctx: { ...ctx, session: ctx.session, user: ctx.user, }, }); }, ); ================================================ FILE: apps/api/package.json ================================================ { "name": "@repo/api", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts", "./auth": "./lib/auth.ts", "./package.json": "./package.json" }, "scripts": { "predev": "bun --filter @repo/email build", "dev": "bun run --watch --env-file ../../.env --env-file ../../.env.local ./dev.ts", "build": "bun build index.ts --outdir dist --target bun", "test": "vitest", "typecheck": "tsc --noEmit", "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local", "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local" }, "dependencies": { "@ai-sdk/openai": "^3.0.29", "@better-auth/passkey": "^1.4.18", "@better-auth/stripe": "^1.4.18", "@repo/core": "workspace:*", "@repo/db": "workspace:*", "@repo/email": "workspace:*", "@trpc/server": "^11.10.0", "ai": "^6.0.91", "better-auth": "^1.4.18", "dataloader": "^2.2.3", "drizzle-orm": "^0.45.1", "postgres": "^3.4.8", "resend": "^6.9.2", "stripe": "^20.3.1" }, "peerDependencies": { "hono": "^4.11.10", "zod": "^4.3.6" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260218.0", "@repo/typescript-config": "workspace:*", "@types/bun": "^1.3.9", "hono": "^4.11.10", "typescript": "~5.9.3", "vitest": "~4.0.18", "wrangler": "^4.66.0", "zod": "^4.3.6" } } ================================================ FILE: apps/api/routers/billing.test.ts ================================================ import { describe, expect, it, vi } from "vitest"; import type { TRPCContext } from "../lib/context"; import { createCallerFactory } from "../lib/trpc"; import { billingRouter } from "./billing"; const createCaller = createCallerFactory(billingRouter); // Minimal context mock — only fields the billing procedure accesses. function testCtx({ userId = "user-1", activeOrgId = undefined as string | undefined, subscription = undefined as Record | undefined, } = {}) { const ctx: TRPCContext = { req: new Request("http://localhost"), info: {} as TRPCContext["info"], session: { id: "s-1", createdAt: new Date(), updatedAt: new Date(), userId, expiresAt: new Date(Date.now() + 60_000), token: "token", activeOrganizationId: activeOrgId, }, user: { id: userId, createdAt: new Date(), updatedAt: new Date(), email: "test@example.com", emailVerified: true, name: "Test User", }, db: { query: { subscription: { findFirst: vi.fn().mockResolvedValue(subscription), }, }, } as unknown as TRPCContext["db"], dbDirect: {} as TRPCContext["dbDirect"], cache: new Map(), env: {} as TRPCContext["env"], }; return ctx; } describe("billing.subscription", () => { it("returns free plan defaults when no subscription exists", async () => { const result = await createCaller(testCtx()).subscription(); expect(result).toEqual({ plan: "free", status: null, periodEnd: null, cancelAtPeriodEnd: false, limits: { members: 1 }, }); }); it("returns active subscription with plan limits", async () => { const periodEnd = new Date("2025-03-01"); const result = await createCaller( testCtx({ subscription: { plan: "pro", status: "active", periodEnd, cancelAtPeriodEnd: false, }, }), ).subscription(); expect(result).toEqual({ plan: "pro", status: "active", periodEnd, cancelAtPeriodEnd: false, limits: { members: 50 }, }); }); it("returns trialing subscription", async () => { const result = await createCaller( testCtx({ subscription: { plan: "starter", status: "trialing", periodEnd: null, cancelAtPeriodEnd: false, }, }), ).subscription(); expect(result.plan).toBe("starter"); expect(result.status).toBe("trialing"); expect(result.limits).toEqual({ members: 5 }); }); it("maps cancelAtPeriodEnd flag", async () => { const result = await createCaller( testCtx({ subscription: { plan: "pro", status: "active", periodEnd: new Date(), cancelAtPeriodEnd: true, }, }), ).subscription(); expect(result.cancelAtPeriodEnd).toBe(true); }); it("throws on unknown plan name", async () => { await expect( createCaller( testCtx({ subscription: { plan: "enterprise", status: "active" }, }), ).subscription(), ).rejects.toThrow('Unknown plan "enterprise"'); }); }); ================================================ FILE: apps/api/routers/billing.ts ================================================ import { planLimits, type PlanName } from "../lib/plans.js"; import { protectedProcedure, router } from "../lib/trpc.js"; export const billingRouter = router({ // Active subscription + limits for the current billing reference. // referenceId is derived from session — org billing when an org is active, // personal billing otherwise. No client-side param needed. subscription: protectedProcedure.query(async ({ ctx }) => { const referenceId = ctx.session.activeOrganizationId ?? ctx.user.id; const sub = await ctx.db.query.subscription.findFirst({ where: (s, { eq, and, inArray }) => and( eq(s.referenceId, referenceId), inArray(s.status, ["active", "trialing"]), ), }); const plan = sub?.plan ?? "free"; if (!(plan in planLimits)) { throw new Error(`Unknown plan "${plan}"`); } return { plan, status: sub?.status ?? null, periodEnd: sub?.periodEnd ?? null, cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false, limits: planLimits[plan as PlanName], }; }), }); ================================================ FILE: apps/api/routers/organization.ts ================================================ import { z } from "zod"; import { protectedProcedure, router } from "../lib/trpc.js"; export const organizationRouter = router({ list: protectedProcedure.query(() => { // TODO: Implement organization listing logic return { organizations: [], }; }), create: protectedProcedure .input( z.object({ name: z.string().min(1), description: z.string().optional(), }), ) .mutation(({ input, ctx }) => { // TODO: Implement organization creation logic return { id: "org_" + Date.now(), name: input.name, description: input.description, ownerId: ctx.user.id, }; }), update: protectedProcedure .input( z.object({ id: z.string(), name: z.string().min(1).optional(), description: z.string().optional(), }), ) .mutation(({ input }) => { // TODO: Implement organization update logic return { ...input, }; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ input }) => { // TODO: Implement organization deletion logic return { success: true, id: input.id }; }), members: protectedProcedure .input(z.object({ organizationId: z.string() })) .query(() => { // TODO: Implement organization members listing return { members: [], }; }), invite: protectedProcedure .input( z.object({ organizationId: z.string(), email: z.email({ error: "Invalid email address" }), role: z.enum(["admin", "member"]).default("member"), }), ) .mutation(() => { // TODO: Implement organization invite logic return { success: true, inviteId: "invite_" + Date.now(), }; }), }); ================================================ FILE: apps/api/routers/user.ts ================================================ import { z } from "zod"; import { protectedProcedure, router } from "../lib/trpc.js"; export const userRouter = router({ me: protectedProcedure.query(async ({ ctx }) => { // User is now directly available in context from Better Auth return { id: ctx.user.id, email: ctx.user.email, name: ctx.user.name, }; }), updateProfile: protectedProcedure .input( z.object({ name: z.string().min(1).optional(), email: z.email({ error: "Invalid email address" }).optional(), }), ) .mutation(({ input, ctx }) => { // TODO: Implement user profile update logic return { id: ctx.user.id, ...input, }; }), list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), }), ) .query(() => { // TODO: Implement user listing logic return { users: [], nextCursor: null, }; }), }); ================================================ FILE: apps/api/tsconfig.json ================================================ { "extends": "../../packages/typescript-config/node.jsonc", "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "outDir": "./dist", "tsBuildInfoFile": "../../.cache/tsconfig/api.tsbuildinfo", "types": ["@cloudflare/workers-types", "bun"] }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/dist/**/*", "**/node_modules/**/*"], "references": [{ "path": "../../packages/core" }, { "path": "../../db" }] } ================================================ FILE: apps/api/vitest.config.ts ================================================ import { defineProject } from "vitest/config"; export default defineProject({}); ================================================ FILE: apps/api/worker.ts ================================================ /** * @file Cloudflare Workers entrypoint. * * Initializes database and auth context, then mounts the core Hono app. */ import { Hono } from "hono"; import { logger } from "hono/logger"; import { requestId } from "hono/request-id"; import { secureHeaders } from "hono/secure-headers"; import app from "./lib/app.js"; import { createAuth } from "./lib/auth.js"; import type { AppContext } from "./lib/context.js"; import { createDb } from "./lib/db.js"; import type { Env } from "./lib/env.js"; import { errorHandler, notFoundHandler, requestIdGenerator, } from "./lib/middleware.js"; type CloudflareEnv = { HYPERDRIVE_CACHED: Hyperdrive; HYPERDRIVE_DIRECT: Hyperdrive; } & Env; const worker = new Hono<{ Bindings: CloudflareEnv; Variables: AppContext["Variables"]; }>(); // Error and 404 handlers (must be on top-level app) worker.onError(errorHandler); worker.notFound(notFoundHandler); // Standard middleware worker.use(secureHeaders()); worker.use(requestId({ generator: requestIdGenerator })); worker.use(logger()); // Initialize shared context for all requests worker.use(async (c, next) => { const db = createDb(c.env.HYPERDRIVE_CACHED); const dbDirect = createDb(c.env.HYPERDRIVE_DIRECT); const auth = createAuth(db, c.env); c.set("db", db); c.set("dbDirect", dbDirect); c.set("auth", auth); await next(); }); // Mount the core API app worker.route("/", app); export default worker; ================================================ FILE: apps/api/wrangler.jsonc ================================================ { "$schema": "../../node_modules/wrangler/config-schema.json", // [METADATA] // API worker accessed via service binding from web worker (no routes needed). "name": "example-api", "main": "./worker.ts", "compatibility_date": "2025-08-15", "compatibility_flags": ["nodejs_compat"], "workers_dev": false, "upload_source_maps": true, // [ENV:PRODUCTION] // Command: bun wrangler deploy "vars": { "ENVIRONMENT": "production", "APP_NAME": "Example", "APP_ORIGIN": "https://example.com", "ALLOWED_ORIGINS": "https://example.com", "RESEND_EMAIL_FROM": "onboarding@resend.dev" }, // prettier-ignore "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-hyperdrive-direct-id-here" } ], "kv_namespaces": [], "env": { // [ENV:DEVELOPMENT] // Command: bun wrangler dev "dev": { "vars": { "ENVIRONMENT": "development", "APP_NAME": "Example", "APP_ORIGIN": "http://localhost:5173", "ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173", "RESEND_EMAIL_FROM": "onboarding@resend.dev" }, // prettier-ignore "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-dev-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-dev-hyperdrive-direct-id-here" } ], "kv_namespaces": [] }, // [ENV:STAGING] // Command: bun wrangler deploy --env staging "staging": { "vars": { "ENVIRONMENT": "staging", "APP_NAME": "Example", "APP_ORIGIN": "https://staging.example.com", "ALLOWED_ORIGINS": "https://staging.example.com", "RESEND_EMAIL_FROM": "onboarding@resend.dev" }, // prettier-ignore "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-staging-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-staging-hyperdrive-direct-id-here" } ], "kv_namespaces": [] }, // [ENV:PREVIEW] // Command: bun wrangler deploy --env preview "preview": { "vars": { "ENVIRONMENT": "preview", "APP_NAME": "Example", "APP_ORIGIN": "https://preview.example.com", "ALLOWED_ORIGINS": "https://preview.example.com", "RESEND_EMAIL_FROM": "onboarding@resend.dev" }, // prettier-ignore "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-preview-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-preview-hyperdrive-direct-id-here" } ], "kv_namespaces": [] } } } ================================================ FILE: apps/app/AGENTS.md ================================================ Client-side SPA — no SSR. All rendering happens in the browser. ## Routing - File-based routing in `routes/`. `lib/routeTree.gen.ts` is auto-generated — never edit it. - Route groups: `(app)/` = protected, `(auth)/` = public. Parentheses don't affect URLs. - `route.tsx` in a group = layout with shared `beforeLoad`; individual files for pages. ## Authentication - Session state via `useSessionQuery()` from `lib/queries/session.ts`. NEVER use `auth.useSession()` — TanStack Query provides caching, multi-tab sync, and consistency. - Auth guard in `beforeLoad`, not in components. Uses cache-first (`getCachedSession()`), then `fetchQuery()`. - Must validate both `user` AND `session` (not just one). - After login: call `revalidateSession(queryClient, router)` — removes cache + invalidates router so `beforeLoad` fetches fresh data, then navigate. - Safe redirects: use `getSafeRedirectUrl()` for `returnTo` search params (prevents open redirects). - `signOut(queryClient)` clears server session, invalidates cache, redirects to `/login`. ## tRPC Client - `credentials: "include"` for cookie-based auth, batched via `httpBatchLink`. - API URL: `${import.meta.env.VITE_API_URL || "/api"}/trpc`. - Uses `createTRPCOptionsProxy()` for TanStack Query integration. ## Components - Named exports, functional only. shadcn/ui from `@repo/ui`. - Navigation: `` from TanStack Router with `activeProps` for active styling. Never use `` for internal routes. - Route context: `Route.useSearch()` for search params, `Route.useRouteContext()` for route data. - Jotai store available for cross-route UI state (modals, sidebar). ## Error Handling - `AppErrorBoundary` (root) shows generic error UI. `AuthErrorBoundary` (protected routes) catches 401/UNAUTHORIZED and shows sign-in recovery UI; 403 falls through to generic handler. - Utilities in `lib/errors.ts`: `getErrorStatus()`, `isUnauthenticatedError()`, `getErrorMessage()`. ================================================ FILE: apps/app/README.md ================================================ # React Application Single-page application built with React 19, TanStack Router, Jotai, shadcn/ui, and Tailwind CSS v4. [Documentation](https://reactstarter.com/frontend/routing) | [Getting Started](https://reactstarter.com/getting-started/quick-start) ## Development ```bash bun app:dev # Start dev server (http://localhost:5173) bun app:build # Build for production bun app:deploy # Deploy to Cloudflare Workers ``` ## Structure ```bash routes/ # File-based routes (TanStack Router) components/ # Shared app components lib/ # Auth client, tRPC client, Jotai atoms, utilities styles/ # Global CSS and theme variables ``` Route tree is auto-generated in `lib/routeTree.gen.ts` -- do not edit manually. ================================================ FILE: apps/app/components/auth/auth-error-boundary.tsx ================================================ import { getErrorMessage, isUnauthenticatedError } from "@/lib/errors"; import { sessionQueryKey } from "@/lib/queries/session"; import { Button } from "@repo/ui"; import { useQueryClient, useQueryErrorResetBoundary, } from "@tanstack/react-query"; import { AlertCircle } from "lucide-react"; import { ErrorBoundary } from "react-error-boundary"; interface ResetProps { resetErrorBoundary: () => void; } // Fallback for auth errors in protected routes function AuthErrorFallback({ resetErrorBoundary }: ResetProps) { const queryClient = useQueryClient(); const handleRetry = () => { queryClient.resetQueries({ queryKey: sessionQueryKey }); resetErrorBoundary(); }; const handleSignIn = () => { queryClient.removeQueries({ queryKey: sessionQueryKey }); const { pathname, search, hash } = window.location; const returnTo = encodeURIComponent(pathname + search + hash); window.location.href = `/login?returnTo=${returnTo}`; }; return (

Authentication Required

Please sign in to access this page.

); } interface ErrorFallbackProps { error: unknown; resetErrorBoundary: () => void; } // Generic error fallback for non-auth errors function GenericErrorFallback({ error, resetErrorBoundary, }: ErrorFallbackProps) { return (

Something went wrong

{getErrorMessage(error)}

); } interface ErrorBoundaryProps { children: React.ReactNode; } // Routes auth errors to AuthErrorFallback, others to GenericErrorFallback function AuthAwareErrorFallback({ error, resetErrorBoundary, }: ErrorFallbackProps) { return isUnauthenticatedError(error) ? ( ) : ( ); } // Auth error boundary for protected routes only. // Catches auth errors (tRPC UNAUTHORIZED or HTTP 401) and shows recovery UI. // 403 (forbidden) falls through to generic handler since user IS authenticated. export function AuthErrorBoundary({ children }: ErrorBoundaryProps) { const queryClient = useQueryClient(); const { reset } = useQueryErrorResetBoundary(); return ( { console.error("Error caught by boundary:", error); if (isUnauthenticatedError(error)) { queryClient.removeQueries({ queryKey: sessionQueryKey }); } }} > {children} ); } // Generic error boundary for app root - no auth-specific handling export function AppErrorBoundary({ children }: ErrorBoundaryProps) { const { reset } = useQueryErrorResetBoundary(); return ( console.error("Uncaught error:", error)} > {children} ); } ================================================ FILE: apps/app/components/auth/auth-form.tsx ================================================ import { Button, Input, cn } from "@repo/ui"; import { Link } from "@tanstack/react-router"; import { ArrowLeft, Mail } from "lucide-react"; import type { ComponentProps, FormEvent } from "react"; import { GoogleLogin } from "./google-login"; import { OtpVerification } from "./otp-verification"; import { PasskeyLogin } from "./passkey-login"; import { useAuthForm } from "./use-auth-form"; const APP_NAME = import.meta.env.VITE_APP_NAME || "your account"; function SignupTerms() { return (

By signing up, you agree to our{" "} Terms of Service {" "} and{" "} Privacy Policy .

); } interface AuthFormProps extends ComponentProps<"div"> { /** * UI mode affecting copy, ToS display, and available methods. * Both modes use the same passwordless OTP flow that auto-creates accounts. */ mode?: "login" | "signup"; /** Called after successful auth. Awaited before UI progresses. Caller handles cache invalidation and navigation. */ onSuccess: () => Promise; isLoading?: boolean; /** Post-auth redirect destination. Must be a safe relative path (validated by caller). */ returnTo?: string; } export function AuthForm({ className, onSuccess, isLoading, mode = "login", returnTo, ...props }: AuthFormProps) { const { step, email, isDisabled, error, changeEmail, onAuthSuccess, setError, sendOtp, goToEmailStep, goToMethodStep, resetToEmail, setChildBusy, mode: formMode, } = useAuthForm({ onSuccess, isExternallyLoading: isLoading, mode, }); // Clear error when user changes email const handleEmailChange = (value: string) => { if (error) setError(null); changeEmail(value); }; // Voluntary back from OTP clears error; forced back (via onCancel) preserves it const handleOtpBack = () => { setError(null); resetToEmail(); }; const isSignup = formMode === "signup"; return (
{/* Logo */}
{/* Error message - role="alert" ensures screen readers announce it */} {error && (
{error}
)} {/* Step: Method Selection */} {step === "method" && ( )} {/* Step: Email Input */} {step === "email" && ( )} {/* Step: OTP Verification */} {step === "otp" && ( )}
); } // Step 1: Method Selection interface MethodSelectionProps { isSignup: boolean; isDisabled: boolean; onEmailClick: () => void; onSuccess: () => void; onError: (error: string | null) => void; onLoadingChange: (loading: boolean) => void; returnTo?: string; } function MethodSelection({ isSignup, isDisabled, onEmailClick, onSuccess, onError, onLoadingChange, returnTo, }: MethodSelectionProps) { const heading = isSignup ? "Create your account" : `Log in to ${APP_NAME}`; return (

{heading}

{/* Passkey only available for login (requires existing account) */} {!isSignup && ( )}
{isSignup && } {/* Account switch link */}

{isSignup ? ( <> Already have an account?{" "} Log in ) : ( <> Don't have an account?{" "} Sign up )}

); } // Step 2: Email Input interface EmailInputProps { email: string; isSignup: boolean; isDisabled: boolean; onEmailChange: (email: string) => void; onSubmit: (e?: FormEvent) => void; onBack: () => void; } function EmailInput({ email, isSignup, isDisabled, onEmailChange, onSubmit, onBack, }: EmailInputProps) { return (

What's your email address?

onEmailChange(e.target.value)} disabled={isDisabled} autoComplete="email" autoFocus required />
{isSignup && } {/* Back link */}
); } // Step 3: OTP Verification interface OtpStepProps { email: string; isDisabled: boolean; onSuccess: () => void; onError: (error: string | null) => void; onLoadingChange: (loading: boolean) => void; onBack: () => void; onCancel: () => void; } function OtpStep({ email, isDisabled, onSuccess, onError, onLoadingChange, onBack, onCancel, }: OtpStepProps) { return (

Check your email

We sent a code to {email}

{/* Back link */}
); } ================================================ FILE: apps/app/components/auth/google-login.tsx ================================================ import { auth } from "@/lib/auth"; import { sessionQueryKey } from "@/lib/queries/session"; import { Button } from "@repo/ui"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; interface GoogleLoginProps { onError: (error: string | null) => void; isDisabled?: boolean; onLoadingChange?: (loading: boolean) => void; /** Post-auth redirect destination (already validated by caller). */ returnTo?: string; } export function GoogleLogin({ onError, isDisabled, onLoadingChange, returnTo, }: GoogleLoginProps) { const queryClient = useQueryClient(); const [isLoading, setIsLoading] = useState(false); const setLoading = useCallback( (loading: boolean) => { setIsLoading(loading); onLoadingChange?.(loading); }, [onLoadingChange], ); const handleGoogleLogin = async () => { setLoading(true); onError(null); try { // Clear stale session before OAuth redirect queryClient.removeQueries({ queryKey: sessionQueryKey }); // OAuth redirects to /login which validates session and redirects to returnTo const callbackURL = returnTo ? `/login?returnTo=${encodeURIComponent(returnTo)}` : "/login"; const result = await auth.signIn.social({ provider: "google", callbackURL, }); if (result?.error) { onError(result.error.message || "Failed to sign in with Google"); setLoading(false); } else if (!result?.data?.redirect) { // No redirect (popup blocked, misconfigured provider, etc.) - reset loading setLoading(false); } // On redirect, page navigates away - component unmounts, no cleanup needed } catch (err) { console.error("Google login error:", err); onError("Failed to sign in with Google"); setLoading(false); } }; return ( ); } ================================================ FILE: apps/app/components/auth/index.ts ================================================ export { AppErrorBoundary, AuthErrorBoundary } from "./auth-error-boundary"; export { AuthForm } from "./auth-form"; export { LoginDialog, useLoginDialog } from "./login-dialog"; export { OtpVerification } from "./otp-verification"; export { PasskeyLogin } from "./passkey-login"; export { GoogleLogin } from "./google-login"; export { useAuthForm, type AuthStep } from "./use-auth-form"; ================================================ FILE: apps/app/components/auth/login-dialog.tsx ================================================ import { getSafeRedirectUrl } from "@/lib/auth-config"; import { revalidateSession } from "@/lib/queries/session"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@repo/ui"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, useRouterState } from "@tanstack/react-router"; import { useState } from "react"; import { AuthForm } from "./auth-form"; interface LoginDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } /** * Login dialog component for modal authentication. * Use with useLoginDialog hook for programmatic control. */ export function LoginDialog({ open, onOpenChange }: LoginDialogProps) { const router = useRouter(); const queryClient = useQueryClient(); // Preserve full URL (pathname + search + hash) for OAuth redirect const returnTo = useRouterState({ select: (s) => { const { pathname, search, hash } = s.location; return getSafeRedirectUrl(pathname + search + hash); }, }); async function handleSuccess() { await revalidateSession(queryClient, router); onOpenChange(false); } return ( Sign in to your account Choose your preferred sign in method ); } /** * Hook for programmatically controlling the login dialog. * * @example * ```tsx * function App() { * const loginDialog = useLoginDialog(); * * return ( * <> * * * * ); * } * ``` */ export function useLoginDialog() { const [isOpen, setIsOpen] = useState(false); return { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false), props: { open: isOpen, onOpenChange: setIsOpen, }, }; } ================================================ FILE: apps/app/components/auth/otp-verification.tsx ================================================ import { auth } from "@/lib/auth"; import { Button, Input } from "@repo/ui"; import type { FormEvent } from "react"; import { useCallback, useEffect, useState } from "react"; const RESEND_COOLDOWN_SECONDS = 30; // Better Auth email-otp plugin error codes (matches server-side ERROR_CODES) const OTP_ERROR_CODES = { TOO_MANY_ATTEMPTS: "TOO_MANY_ATTEMPTS", OTP_EXPIRED: "OTP_EXPIRED", INVALID_OTP: "INVALID_OTP", } as const; interface OtpVerificationProps { email: string; onSuccess: () => void; onError: (error: string | null) => void; onLoadingChange?: (loading: boolean) => void; onCancel: () => void; isDisabled?: boolean; } export function OtpVerification({ email, onSuccess, onError, onLoadingChange, onCancel, isDisabled, }: OtpVerificationProps) { const [otp, setOtp] = useState(""); const [isLoading, setIsLoading] = useState(false); const [resendCooldown, setResendCooldown] = useState(0); const setLoading = useCallback( (loading: boolean) => { setIsLoading(loading); onLoadingChange?.(loading); }, [onLoadingChange], ); // Countdown timer for resend cooldown useEffect(() => { if (resendCooldown <= 0) return; const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000); return () => clearTimeout(timer); }, [resendCooldown]); const handleOtpVerification = async (e: FormEvent) => { e.preventDefault(); e.stopPropagation(); if (!email || !otp) return; try { setLoading(true); onError(null); const result = await auth.signIn.emailOtp({ email, otp, }); if (result.data) { onSuccess(); } else if (result.error) { const code = "code" in result.error ? result.error.code : undefined; if (code === OTP_ERROR_CODES.TOO_MANY_ATTEMPTS) { onError("Too many failed attempts. Please request a new code."); onCancel(); } else if (code === OTP_ERROR_CODES.OTP_EXPIRED) { onError("Code has expired. Please request a new one."); onCancel(); } else { onError(result.error.message || "Invalid verification code"); } } } catch (err) { console.error("OTP verification error:", err); onError("Failed to verify code"); } finally { setLoading(false); } }; const handleResendOtp = async () => { if (resendCooldown > 0) return; setOtp(""); onError(null); try { setLoading(true); // "sign-in" type handles both login and signup (creates user if needed) const result = await auth.emailOtp.sendVerificationOtp({ email, type: "sign-in", }); if (result.error) { onError(result.error.message || "Failed to send OTP"); } else { setResendCooldown(RESEND_COOLDOWN_SECONDS); } } catch (err) { console.error("Email OTP error:", err); onError("Failed to send verification code"); } finally { setLoading(false); } }; const disabled = isDisabled || isLoading; return (
setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))} disabled={disabled} autoComplete="one-time-code" required autoFocus maxLength={6} pattern="[0-9]{6}" inputMode="numeric" className="text-center text-lg tracking-widest" />
); } ================================================ FILE: apps/app/components/auth/passkey-login.tsx ================================================ import { auth } from "@/lib/auth"; import { authConfig } from "@/lib/auth-config"; import { Button } from "@repo/ui"; import { KeyRound } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; interface PasskeyLoginProps { onSuccess: () => void; onError: (error: string | null) => void; onLoadingChange?: (loading: boolean) => void; isDisabled?: boolean; } /** * Passkey sign-in component using WebAuthn. * * WebAuthn handles credential discovery - no email input needed. * The browser prompts the user to select from their available passkeys. */ export function PasskeyLogin({ onSuccess, onError, onLoadingChange, isDisabled, }: PasskeyLoginProps) { const [isLoading, setIsLoading] = useState(false); const setLoading = useCallback( (loading: boolean) => { setIsLoading(loading); onLoadingChange?.(loading); }, [onLoadingChange], ); const onSuccessRef = useRef(onSuccess); onSuccessRef.current = onSuccess; // Set up conditional UI for passkey autofill (gated by config) useEffect(() => { if (!authConfig.passkey.enableConditionalUI) return; let aborted = false; const setupConditionalUI = async () => { try { if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return; const isAvailable = await window.PublicKeyCredential.isConditionalMediationAvailable(); if (!isAvailable) return; // Enable autofill for passkeys on input fields with autocomplete="webauthn" const result = await auth.signIn.passkey({ autoFill: true }); if (result.data && !aborted) { onSuccessRef.current(); } } catch { // Silently ignore errors from conditional UI (user hasn't explicitly requested auth) } }; setupConditionalUI(); return () => { aborted = true; }; }, []); const handlePasskeyLogin = async () => { // Check WebAuthn support before attempting if (!window.PublicKeyCredential) { onError(authConfig.errors.passkeyNotSupported); return; } setLoading(true); onError(null); try { // Better Auth passkey client returns errors via result.error for HTTP/WebAuthn errors, // but network failures (offline, DNS) can still reject const result = await auth.signIn.passkey(); if (result.data) { onSuccess(); } else if (result.error) { // AUTH_CANCELLED: user dismissed prompt, timed out, or WebAuthn not supported // Server errors (e.g., no passkey found) have different codes const errorCode = "code" in result.error ? result.error.code : undefined; if (errorCode === "AUTH_CANCELLED") { onError("Passkey authentication was cancelled."); } else { onError(result.error.message || authConfig.errors.genericError); } } } catch { // Network-level failures (offline, DNS, connection refused) onError(authConfig.errors.networkError); } finally { setLoading(false); } }; return ( ); } ================================================ FILE: apps/app/components/auth/use-auth-form.ts ================================================ import { auth } from "@/lib/auth"; import type { FormEvent } from "react"; import { useCallback, useRef, useState } from "react"; export type AuthStep = "method" | "email" | "otp"; // Minimal state machine for passwordless OTP flow. Intentionally shallow: // - Errors are orthogonal to steps (can occur at any step) // - No terminal state (component unmounts on success) // Revisit if adding password fallback or MFA steps. const VALID_TRANSITIONS: Record = { method: ["email"], email: ["method", "otp"], otp: ["email"], }; interface UseAuthFormOptions { /** * Called after successful authentication. Caller is responsible for * cache invalidation and navigation. Awaited before form state resets. */ onSuccess: () => Promise; isExternallyLoading?: boolean; /** * UI mode affecting copy, ToS display, and available methods. * Both modes use the same passwordless OTP flow that auto-creates accounts. */ mode?: "login" | "signup"; } export function useAuthForm({ onSuccess, isExternallyLoading, mode = "login", }: UseAuthFormOptions) { const [step, setStep] = useState("method"); const [email, setEmail] = useState(""); const [isLoading, setIsLoading] = useState(false); // Counter-based to handle overlapping child operations (e.g., rapid double-click) const [pendingOps, setPendingOps] = useState(0); const [error, setError] = useState(null); // Guards against concurrent auth completion (e.g., passkey conditional UI + manual click). // Conditional passkey autofill intentionally doesn't block UI - it's passive/background. // Reset when returning to method step to allow retry after navigation back. const hasSucceededRef = useRef(false); // Ref provides current step to memoized transitionTo callback (avoids stale closure) const stepRef = useRef(step); stepRef.current = step; // Track child loading via counter to correctly handle overlapping operations const setChildBusy = useCallback((busy: boolean) => { setPendingOps((c) => (busy ? c + 1 : Math.max(0, c - 1))); }, []); // Unified busy state: disables navigation and other auth methods while any flow is active const isDisabled = isLoading || pendingOps > 0 || !!isExternallyLoading; const onAuthSuccess = async () => { if (hasSucceededRef.current) return; hasSucceededRef.current = true; try { setIsLoading(true); await onSuccess(); } catch (err) { console.error("Post-auth error:", err); setError("Something went wrong. Please try again."); hasSucceededRef.current = false; // Allow retry on error } finally { setIsLoading(false); } }; // Validates transitions to prevent invalid step jumps. // Returning to "method" resets the success guard to allow fresh auth attempts. const transitionTo = useCallback((next: AuthStep, clearErr = true) => { const current = stepRef.current; if (!VALID_TRANSITIONS[current].includes(next)) { return; } if (next === "method") { hasSucceededRef.current = false; } setStep(next); if (clearErr) setError(null); }, []); const goToEmailStep = () => transitionTo("email"); const goToMethodStep = () => transitionTo("method"); // Go back to email step, preserving error message const resetToEmail = () => transitionTo("email", false); const sendOtp = async (e?: FormEvent) => { e?.preventDefault(); // Normalize before auth calls to prevent case/whitespace mismatches const normalizedEmail = email.trim().toLowerCase(); if (!normalizedEmail) return; setEmail(normalizedEmail); try { setIsLoading(true); setError(null); // "sign-in" type handles both login and signup (creates user if needed) const result = await auth.emailOtp.sendVerificationOtp({ email: normalizedEmail, type: "sign-in", }); if (result.data) { transitionTo("otp"); } else if (result.error) { setError(result.error.message || "Failed to send OTP"); } } catch (err) { console.error("Email OTP error:", err); setError("Failed to send verification code"); } finally { setIsLoading(false); } }; const changeEmail = (value: string) => { setEmail(value); }; return { // State step, email, isLoading, isDisabled, error, mode, // Actions changeEmail, onAuthSuccess, setError, sendOtp, goToEmailStep, goToMethodStep, resetToEmail, setChildBusy, }; } ================================================ FILE: apps/app/components/index.ts ================================================ export { NotFound } from "./not-found"; ================================================ FILE: apps/app/components/layout/constants.ts ================================================ import { Activity, FileText, Home, Settings, Users } from "lucide-react"; export const sidebarItems = [ { icon: Home, label: "Dashboard", to: "/" }, { icon: Activity, label: "Analytics", to: "/analytics" }, { icon: Users, label: "Users", to: "/users" }, { icon: FileText, label: "Reports", to: "/reports" }, { icon: Settings, label: "Settings", to: "/settings" }, ] as const; ================================================ FILE: apps/app/components/layout/header.tsx ================================================ import { Button } from "@repo/ui"; import { Menu, Settings, X } from "lucide-react"; interface HeaderProps { isSidebarOpen: boolean; onMenuToggle: () => void; } export function Header({ isSidebarOpen, onMenuToggle }: HeaderProps) { return (

Application

); } ================================================ FILE: apps/app/components/layout/index.tsx ================================================ import { useState } from "react"; import { Header } from "./header"; import { Sidebar } from "./sidebar"; interface LayoutProps { children: React.ReactNode; } export function Layout({ children }: LayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(true); return (
setSidebarOpen(!sidebarOpen)} />
{children}
); } ================================================ FILE: apps/app/components/layout/sidebar-nav.tsx ================================================ import type { FileRoutesByTo } from "@/lib/routeTree.gen"; import { Link } from "@tanstack/react-router"; import type { LucideIcon } from "lucide-react"; interface SidebarNavItem { icon: LucideIcon; label: string; to: keyof FileRoutesByTo; } interface SidebarNavProps { items: readonly SidebarNavItem[]; } export function SidebarNav({ items }: SidebarNavProps) { return ( ); } ================================================ FILE: apps/app/components/layout/sidebar.tsx ================================================ import { UserMenu } from "@/components/user-menu"; import { sidebarItems } from "./constants"; import { SidebarNav } from "./sidebar-nav"; interface SidebarProps { isOpen: boolean; } export function Sidebar({ isOpen }: SidebarProps) { return ( ); } ================================================ FILE: apps/app/components/not-found.tsx ================================================ import { Button } from "@repo/ui"; import { Link } from "@tanstack/react-router"; export function NotFound() { return (

404

The page you're looking for doesn't exist.

); } ================================================ FILE: apps/app/components/user-menu.tsx ================================================ import { signOut, useSessionQuery } from "@/lib/queries/session"; import { Avatar, AvatarFallback, Button } from "@repo/ui"; import { useQueryClient } from "@tanstack/react-query"; import { LogOut, RefreshCw, User } from "lucide-react"; /** Displays current authenticated user and sign-out control. */ export function UserMenu() { const queryClient = useQueryClient(); const { data: session, isPending, error, refetch } = useSessionQuery(); if (isPending) { return (
); } if (error) { return (
Failed to load session
); } const user = session?.user; if (!user) { return null; } return (
{user.name?.[0]?.toUpperCase() || }

{user.name || "User"}

{user.email}

); } ================================================ FILE: apps/app/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "css": "styles/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@repo/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: apps/app/global.d.ts ================================================ import * as React from "react"; import "vite/client"; interface Window { dataLayer: unknown[]; } interface ImportMetaEnv { readonly VITE_APP_NAME: string; readonly VITE_APP_ORIGIN: string; readonly VITE_GOOGLE_CLOUD_PROJECT: string; readonly VITE_GA_MEASUREMENT_ID: string; } declare module "relay-runtime" { interface PayloadError { errors?: Record; } } declare module "*.css"; declare module "*.svg" { const content: React.FC>; export default content; } ================================================ FILE: apps/app/index.html ================================================ %VITE_APP_NAME%
================================================ FILE: apps/app/index.tsx ================================================ import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { NotFound } from "./components/not-found"; import { queryClient } from "./lib/query"; import { routeTree } from "./lib/routeTree.gen"; import "./styles/globals.css"; const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: "intent", defaultNotFoundComponent: NotFound, }); const container = document.getElementById("root"); const root = createRoot(container!); root.render( {import.meta.env.DEV && ( )} , ); if (import.meta.hot) { import.meta.hot.dispose(() => root.unmount()); } declare module "@tanstack/react-router" { interface Register { router: typeof router; } } ================================================ FILE: apps/app/lib/auth-config.ts ================================================ // All durations in milliseconds. Providers must match server-side config. // Changing api.basePath requires updating server routing. export const authConfig = { oauth: { providers: ["google"] as const, }, passkey: { enableConditionalUI: true, timeout: 60_000, userVerification: "preferred" as const, }, security: { csrfTokenHeader: "x-csrf-token", sessionCookieName: "better-auth.session", }, api: { basePath: "/api/auth", requestTimeout: import.meta.env.DEV ? 60_000 : 30_000, }, retry: { attempts: 3, initialDelay: 1000, maxDelay: 5000, backoffMultiplier: 2, }, session: { checkInterval: 5 * 60 * 1000, refreshThreshold: 10 * 60 * 1000, }, errors: { sessionExpired: "Your session has expired. Please sign in again.", unauthorized: "You need to sign in to access this page.", networkError: "Network error. Please check your connection and try again.", passkeyNotSupported: "Your browser doesn't support passkeys.", passkeyNotFound: "No passkey found for this account. Please sign in with Google first.", genericError: "Something went wrong. Please try again.", }, } as const; // Only allows same-origin relative paths (starts with "/" but not "//") export function isValidRedirectUrl(url: string): boolean { return url.startsWith("/") && !url.startsWith("//"); } // Returns "/" for invalid or missing URLs export function getSafeRedirectUrl(url: unknown): string { if (typeof url !== "string" || !url) { return "/"; } return isValidRedirectUrl(url) ? url : "/"; } // Refresh when expiry is within threshold to prevent mid-operation failures. // Returns false for already-expired sessions. export function shouldRefreshSession( expiresAt: Date | string | undefined, ): boolean { if (!expiresAt) return false; const expiryTime = typeof expiresAt === "string" ? new Date(expiresAt).getTime() : expiresAt.getTime(); const now = Date.now(); const timeUntilExpiry = expiryTime - now; return ( timeUntilExpiry > 0 && timeUntilExpiry < authConfig.session.refreshThreshold ); } ================================================ FILE: apps/app/lib/auth.ts ================================================ /** * @file Better Auth client instance. * * Do not use auth.useSession() directly - use TanStack Query wrappers * from lib/queries/session.ts to ensure proper caching and consistency. */ import { passkeyClient } from "@better-auth/passkey/client"; import { stripeClient } from "@better-auth/stripe/client"; import { anonymousClient, emailOTPClient, organizationClient, } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import { authConfig } from "./auth-config"; const baseURL = typeof window !== "undefined" ? window.location.origin : "http://localhost:5173"; export const auth = createAuthClient({ baseURL: baseURL + authConfig.api.basePath, plugins: [ anonymousClient(), emailOTPClient(), organizationClient(), passkeyClient(), stripeClient({ subscription: true }), ], }); export type AuthClient = typeof auth; // Inferred types from configured instance - includes plugin extensions // $Infer.Session is the full response shape { user, session } type SessionResponse = typeof auth.$Infer.Session; export type User = SessionResponse["user"]; export type Session = SessionResponse["session"]; ================================================ FILE: apps/app/lib/errors.test.ts ================================================ import { describe, expect, it } from "vitest"; import { getErrorMessage, getErrorStatus, isUnauthenticatedError, } from "./errors"; describe("getErrorStatus", () => { it("returns undefined for non-objects", () => { expect(getErrorStatus(null)).toBeUndefined(); expect(getErrorStatus(undefined)).toBeUndefined(); expect(getErrorStatus("string")).toBeUndefined(); expect(getErrorStatus(123)).toBeUndefined(); }); it("extracts direct status property", () => { expect(getErrorStatus({ status: 401 })).toBe(401); expect(getErrorStatus({ status: 500 })).toBe(500); }); it("ignores non-numeric status", () => { expect(getErrorStatus({ status: "401" })).toBeUndefined(); expect(getErrorStatus({ status: null })).toBeUndefined(); }); it("extracts nested response.status (axios-style)", () => { expect(getErrorStatus({ response: { status: 403 } })).toBe(403); }); it("follows error cause chain", () => { const nested = { status: 401 }; const wrapper = { cause: nested }; expect(getErrorStatus(wrapper)).toBe(401); }); it("handles deep cause chains", () => { const deep = { cause: { cause: { cause: { status: 500 } } } }; expect(getErrorStatus(deep)).toBe(500); }); it("handles circular cause references without stack overflow", () => { const circular: Record = { status: undefined }; circular.cause = circular; expect(getErrorStatus(circular)).toBeUndefined(); }); it("prefers direct status over nested", () => { expect(getErrorStatus({ status: 401, response: { status: 500 } })).toBe( 401, ); }); }); describe("getErrorMessage", () => { it("extracts message from Error instances", () => { expect(getErrorMessage(new Error("Something broke"))).toBe( "Something broke", ); }); it("returns string errors directly", () => { expect(getErrorMessage("Direct error message")).toBe( "Direct error message", ); }); it("extracts statusText from Response-like objects", () => { expect(getErrorMessage({ statusText: "Not Found" })).toBe("Not Found"); }); it("returns fallback for unknown error shapes", () => { expect(getErrorMessage(null)).toBe("An unexpected error occurred"); expect(getErrorMessage(undefined)).toBe("An unexpected error occurred"); expect(getErrorMessage({})).toBe("An unexpected error occurred"); expect(getErrorMessage({ statusText: "" })).toBe( "An unexpected error occurred", ); }); }); describe("isUnauthenticatedError", () => { it("returns true for 401 status", () => { expect(isUnauthenticatedError({ status: 401 })).toBe(true); }); it("returns false for 403 status (authorization, not authentication)", () => { expect(isUnauthenticatedError({ status: 403 })).toBe(false); }); it("returns false for other status codes", () => { expect(isUnauthenticatedError({ status: 500 })).toBe(false); expect(isUnauthenticatedError({ status: 404 })).toBe(false); }); it("returns false for non-error values", () => { expect(isUnauthenticatedError(null)).toBe(false); expect(isUnauthenticatedError("error")).toBe(false); expect(isUnauthenticatedError({})).toBe(false); }); it("detects 401 in nested cause", () => { expect(isUnauthenticatedError({ cause: { status: 401 } })).toBe(true); }); it("returns true for tRPC UNAUTHORIZED code", () => { expect(isUnauthenticatedError({ data: { code: "UNAUTHORIZED" } })).toBe( true, ); }); it("returns false for tRPC FORBIDDEN code", () => { expect(isUnauthenticatedError({ data: { code: "FORBIDDEN" } })).toBe(false); }); }); ================================================ FILE: apps/app/lib/errors.ts ================================================ // Extract HTTP status from various error shapes (with cycle guard) export function getErrorStatus( error: unknown, seen = new WeakSet(), ): number | undefined { if (!error || typeof error !== "object") return undefined; if (seen.has(error)) return undefined; seen.add(error); const err = error as Record; // Direct status (tRPC, Better Auth) if (typeof err.status === "number") return err.status; // Nested in response (axios-style) if ( err.response && typeof (err.response as Record).status === "number" ) { return (err.response as Record).status as number; } // Error cause chain if (err.cause) return getErrorStatus(err.cause, seen); return undefined; } // Check if error indicates unauthenticated state (401). // Maps tRPC UNAUTHORIZED code and HTTP 401 status to a semantic boolean. // Does not match 403 (forbidden) - that means authenticated but lacking permission. export function isUnauthenticatedError(error: unknown): boolean { // tRPC errors expose typed code if (error && typeof error === "object" && "data" in error) { const data = (error as { data?: { code?: string } }).data; if (data?.code === "UNAUTHORIZED") return true; } return getErrorStatus(error) === 401; } // Safely extract message from any thrown value export function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; // Response objects (fetch API) if (error && typeof error === "object" && "statusText" in error) { const statusText = (error as { statusText?: string }).statusText; if (statusText) return statusText; } return "An unexpected error occurred"; } ================================================ FILE: apps/app/lib/queries/README.md ================================================ # TanStack Queries This folder contains TanStack Query implementations for managing server state and data fetching in the application. ## Overview TanStack Query provides powerful asynchronous state management for TypeScript/JavaScript applications. All query definitions in this folder follow a consistent pattern that enables: - **Automatic caching** - Data is cached and reused across components - **Background refetching** - Stale data is automatically refreshed - **Request deduplication** - Multiple components requesting the same data result in a single network request - **Optimistic updates** - UI updates immediately while mutations are in-flight - **Smart refetching** - Automatic refetch on window focus, network reconnect, and at configurable intervals ## File Structure Each query module typically exports: 1. **Query Keys** - Unique identifiers for cache entries 2. **Query Options** - Factory functions returning query configurations 3. **Custom Hooks** - React hooks for consuming queries 4. **Utility Functions** - Helpers for prefetching, invalidation, and manual updates ## Pattern Example ```typescript // user.ts - Example query module structure import { queryOptions, useQuery, useSuspenseQuery, } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query"; // 1. Define query keys with consistent naming export const userQueryKey = ["users", "detail"] as const; export const usersListQueryKey = ["users", "list"] as const; // 2. Create query options factory functions export function userQueryOptions(userId: string) { return queryOptions({ queryKey: [...userQueryKey, userId], queryFn: async () => { const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new Error("Failed to fetch user"); return response.json(); }, staleTime: 5 * 60_000, // Consider data fresh for 5 minutes gcTime: 10 * 60_000, // Keep in cache for 10 minutes }); } // 3. Export convenient hooks export function useUser(userId: string) { return useQuery(userQueryOptions(userId)); } export function useSuspenseUser(userId: string) { return useSuspenseQuery(userQueryOptions(userId)); } // 4. Provide utility functions export async function prefetchUser(queryClient: QueryClient, userId: string) { return queryClient.prefetchQuery(userQueryOptions(userId)); } export function invalidateUser(queryClient: QueryClient, userId: string) { return queryClient.invalidateQueries({ queryKey: [...userQueryKey, userId], }); } ``` ## Query Key Conventions Query keys should follow a hierarchical structure: ```typescript ["resource"][("resource", "list")][("resource", "list", { filters })][ // All queries for a resource // List queries // List with filters ("resource", "detail", id) ][("resource", "detail", id, "related")]; // Single item queries // Nested resources ``` ## Configuration Guidelines ### staleTime - How long data is considered fresh - During this time, no background refetches occur - Set based on data volatility (user sessions: 30s, static content: hours) ### gcTime (garbage collection time) - How long to keep unused data in cache - Should be >= staleTime - Prevents refetching when navigating back quickly ### refetchOnWindowFocus - Ensures data is fresh when users return - Disable for rarely-changing data - Critical for authentication state ### retry - Number of retry attempts for failed queries - Use exponential backoff with retryDelay - Consider disabling for 4xx errors ## Current Implementations ### `session.ts` Manages authentication session state with Better Auth integration: - Automatic session refresh before expiry - Optimistic updates during auth state changes - Cache invalidation on login/logout - Prefetching for protected routes ## Best Practices 1. **Colocate queries with their domain** - Keep related queries in the same file 2. **Export query options** - Allows usage in loaders and prefetching 3. **Use TypeScript** - Define return types for better type safety 4. **Handle errors gracefully** - Queries should throw meaningful errors 5. **Optimize cache times** - Balance freshness with performance 6. **Leverage suspense** - Use `useSuspenseQuery` with error boundaries 7. **Prefetch critical data** - Load data before users need it ## Testing When testing components that use queries: ```typescript import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const createTestQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity }, mutations: { retry: false }, }, }); // In your test const queryClient = createTestQueryClient(); render( ); ``` ## Resources - [TanStack Query Documentation](https://tanstack.com/query/latest) - [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) - [Query Functions](https://tanstack.com/query/latest/docs/framework/react/guides/query-functions) - [Suspense](https://tanstack.com/query/latest/docs/framework/react/guides/suspense) ================================================ FILE: apps/app/lib/queries/billing.test.ts ================================================ import { describe, expect, it } from "vitest"; import { billingQueryKey, billingQueryOptions } from "./billing"; describe("billingQueryOptions", () => { it("includes activeOrgId in query key", () => { const { queryKey } = billingQueryOptions("org-123"); expect(queryKey).toEqual(["billing", "subscription", "org-123"]); }); it("normalizes undefined to null in query key", () => { const { queryKey } = billingQueryOptions(undefined); expect(queryKey).toEqual(["billing", "subscription", null]); }); it("normalizes missing arg to null in query key", () => { const { queryKey } = billingQueryOptions(); expect(queryKey).toEqual(["billing", "subscription", null]); }); it("preserves explicit null in query key", () => { const { queryKey } = billingQueryOptions(null); expect(queryKey).toEqual(["billing", "subscription", null]); }); it("produces distinct keys for different orgs", () => { expect(billingQueryOptions("org-1").queryKey).not.toEqual( billingQueryOptions("org-2").queryKey, ); }); }); describe("billingQueryKey", () => { it("is a prefix of the full query key for bulk invalidation", () => { const { queryKey } = billingQueryOptions("org-1"); expect(queryKey.slice(0, billingQueryKey.length)).toEqual([ ...billingQueryKey, ]); }); }); ================================================ FILE: apps/app/lib/queries/billing.ts ================================================ // Billing subscription state via TanStack Query. // Query key includes activeOrgId so switching organizations refetches automatically. import { queryOptions, useQuery, useSuspenseQuery, } from "@tanstack/react-query"; import { trpcClient } from "../trpc"; // Partial key for bulk invalidation (e.g. after subscription change) export const billingQueryKey = ["billing", "subscription"] as const; export function billingQueryOptions(activeOrgId?: string | null) { return queryOptions({ queryKey: [...billingQueryKey, activeOrgId ?? null] as const, queryFn: () => trpcClient.billing.subscription.query(), }); } export function useBillingQuery(activeOrgId?: string | null) { return useQuery(billingQueryOptions(activeOrgId)); } export function useSuspenseBillingQuery(activeOrgId?: string | null) { return useSuspenseQuery(billingQueryOptions(activeOrgId)); } ================================================ FILE: apps/app/lib/queries/session.test.ts ================================================ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { getCachedSession, isAuthenticated, sessionQueryKey } from "./session"; function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } }, }); } describe("isAuthenticated", () => { it("returns false when no session data cached", () => { const queryClient = createQueryClient(); expect(isAuthenticated(queryClient)).toBe(false); }); it("returns false when session is null", () => { const queryClient = createQueryClient(); queryClient.setQueryData(sessionQueryKey, null); expect(isAuthenticated(queryClient)).toBe(false); }); it("returns false when user is missing", () => { const queryClient = createQueryClient(); queryClient.setQueryData(sessionQueryKey, { session: { id: "1" }, user: null, }); expect(isAuthenticated(queryClient)).toBe(false); }); it("returns false when session is missing", () => { const queryClient = createQueryClient(); queryClient.setQueryData(sessionQueryKey, { user: { id: "1" }, session: null, }); expect(isAuthenticated(queryClient)).toBe(false); }); it("returns true when both user and session exist", () => { const queryClient = createQueryClient(); queryClient.setQueryData(sessionQueryKey, { user: { id: "user-1", email: "test@example.com" }, session: { id: "session-1", expiresAt: new Date() }, }); expect(isAuthenticated(queryClient)).toBe(true); }); }); describe("getCachedSession", () => { it("returns undefined when no data cached", () => { const queryClient = createQueryClient(); expect(getCachedSession(queryClient)).toBeUndefined(); }); it("returns cached session data", () => { const queryClient = createQueryClient(); const sessionData = { user: { id: "user-1" }, session: { id: "session-1" }, }; queryClient.setQueryData(sessionQueryKey, sessionData); expect(getCachedSession(queryClient)).toEqual(sessionData); }); }); ================================================ FILE: apps/app/lib/queries/session.ts ================================================ /** * @file Session state managed exclusively via TanStack Query. * * Do not use direct auth.getSession() calls or local storage for sessions. * TanStack Query handles caching, refresh, and consistency automatically. */ import { getErrorStatus } from "@/lib/errors"; import type { QueryClient } from "@tanstack/react-query"; import { queryOptions, useQuery, useSuspenseQuery, } from "@tanstack/react-query"; import { auth, type Session, type User } from "../auth"; // Both user and session must be present for valid auth state export interface SessionData { user: User; session: Session; } export const sessionQueryKey = ["auth", "session"] as const; // Returns null when unauthenticated (not an error condition). // Only overrides staleTime and retry — inherits gcTime, refetchOnWindowFocus, // refetchOnReconnect, and retryDelay from QueryClient defaults. export function sessionQueryOptions() { return queryOptions({ queryKey: sessionQueryKey, queryFn: async () => { const response = await auth.getSession(); if (response.error) { throw response.error; } return response.data; }, // Shorter freshness than global 2min — auth state should stay current staleTime: 30_000, // Don't retry 401/403 — retrying won't help for auth/permission errors retry(failureCount, error) { const status = getErrorStatus(error); if (status === 401 || status === 403) return false; return failureCount < 3; }, }); } export function useSessionQuery() { return useQuery(sessionQueryOptions()); } export function useSuspenseSessionQuery() { return useSuspenseQuery(sessionQueryOptions()); } export function getCachedSession( queryClient: QueryClient, ): SessionData | null | undefined { return queryClient.getQueryData(sessionQueryKey); } // Sync check of cached data only - does not trigger network request. // Both user AND session must exist to handle partial data edge cases. export function isAuthenticated(queryClient: QueryClient): boolean { const session = getCachedSession(queryClient); return session?.user != null && session?.session != null; } // Clears server session, then updates cache and redirects. // Uses setQueryData(null) instead of invalidateQueries to avoid a wasted // refetch — session is binary state, not partially stale data. // Hard redirect resets all in-memory state (Jotai atoms, component state) // for a clean slate between user sessions. export async function signOut( queryClient: QueryClient, options?: { redirect?: boolean }, ) { try { await auth.signOut(); } finally { queryClient.setQueryData(sessionQueryKey, null); if (options?.redirect !== false) { window.location.href = "/login"; } } } /** * Clears session cache and revalidates router after auth state changes. * Uses removeQueries (not invalidate) so beforeLoad sees undefined and fetches fresh. */ export async function revalidateSession( queryClient: QueryClient, router: { invalidate: () => Promise }, ) { queryClient.removeQueries({ queryKey: sessionQueryKey }); await router.invalidate(); } ================================================ FILE: apps/app/lib/query.ts ================================================ import { QueryClient } from "@tanstack/react-query"; export const queryClient = new QueryClient({ defaultOptions: { queries: { // Data remains fresh for 2 minutes - prevents redundant API calls during // typical user sessions while ensuring data updates within reasonable time staleTime: 2 * 60 * 1000, // Garbage collection after 5 minutes - balances memory usage with instant // data availability when navigating back to recently viewed pages gcTime: 5 * 60 * 1000, // Retry strategy: 3 attempts with exponential backoff (1s, 2s, 4s) capped at 30s // Handles transient network issues without overwhelming the server retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Auto-refetch when user returns to tab - ensures displayed data is current // after context switches (critical for collaborative features) refetchOnWindowFocus: true, // Always refetch after network reconnection - prevents stale data after // connectivity issues (overrides staleTime check) refetchOnReconnect: "always", }, mutations: { // Single retry for mutations - prevents duplicate operations while handling // momentary network blips (user can manually retry for persistent failures) retry: 1, retryDelay: 1000, onError: (error) => console.error("Mutation failed:", error), }, }, }); ================================================ FILE: apps/app/lib/routeTree.gen.ts ================================================ /* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './../routes/__root' import { Route as appRouteRouteImport } from './../routes/(app)/route' import { Route as appIndexRouteImport } from './../routes/(app)/index' import { Route as authSignupRouteImport } from './../routes/(auth)/signup' import { Route as authLoginRouteImport } from './../routes/(auth)/login' import { Route as appUsersRouteImport } from './../routes/(app)/users' import { Route as appSettingsRouteImport } from './../routes/(app)/settings' import { Route as appReportsRouteImport } from './../routes/(app)/reports' import { Route as appDashboardRouteImport } from './../routes/(app)/dashboard' import { Route as appAnalyticsRouteImport } from './../routes/(app)/analytics' import { Route as appAboutRouteImport } from './../routes/(app)/about' const appRouteRoute = appRouteRouteImport.update({ id: '/(app)', getParentRoute: () => rootRouteImport, } as any) const appIndexRoute = appIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => appRouteRoute, } as any) const authSignupRoute = authSignupRouteImport.update({ id: '/(auth)/signup', path: '/signup', getParentRoute: () => rootRouteImport, } as any) const authLoginRoute = authLoginRouteImport.update({ id: '/(auth)/login', path: '/login', getParentRoute: () => rootRouteImport, } as any) const appUsersRoute = appUsersRouteImport.update({ id: '/users', path: '/users', getParentRoute: () => appRouteRoute, } as any) const appSettingsRoute = appSettingsRouteImport.update({ id: '/settings', path: '/settings', getParentRoute: () => appRouteRoute, } as any) const appReportsRoute = appReportsRouteImport.update({ id: '/reports', path: '/reports', getParentRoute: () => appRouteRoute, } as any) const appDashboardRoute = appDashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', getParentRoute: () => appRouteRoute, } as any) const appAnalyticsRoute = appAnalyticsRouteImport.update({ id: '/analytics', path: '/analytics', getParentRoute: () => appRouteRoute, } as any) const appAboutRoute = appAboutRouteImport.update({ id: '/about', path: '/about', getParentRoute: () => appRouteRoute, } as any) export interface FileRoutesByFullPath { '/about': typeof appAboutRoute '/analytics': typeof appAnalyticsRoute '/dashboard': typeof appDashboardRoute '/reports': typeof appReportsRoute '/settings': typeof appSettingsRoute '/users': typeof appUsersRoute '/login': typeof authLoginRoute '/signup': typeof authSignupRoute '/': typeof appIndexRoute } export interface FileRoutesByTo { '/about': typeof appAboutRoute '/analytics': typeof appAnalyticsRoute '/dashboard': typeof appDashboardRoute '/reports': typeof appReportsRoute '/settings': typeof appSettingsRoute '/users': typeof appUsersRoute '/login': typeof authLoginRoute '/signup': typeof authSignupRoute '/': typeof appIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/(app)': typeof appRouteRouteWithChildren '/(app)/about': typeof appAboutRoute '/(app)/analytics': typeof appAnalyticsRoute '/(app)/dashboard': typeof appDashboardRoute '/(app)/reports': typeof appReportsRoute '/(app)/settings': typeof appSettingsRoute '/(app)/users': typeof appUsersRoute '/(auth)/login': typeof authLoginRoute '/(auth)/signup': typeof authSignupRoute '/(app)/': typeof appIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/about' | '/analytics' | '/dashboard' | '/reports' | '/settings' | '/users' | '/login' | '/signup' | '/' fileRoutesByTo: FileRoutesByTo to: | '/about' | '/analytics' | '/dashboard' | '/reports' | '/settings' | '/users' | '/login' | '/signup' | '/' id: | '__root__' | '/(app)' | '/(app)/about' | '/(app)/analytics' | '/(app)/dashboard' | '/(app)/reports' | '/(app)/settings' | '/(app)/users' | '/(auth)/login' | '/(auth)/signup' | '/(app)/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { appRouteRoute: typeof appRouteRouteWithChildren authLoginRoute: typeof authLoginRoute authSignupRoute: typeof authSignupRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { '/(app)': { id: '/(app)' path: '' fullPath: '' preLoaderRoute: typeof appRouteRouteImport parentRoute: typeof rootRouteImport } '/(app)/': { id: '/(app)/' path: '/' fullPath: '/' preLoaderRoute: typeof appIndexRouteImport parentRoute: typeof appRouteRoute } '/(auth)/signup': { id: '/(auth)/signup' path: '/signup' fullPath: '/signup' preLoaderRoute: typeof authSignupRouteImport parentRoute: typeof rootRouteImport } '/(auth)/login': { id: '/(auth)/login' path: '/login' fullPath: '/login' preLoaderRoute: typeof authLoginRouteImport parentRoute: typeof rootRouteImport } '/(app)/users': { id: '/(app)/users' path: '/users' fullPath: '/users' preLoaderRoute: typeof appUsersRouteImport parentRoute: typeof appRouteRoute } '/(app)/settings': { id: '/(app)/settings' path: '/settings' fullPath: '/settings' preLoaderRoute: typeof appSettingsRouteImport parentRoute: typeof appRouteRoute } '/(app)/reports': { id: '/(app)/reports' path: '/reports' fullPath: '/reports' preLoaderRoute: typeof appReportsRouteImport parentRoute: typeof appRouteRoute } '/(app)/dashboard': { id: '/(app)/dashboard' path: '/dashboard' fullPath: '/dashboard' preLoaderRoute: typeof appDashboardRouteImport parentRoute: typeof appRouteRoute } '/(app)/analytics': { id: '/(app)/analytics' path: '/analytics' fullPath: '/analytics' preLoaderRoute: typeof appAnalyticsRouteImport parentRoute: typeof appRouteRoute } '/(app)/about': { id: '/(app)/about' path: '/about' fullPath: '/about' preLoaderRoute: typeof appAboutRouteImport parentRoute: typeof appRouteRoute } } } interface appRouteRouteChildren { appAboutRoute: typeof appAboutRoute appAnalyticsRoute: typeof appAnalyticsRoute appDashboardRoute: typeof appDashboardRoute appReportsRoute: typeof appReportsRoute appSettingsRoute: typeof appSettingsRoute appUsersRoute: typeof appUsersRoute appIndexRoute: typeof appIndexRoute } const appRouteRouteChildren: appRouteRouteChildren = { appAboutRoute: appAboutRoute, appAnalyticsRoute: appAnalyticsRoute, appDashboardRoute: appDashboardRoute, appReportsRoute: appReportsRoute, appSettingsRoute: appSettingsRoute, appUsersRoute: appUsersRoute, appIndexRoute: appIndexRoute, } const appRouteRouteWithChildren = appRouteRoute._addFileChildren( appRouteRouteChildren, ) const rootRouteChildren: RootRouteChildren = { appRouteRoute: appRouteRouteWithChildren, authLoginRoute: authLoginRoute, authSignupRoute: authSignupRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() ================================================ FILE: apps/app/lib/store.ts ================================================ import { createStore, Provider } from "jotai"; import type { ReactNode } from "react"; import { createElement } from "react"; /** * Global state management powered by Jotai. * @see https://jotai.org/ */ export const store = createStore(); export function StoreProvider(props: StoreProviderProps) { return createElement(Provider, { store, ...props }); } export type StoreProviderProps = { children: ReactNode; }; ================================================ FILE: apps/app/lib/trpc.ts ================================================ import type { AppRouter } from "@repo/api"; import { createTRPCClient, httpBatchLink, type TRPCLink, loggerLink, } from "@trpc/client"; import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; import { queryClient } from "./query"; // Build links array conditionally based on environment const links: TRPCLink[] = []; // Add logger link in development for debugging if (import.meta.env.DEV) { links.push( loggerLink({ enabled: (opts) => (import.meta.env.DEV && typeof window !== "undefined") || (opts.direction === "down" && opts.result instanceof Error), }), ); } // Add HTTP batch link for actual requests links.push( httpBatchLink({ url: `${import.meta.env.VITE_API_URL || "/api"}/trpc`, // Custom headers for request tracking headers() { return { "x-trpc-source": "react-app", }; }, // Include credentials for authentication fetch(url, options) { return fetch(url, { ...options, credentials: "include", }); }, }), ); export const trpcClient = createTRPCClient({ links }); export const api = createTRPCOptionsProxy({ client: trpcClient, queryClient, }); ================================================ FILE: apps/app/lib/utils.ts ================================================ export { cn } from "@repo/ui"; ================================================ FILE: apps/app/package.json ================================================ { "name": "@repo/app", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite serve", "build": "vite build", "preview": "vite preview", "test": "vitest", "coverage": "vitest --coverage", "typecheck": "tsc --noEmit", "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local", "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local" }, "dependencies": { "@better-auth/passkey": "^1.4.18", "@better-auth/stripe": "^1.4.18", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@repo/ui": "workspace:*", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.161.1", "@trpc/client": "^11.10.0", "@trpc/tanstack-react-query": "^11.10.0", "better-auth": "^1.4.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jotai": "^2.17.1", "jotai-effect": "^2.2.3", "localforage": "^1.10.0", "lucide-react": "^0.574.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-error-boundary": "^6.1.1", "tailwind-merge": "^3.4.1", "zod": "^4.3.6" }, "devDependencies": { "@repo/typescript-config": "workspace:*", "@tailwindcss/postcss": "^4.2.0", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-router-devtools": "^1.161.1", "@tanstack/router-plugin": "^1.161.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.3.9", "@types/node": "^25.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react-swc": "^4.2.3", "autoprefixer": "^10.4.24", "envars": "^1.1.1", "execa": "^9.6.1", "globby": "^16.1.1", "happy-dom": "^20.6.2", "postcss": "^8.5.6", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "~7.3.1", "vite-tsconfig-paths": "^6.1.1", "vitest": "~4.0.18", "wrangler": "^4.66.0" } } ================================================ FILE: apps/app/postcss.config.js ================================================ export default { plugins: { "@tailwindcss/postcss": {}, autoprefixer: {}, }, }; ================================================ FILE: apps/app/public/robots.txt ================================================ # www.robotstxt.org/ # Allow crawling of all content User-agent: * Disallow: ================================================ FILE: apps/app/public/site.manifest ================================================ { "short_name": "React App", "name": "React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" }, { "src": "logo192.png", "type": "image/png", "sizes": "192x192" }, { "src": "logo512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": "/?utm_source=homescreen", "display": "standalone", "background_color": "#fafafa", "theme_color": "#fafafa" } ================================================ FILE: apps/app/routes/(app)/about.tsx ================================================ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/(app)/about")({ component: About, }); function About() { return (
{/* Hero Section */}

About React Starter Kit

A production-ready, full-stack web application template that combines modern development practices with cutting-edge technologies to deliver exceptional performance and developer experience.

{/* Mission Section */}
Our Mission Empowering developers to build faster, better web applications

React Starter Kit was created to bridge the gap between prototype and production. We believe that developers should focus on building great features, not wrestling with configuration and setup.

Our template provides a solid foundation with best practices, modern tooling, and optimized performance out of the box, so you can ship your ideas faster and with confidence.

{/* Key Features */}

What Makes Us Different

🎯 Production-Ready Not just a demo, but a real foundation for your applications

Every component, pattern, and configuration has been battle-tested in production environments. Security, performance, and maintainability are built-in from day one.

⚡ Edge-First Architecture Optimized for global performance at CDN edge locations

Built specifically for Cloudflare Workers and edge computing. Your applications run closer to your users for lightning-fast response times.

🔧 Developer Experience Carefully crafted tooling for maximum productivity

Hot reload, TypeScript support, comprehensive testing setup, and intuitive project structure. Everything you need to stay in the flow.

🌐 Full-Stack Solution Complete backend and frontend in one cohesive package

tRPC for type-safe APIs, Better Auth for authentication and database, and WebSocket support for real-time features.

{/* Technology Choices */}

Technology Choices

Frontend Stack

  • React 19: Latest React with concurrent features
  • TypeScript: Type safety and better developer experience
  • Vite: Lightning-fast build tool and dev server
  • TanStack Router: Type-safe routing with code splitting
  • shadcn/ui: Beautiful, accessible component library
  • Tailwind CSS: Utility-first CSS framework

Backend Stack

  • Bun: Fast JavaScript runtime and package manager
  • Hono: Ultra-fast web framework for edge computing
  • tRPC: End-to-end type safety for APIs
  • Better Auth: Authentication
  • Cloudflare Workers: Serverless edge computing
  • WebSockets: Real-time communication support
{/* Team Section */}

Built by Kriasoft

React Starter Kit is maintained by Kriasoft, a team of experienced developers passionate about modern web technologies and developer experience.

{/* CTA Section */}

Ready to Get Started?

Join thousands of developers who have chosen React Starter Kit for their next project.

); } ================================================ FILE: apps/app/routes/(app)/analytics.tsx ================================================ import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; import { Activity, DollarSign, TrendingUp, Users } from "lucide-react"; export const Route = createFileRoute("/(app)/analytics")({ component: Analytics, }); function Analytics() { const metrics = [ { title: "Total Revenue", value: "$45,231.89", change: "+20.1% from last month", icon: DollarSign, color: "text-green-600", }, { title: "Active Users", value: "2,350", change: "+180 from last month", icon: Users, color: "text-blue-600", }, { title: "Conversion Rate", value: "3.2%", change: "+0.5% from last month", icon: TrendingUp, color: "text-purple-600", }, { title: "Avg. Session Duration", value: "4m 32s", change: "+12s from last month", icon: Activity, color: "text-orange-600", }, ]; return (

Analytics

Track your application's performance and user engagement metrics.

{/* Metrics Grid */}
{metrics.map((metric) => ( {metric.title}
{metric.value}

{metric.change}

))}
{/* Charts Section */}
Revenue Overview Monthly revenue for the past 6 months
{/* Placeholder for chart */}

Chart visualization would go here

User Growth New vs returning users over time
{/* Placeholder for chart */}

Chart visualization would go here

{/* Top Pages */} Top Pages Most visited pages in your application
{[ { page: "/dashboard", views: 12543, percentage: 35 }, { page: "/products", views: 8932, percentage: 25 }, { page: "/settings", views: 6421, percentage: 18 }, { page: "/reports", views: 4532, percentage: 13 }, { page: "/about", views: 3221, percentage: 9 }, ].map((item) => (
{item.page} {item.views.toLocaleString()} views
))}
); } ================================================ FILE: apps/app/routes/(app)/dashboard.tsx ================================================ import { createFileRoute, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/(app)/dashboard")({ beforeLoad: () => { // Redirect to index which is the main dashboard throw redirect({ to: "/", }); }, }); ================================================ FILE: apps/app/routes/(app)/index.tsx ================================================ import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; import { Activity, FileText, TrendingUp, Users } from "lucide-react"; export const Route = createFileRoute("/(app)/")({ component: Dashboard, }); function Dashboard() { const stats = [ { title: "Total Users", value: "1,234", change: "+12%", icon: Users, }, { title: "Active Sessions", value: "89", change: "+5%", icon: Activity, }, { title: "Reports Generated", value: "456", change: "+23%", icon: FileText, }, { title: "Growth Rate", value: "18.2%", change: "+2.1%", icon: TrendingUp, }, ]; return (

Dashboard

Welcome back! Here's an overview of your application.

{/* Stats Grid */}
{stats.map((stat) => ( {stat.title}
{stat.value}

{stat.change} from last month

))}
{/* Main Content Area */}
Recent Activity Latest events in your application
{[1, 2, 3, 4].map((i) => (

User action performed

{i} hour{i > 1 ? "s" : ""} ago

))}
Quick Actions Common tasks and operations
); } ================================================ FILE: apps/app/routes/(app)/reports.tsx ================================================ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; import { Calendar, Download, FileText, Filter } from "lucide-react"; export const Route = createFileRoute("/(app)/reports")({ component: Reports, }); function Reports() { const reports = [ { id: 1, name: "Monthly Sales Report", type: "Sales", date: "2024-01-01", status: "Ready", }, { id: 2, name: "User Activity Report", type: "Analytics", date: "2024-01-15", status: "Ready", }, { id: 3, name: "Financial Summary", type: "Finance", date: "2024-01-20", status: "Processing", }, { id: 4, name: "Performance Metrics", type: "Performance", date: "2024-01-25", status: "Ready", }, ]; return (

Reports

Generate and download various reports for your data.

{/* Filters */} Filters Filter reports by type and date range
{/* Report Generation */} Generate New Report Create a custom report based on your needs
{/* Recent Reports */} Recent Reports Your recently generated reports
{reports.map((report) => (

{report.name}

{report.type} {report.date}
{report.status}
))}
); } ================================================ FILE: apps/app/routes/(app)/route.tsx ================================================ import { AuthErrorBoundary } from "@/components/auth"; import { Layout } from "@/components/layout"; import { getCachedSession, sessionQueryOptions } from "@/lib/queries/session"; import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/(app)")({ // Route-level authentication guard using cache-first strategy. // Checks cache before fetching to make navigation instant. beforeLoad: async ({ context, location }) => { let session = getCachedSession(context.queryClient); if (session === undefined) { session = await context.queryClient.fetchQuery(sessionQueryOptions()); } // Both user and session must exist for valid auth state if (!session?.user || !session?.session) { throw redirect({ to: "/login", search: { returnTo: location.href }, }); } return { user: session.user, session }; }, component: AppLayout, }); function AppLayout() { return ( ); } ================================================ FILE: apps/app/routes/(app)/settings.tsx ================================================ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Separator, Switch, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; import { Bell, CreditCard, Palette, Shield, User } from "lucide-react"; import { auth } from "@/lib/auth"; import { useBillingQuery } from "@/lib/queries/billing"; import { useSessionQuery } from "@/lib/queries/session"; export const Route = createFileRoute("/(app)/settings")({ component: Settings, }); function Settings() { return (

Settings

Manage your account settings and preferences.

{/* Profile Settings */}
Profile
Update your personal information and profile settings.
{/* Billing */} {/* Notification Settings */}
Notifications
Configure how you receive notifications.

Receive notifications via email

Receive push notifications in your browser

{/* Security Settings */}
Security
Manage your security preferences and authentication.
{/* Appearance Settings */}
Appearance
Customize the look and feel of the application.

Toggle dark mode theme

); } function BillingCard() { const { data: session } = useSessionQuery(); const activeOrgId = session?.session?.activeOrganizationId; const { data: billing, isLoading } = useBillingQuery(activeOrgId); const returnUrl = window.location.href; async function handleUpgrade(plan: "starter" | "pro") { try { await auth.subscription.upgrade({ plan, successUrl: returnUrl, cancelUrl: returnUrl, }); } catch (error) { console.error("Failed to start upgrade:", error); } } async function handleManageBilling() { try { await auth.subscription.billingPortal({ returnUrl }); } catch (error) { console.error("Failed to open billing portal:", error); } } const hasSubscription = billing?.status === "active" || billing?.status === "trialing"; const isCanceling = hasSubscription && billing.cancelAtPeriodEnd; return (
Billing
Manage your subscription and billing details.
{isLoading ? (

Loading...

) : hasSubscription ? ( <>

{billing.plan.charAt(0).toUpperCase() + billing.plan.slice(1)}{" "} plan ({billing.status})

{billing.periodEnd && (

{isCanceling ? "Access until" : "Renews on"}{" "} {new Date(billing.periodEnd).toLocaleDateString()}

)} {isCanceling && (

Your subscription will not renew. You can restore it from the billing portal.

)}
) : (

You are on the Free plan.

)}
); } ================================================ FILE: apps/app/routes/(app)/users.tsx ================================================ import { Avatar, AvatarFallback, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, } from "@repo/ui"; import { createFileRoute } from "@tanstack/react-router"; import { MoreVertical, Search, UserPlus, Users as UsersIcon, } from "lucide-react"; export const Route = createFileRoute("/(app)/users")({ component: Users, }); function Users() { const users = [ { id: 1, name: "John Doe", email: "john.doe@example.com", role: "Admin", status: "Active", lastActive: "2 hours ago", }, { id: 2, name: "Jane Smith", email: "jane.smith@example.com", role: "Editor", status: "Active", lastActive: "5 minutes ago", }, { id: 3, name: "Bob Johnson", email: "bob.johnson@example.com", role: "Viewer", status: "Inactive", lastActive: "2 days ago", }, { id: 4, name: "Alice Brown", email: "alice.brown@example.com", role: "Editor", status: "Active", lastActive: "1 hour ago", }, { id: 5, name: "Charlie Wilson", email: "charlie.wilson@example.com", role: "Viewer", status: "Active", lastActive: "30 minutes ago", }, ]; return (

Users

Manage user accounts and permissions.

{/* Stats */}
Total Users
1,234

+10% from last month

Active Users
892

72% of total users

New This Month
48

+32% from last month

{/* User List */} User Management View and manage all user accounts {/* Search Bar */}
{/* Users Table */}
{users.map((user) => ( ))}
User Role Status Last Active Actions
{user.name .split(" ") .map((n) => n[0]) .join("")}

{user.name}

{user.email}

{user.role} {user.status} {user.lastActive}
); } ================================================ FILE: apps/app/routes/(auth)/login.tsx ================================================ import { AuthForm } from "@/components/auth"; import { getSafeRedirectUrl } from "@/lib/auth-config"; import { revalidateSession, sessionQueryOptions } from "@/lib/queries/session"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, isRedirect, redirect, useRouter, } from "@tanstack/react-router"; import { z } from "zod"; // Sanitize returnTo at parse time - consumers get a safe value or undefined const searchSchema = z.object({ returnTo: z .string() .optional() .transform((val) => { const safe = getSafeRedirectUrl(val); return safe === "/" ? undefined : safe; }) .catch(undefined), }); export const Route = createFileRoute("/(auth)/login")({ validateSearch: searchSchema, beforeLoad: async ({ context, search }) => { try { const session = await context.queryClient.fetchQuery( sessionQueryOptions(), ); // Redirect authenticated users to their destination if (session?.user && session?.session) { throw redirect({ to: search.returnTo ?? "/" }); } } catch (error) { // Re-throw redirects, show login form for fetch errors if (isRedirect(error)) throw error; } }, component: LoginPage, }); function LoginPage() { const router = useRouter(); const queryClient = useQueryClient(); const search = Route.useSearch(); async function handleSuccess() { await revalidateSession(queryClient, router); await router.navigate({ to: search.returnTo ?? "/" }); } return (
); } ================================================ FILE: apps/app/routes/(auth)/signup.tsx ================================================ import { AuthForm } from "@/components/auth"; import { getSafeRedirectUrl } from "@/lib/auth-config"; import { revalidateSession, sessionQueryOptions } from "@/lib/queries/session"; import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute, isRedirect, redirect, useRouter, } from "@tanstack/react-router"; import { z } from "zod"; // Sanitize returnTo at parse time - consumers get a safe value or undefined const searchSchema = z.object({ returnTo: z .string() .optional() .transform((val) => { const safe = getSafeRedirectUrl(val); return safe === "/" ? undefined : safe; }) .catch(undefined), }); export const Route = createFileRoute("/(auth)/signup")({ validateSearch: searchSchema, beforeLoad: async ({ context, search }) => { try { const session = await context.queryClient.fetchQuery( sessionQueryOptions(), ); // Redirect authenticated users to their destination if (session?.user && session?.session) { throw redirect({ to: search.returnTo ?? "/" }); } } catch (error) { // Re-throw redirects, show signup form for fetch errors if (isRedirect(error)) throw error; } }, component: SignupPage, }); function SignupPage() { const router = useRouter(); const queryClient = useQueryClient(); const search = Route.useSearch(); async function handleSuccess() { await revalidateSession(queryClient, router); await router.navigate({ to: search.returnTo ?? "/" }); } return (
); } ================================================ FILE: apps/app/routes/__root.tsx ================================================ import { AppErrorBoundary } from "@/components/auth"; import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; // Only queryClient in context - needed for beforeLoad prefetching. // Auth client is a singleton (no hook equivalent in Better Auth). export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ component: Root, }); export function Root() { return ( {import.meta.env.DEV && } ); } ================================================ FILE: apps/app/styles/globals.css ================================================ @import "../tailwind.config.css"; /** * CSS Variables for ShadCN UI Theming * * These variables define the color scheme for light and dark modes. * They are referenced by the UI components and mapped to Tailwind * utilities in tailwind.config.css. * * Using oklch() for better color interpolation and consistency. * @see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch */ :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } ================================================ FILE: apps/app/tailwind.config.css ================================================ /** * Tailwind CSS v4 configuration for the main app. * @see https://tailwindcss.com/docs/v4-beta */ @import "tailwindcss"; /* Content paths for Tailwind to scan */ @source "./lib/**/*.{js,ts,jsx,tsx}"; @source "./routes/**/*.{js,ts,jsx,tsx}"; @source "./components/**/*.{js,ts,jsx,tsx}"; @source "./index.html"; @source "./index.tsx"; @source "../../packages/ui/components/**/*.{ts,tsx}"; @source "../../packages/ui/lib/**/*.{ts,tsx}"; @source "../../packages/ui/hooks/**/*.{ts,tsx}"; /* Custom dark mode variant */ @custom-variant dark (&:is(.dark *)); /* Theme configuration */ @theme inline { /* Border radius values */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); /* Color mappings for Tailwind utilities */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } ================================================ FILE: apps/app/tsconfig.json ================================================ { "extends": "../../packages/typescript-config/react.jsonc", "compilerOptions": { "noEmit": true, "tsBuildInfoFile": "../../.cache/tsconfig/web.tsbuildinfo", "baseUrl": ".", "paths": { "@/*": ["./*"], "@repo/api": ["../api"], "@repo/api/*": ["../api/*"], "@repo/core": ["../../packages/core"], "@repo/core/*": ["../../packages/core/*"], "@repo/db": ["../../db"], "@repo/db/*": ["../../db/*"], "@repo/ui": ["../../packages/ui"], "@repo/ui/*": ["../../packages/ui/*"], "@repo/ws-protocol": ["../../packages/ws-protocol"], "@repo/ws-protocol/*": ["../../packages/ws-protocol/*"] } }, "include": ["**/*.ts", "**/*.tsx", "**/*.json", "./global.d.ts"], "exclude": ["**/dist/**/*", "**/node_modules/**/*"], "references": [{ "path": "../api" }, { "path": "../../packages/ui" }] } ================================================ FILE: apps/app/vite.config.ts ================================================ import { tanstackRouter } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react-swc"; import { TLSSocket } from "node:tls"; import { URL, fileURLToPath } from "node:url"; import { loadEnv } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { defineProject } from "vitest/config"; const publicEnvVars = [ "APP_NAME", "APP_ORIGIN", "GOOGLE_CLOUD_PROJECT", "GA_MEASUREMENT_ID", ]; /** * Vite configuration. * https://vitejs.dev/config/ */ export default defineProject(({ mode }) => { const envDir = fileURLToPath(new URL("../..", import.meta.url)); const env = loadEnv(mode, envDir, ""); publicEnvVars.forEach((key) => { if (!env[key]) throw new Error(`Missing environment variable: ${key}`); process.env[`VITE_${key}`] = env[key]; }); return { cacheDir: fileURLToPath(new URL("../../.cache/vite-app", import.meta.url)), build: { rollupOptions: { output: { assetFileNames: "_app/assets/[name]-[hash][extname]", chunkFileNames: "_app/assets/[name]-[hash].js", entryFileNames: "_app/assets/[name]-[hash].js", manualChunks: { react: ["react", "react-dom"], tanstack: ["@tanstack/react-router"], ui: [ "@radix-ui/react-slot", "class-variance-authority", "clsx", "tailwind-merge", ], }, }, }, }, resolve: { conditions: ["module", "browser", "development|production"], }, css: { postcss: "./postcss.config.js", }, plugins: [ tsconfigPaths(), tanstackRouter({ routesDirectory: "./routes", generatedRouteTree: "./lib/routeTree.gen.ts", routeFileIgnorePrefix: "-", quoteStyle: "single", semicolons: false, autoCodeSplitting: true, // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any, // https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc react(), ], server: { proxy: { // Proxy API requests to the backend server during development "/api": { target: env.API_ORIGIN, changeOrigin: true, configure(proxy) { proxy.on("proxyReq", (proxyReq, req) => { // Forward the frontend's origin to the API server // This allows the API to know the actual client origin for: // - CORS configuration // - Better Auth baseURL and trustedOrigins // - Redirect URLs and callbacks const proto = req.socket instanceof TLSSocket ? "https" : "http"; const host = req.headers.host || ""; const origin = req.headers.origin || `${proto}://${host}`; proxyReq.setHeader("x-forwarded-origin", origin); }); }, }, }, }, test: { environment: "happy-dom", setupFiles: ["./vitest.setup.ts"], }, }; }); ================================================ FILE: apps/app/vitest.setup.ts ================================================ import "@testing-library/jest-dom/vitest"; ================================================ FILE: apps/app/wrangler.jsonc ================================================ { "$schema": "../../node_modules/wrangler/config-schema.json", // [METADATA] // App worker accessed via service binding from web worker (no routes needed). "name": "example-app", "compatibility_date": "2025-08-15", "compatibility_flags": [], "workers_dev": false, // [ASSETS] // Serves bundled JavaScript, CSS, images, and other static assets. "assets": { "directory": "./dist", "not_found_handling": "single-page-application" }, // [ENV:PRODUCTION] // Command: bun wrangler deploy "vars": { "ENVIRONMENT": "production", "ALLOWED_ORIGINS": "https://example.com" }, "env": { // [ENV:DEVELOPMENT] // Command: bun wrangler dev "dev": { "vars": { "ENVIRONMENT": "development", "ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173" } }, // [ENV:STAGING] // Command: bun wrangler deploy --env staging "staging": { "vars": { "ENVIRONMENT": "staging", "ALLOWED_ORIGINS": "https://staging.example.com" } }, // [ENV:PREVIEW] // Command: bun wrangler deploy --env preview "preview": { "vars": { "ENVIRONMENT": "preview", "ALLOWED_ORIGINS": "https://preview.example.com" } } } } ================================================ FILE: apps/email/README.md ================================================ # Email Templates Transactional email templates built with React Email. [Documentation](https://reactstarter.com/email) ## Templates - **EmailVerification** - Email verification with verification link - **PasswordReset** - Password reset with secure reset link - **OTPEmail** - One-time password codes for sign-in, verification, or password reset ## Development ```bash # Start email preview development server bun email:dev # Build email templates bun email:build # Export static email templates bun email:export ``` The development server will be available at ## Usage ```typescript import { EmailVerification, renderEmailToHtml } from "@repo/email"; const component = EmailVerification({ userName: "John Doe", verificationUrl: "https://example.com/verify?token=abc123", appName: "My App", appUrl: "https://example.com", }); const html = await renderEmailToHtml(component); ``` ## Structure ```bash templates/ # React Email component templates components/ # Shared components (BaseTemplate) utils/ # Rendering utilities emails/ # Preview files for development server ``` ================================================ FILE: apps/email/components/BaseTemplate.tsx ================================================ import { Body, Container, Head, Hr, Html, Img, Link, Preview, Section, Text, } from "@react-email/components"; import type { ReactNode } from "react"; interface BaseTemplateProps { preview: string; children: ReactNode; appName?: string; appUrl?: string; } // Color constants for consistent styling const colors = { primary: "#007bff", danger: "#dc3545", text: "#32325d", textMuted: "#525f7f", textLight: "#8898aa", border: "#e6e8eb", background: "#f6f9fc", white: "#ffffff", warning: "#fff3cd", warningBorder: "#ffeaa7", } as const; export function BaseTemplate({ preview, children, appName = "React Starter Kit", appUrl = "https://example.com", }: BaseTemplateProps) { // Embedded SVG logo as data URI for better email compatibility const logoDataUri = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8Y2lyY2xlIGN4PSIyMCIgY3k9IjIwIiByPSIyMCIgZmlsbD0iIzAwN2JmZiIvPgogIDx0ZXh0IHg9IjIwIiB5PSIyNiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0id2hpdGUiIGZvbnQtZmFtaWx5PSItYXBwbGUtc3lzdGVtLEJsaW5rTWFjU3lzdGVtRm9udCwnU2Vnb2UgVUknLFJvYm90bywnSGVsdmV0aWNhIE5ldWUnLFVidW50dSxzYW5zLXNlcmlmIiBmb250LXNpemU9IjE4IiBmb250LXdlaWdodD0iNjAwIj5SPC90ZXh0Pgo8L3N2Zz4K"; return ( {preview} {/* Header */}
{appName} {appName}
{/* Main Content */}
{children}
{/* Footer */}
This email was sent by {appName}. If you didn't expect this email, you can safely ignore it. {/* NOTE: Links assume standard /unsubscribe and /privacy routes exist */} {appUrl && ( Unsubscribe {" "} |{" "} Privacy Policy )}
); } const main = { backgroundColor: colors.background, fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif', }; const container = { backgroundColor: colors.white, margin: "0 auto", padding: "20px 0 48px", marginBottom: "64px", maxWidth: "600px", }; const header = { padding: "0 48px", textAlign: "center" as const, borderBottom: `1px solid ${colors.border}`, paddingBottom: "20px", marginBottom: "32px", }; const logo = { margin: "0 auto 8px auto", display: "block", }; const headerText = { fontSize: "24px", fontWeight: "600", color: colors.text, margin: "0", textAlign: "center" as const, }; const content = { padding: "0 48px", }; const hr = { borderColor: colors.border, margin: "20px 0", }; const footer = { padding: "0 48px", }; const footerText = { color: colors.textLight, fontSize: "12px", lineHeight: "16px", textAlign: "center" as const, margin: "0 0 8px 0", }; const footerLink = { color: colors.primary, textDecoration: "underline", }; export { colors }; ================================================ FILE: apps/email/emails/email-verification.tsx ================================================ import { EmailVerification } from "../templates/email-verification"; export default function EmailVerificationPreview() { return ( ); } ================================================ FILE: apps/email/emails/otp-password-reset.tsx ================================================ import { OTPEmail } from "../templates/otp-email"; export default function OTPPasswordResetPreview() { return ( ); } ================================================ FILE: apps/email/emails/otp-sign-in.tsx ================================================ import { OTPEmail } from "../templates/otp-email"; export default function OTPSignInPreview() { return ( ); } ================================================ FILE: apps/email/emails/otp-verification.tsx ================================================ import { OTPEmail } from "../templates/otp-email"; export default function OTPVerificationPreview() { return ( ); } ================================================ FILE: apps/email/emails/password-reset.tsx ================================================ import { PasswordReset } from "../templates/password-reset"; export default function PasswordResetPreview() { return ( ); } ================================================ FILE: apps/email/index.ts ================================================ export { EmailVerification } from "./templates/email-verification.js"; export { PasswordReset } from "./templates/password-reset.js"; export { OTPEmail } from "./templates/otp-email.js"; export { renderEmailToHtml, renderEmailToText } from "./utils/render.js"; ================================================ FILE: apps/email/package.json ================================================ { "name": "@repo/email", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, "./templates/*": { "types": "./dist/templates/*.d.ts", "import": "./dist/templates/*.js" }, "./package.json": "./package.json" }, "scripts": { "dev": "bunx react-email dev --port 3001", "build": "tsc", "export": "bunx react-email export", "typecheck": "tsc --noEmit" }, "dependencies": { "@react-email/components": "^1.0.8", "@react-email/render": "^2.0.4", "react": "19.2.4" }, "devDependencies": { "@react-email/preview-server": "^5.2.8", "@repo/typescript-config": "workspace:*", "@types/react": "^19.2.14", "react-email": "^5.2.8", "typescript": "~5.9.3" } } ================================================ FILE: apps/email/templates/email-verification.tsx ================================================ import { Button, Heading, Section, Text } from "@react-email/components"; import { BaseTemplate, colors } from "../components/BaseTemplate"; interface EmailVerificationProps { userName?: string; verificationUrl: string; appName?: string; appUrl?: string; } export function EmailVerification({ userName, verificationUrl, appName, appUrl, }: EmailVerificationProps) { const preview = `Verify your email address for ${appName || "your account"}`; return ( Verify your email address Hi{userName ? ` ${userName}` : ""}, Thanks for signing up! Please click the button below to verify your email address and complete your account setup.
Or copy and paste this URL into your browser: {verificationUrl} This verification link will expire in 24 hours for security reasons. If you didn't create an account with us, you can safely ignore this email.
); } const heading = { fontSize: "24px", fontWeight: "600", color: colors.text, margin: "0 0 24px", }; const paragraph = { fontSize: "16px", lineHeight: "24px", color: colors.textMuted, margin: "0 0 16px", }; const buttonContainer = { textAlign: "center" as const, margin: "32px 0", }; const button = { backgroundColor: colors.primary, borderRadius: "6px", color: colors.white, fontSize: "16px", fontWeight: "600", textDecoration: "none", textAlign: "center" as const, display: "inline-block", padding: "12px 24px", lineHeight: "20px", }; const linkText = { fontSize: "14px", color: colors.textLight, wordBreak: "break-all" as const, margin: "0 0 16px", padding: "12px", backgroundColor: "#f8f9fa", borderRadius: "4px", border: "1px solid #e9ecef", }; ================================================ FILE: apps/email/templates/otp-email.tsx ================================================ import { Heading, Section, Text } from "@react-email/components"; import { BaseTemplate, colors } from "../components/BaseTemplate"; interface OTPEmailProps { otp: string; type: "sign-in" | "email-verification" | "forget-password"; appName?: string; appUrl?: string; expiresInMinutes?: number; } export function OTPEmail({ otp, type, appName, appUrl, expiresInMinutes = 5, }: OTPEmailProps) { // [CONTENT_MAPPING] Maps type enum to user-facing labels and descriptions const typeLabels = { "sign-in": "Sign In", "email-verification": "Email Verification", "forget-password": "Password Reset", }; const typeDescriptions = { "sign-in": "complete your sign in", "email-verification": "verify your email address", "forget-password": "reset your password", }; const typeLabel = typeLabels[type]; const typeDescription = typeDescriptions[type]; const preview = `Your ${typeLabel} code: ${otp}`; return ( Your {typeLabel} Code Use the verification code below to {typeDescription}:
{otp}
This code will expire in {expiresInMinutes} minutes for security reasons. If you didn't request this code, you can safely ignore this email. {/* WARNING: Security notice shown only for password reset to emphasize risk */} {type === "forget-password" && ( Security tip: Never share this verification code with anyone. Our support team will never ask for your verification codes. )}
); } const heading = { fontSize: "24px", fontWeight: "600", color: colors.text, margin: "0 0 24px", }; const paragraph = { fontSize: "16px", lineHeight: "24px", color: colors.textMuted, margin: "0 0 16px", }; const otpContainer = { textAlign: "center" as const, margin: "32px 0", padding: "24px", backgroundColor: "#f8f9fa", border: "2px solid #e9ecef", borderRadius: "8px", }; const otpText = { fontSize: "36px", fontWeight: "bold", letterSpacing: "0.5em", color: colors.primary, fontFamily: "Monaco, Consolas, monospace", margin: "0", textAlign: "center" as const, }; const securityNote = { fontSize: "14px", lineHeight: "20px", color: "#6c757d", margin: "24px 0 0", padding: "16px", backgroundColor: colors.warning, borderRadius: "4px", border: `1px solid ${colors.warningBorder}`, }; ================================================ FILE: apps/email/templates/password-reset.tsx ================================================ import { Button, Heading, Section, Text } from "@react-email/components"; import { BaseTemplate, colors } from "../components/BaseTemplate"; interface PasswordResetProps { userName?: string; resetUrl: string; appName?: string; appUrl?: string; } export function PasswordReset({ userName, resetUrl, appName, appUrl, }: PasswordResetProps) { const preview = `Reset your password for ${appName || "your account"}`; return ( Reset your password Hi{userName ? ` ${userName}` : ""}, We received a request to reset your password. Click the button below to choose a new password.
Or copy and paste this URL into your browser: {resetUrl} {/* NOTE: 1-hour expiration balances security vs user convenience */} This password reset link will expire in 1 hour for security reasons. If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged. Security tip: Never share this reset link with anyone. Our support team will never ask for your password or login credentials.
); } const heading = { fontSize: "24px", fontWeight: "600", color: colors.text, margin: "0 0 24px", }; const paragraph = { fontSize: "16px", lineHeight: "24px", color: colors.textMuted, margin: "0 0 16px", }; const buttonContainer = { textAlign: "center" as const, margin: "32px 0", }; // Uses danger color (red) to emphasize the security-sensitive nature of password resets const button = { backgroundColor: colors.danger, borderRadius: "6px", color: colors.white, fontSize: "16px", fontWeight: "600", textDecoration: "none", textAlign: "center" as const, display: "inline-block", padding: "12px 24px", lineHeight: "20px", }; const linkText = { fontSize: "14px", color: colors.textLight, wordBreak: "break-all" as const, margin: "0 0 16px", padding: "12px", backgroundColor: "#f8f9fa", borderRadius: "4px", border: "1px solid #e9ecef", }; const securityNote = { fontSize: "14px", lineHeight: "20px", color: "#6c757d", margin: "24px 0 0", padding: "16px", backgroundColor: colors.warning, borderRadius: "4px", border: `1px solid ${colors.warningBorder}`, }; ================================================ FILE: apps/email/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "skipLibCheck": true, "strict": true, "declaration": true, "declarationMap": true, "outDir": "dist", "rootDir": ".", "jsx": "react-jsx" }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["dist", "node_modules", ".email"] } ================================================ FILE: apps/email/utils/render.ts ================================================ import { render } from "@react-email/render"; import type { ReactElement } from "react"; /** * Render a React email component to HTML string * @param component React email component * @returns HTML string */ export async function renderEmailToHtml( component: ReactElement, ): Promise { return await render(component, { pretty: true }); } /** * Render a React email component to plain text string * @param component React email component * @returns Plain text string */ export async function renderEmailToText( component: ReactElement, ): Promise { return await render(component, { plainText: true }); } ================================================ FILE: apps/web/README.md ================================================ # Edge Router Astro-based edge worker that routes traffic to the app and API workers via Cloudflare service bindings. [Documentation](https://reactstarter.com/architecture/edge) | [Deployment](https://reactstarter.com/deployment/) ## Development ```bash bun web:dev # Start dev server (http://localhost:4321) bun web:build # Build for production bun web:deploy # Deploy to Cloudflare Workers ``` ## Routing - `/api/*` → API worker - App routes → App worker - Static assets served directly from the edge ================================================ FILE: apps/web/_headers ================================================ /* Strict-Transport-Security: max-age=31536000; includeSubDomains; preload X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self' X-Frame-Options: DENY /*.html Cache-Control: public, max-age=0, must-revalidate /*.css Cache-Control: public, max-age=31536000, immutable /*.js Cache-Control: public, max-age=31536000, immutable /*.woff2 Cache-Control: public, max-age=31536000, immutable /*.woff Cache-Control: public, max-age=31536000, immutable /*.ttf Cache-Control: public, max-age=31536000, immutable /*.svg Cache-Control: public, max-age=86400 /*.jpg Cache-Control: public, max-age=86400 /*.jpeg Cache-Control: public, max-age=86400 /*.png Cache-Control: public, max-age=86400 /*.webp Cache-Control: public, max-age=86400 /*.ico Cache-Control: public, max-age=86400 ================================================ FILE: apps/web/astro.config.mjs ================================================ import react from "@astrojs/react"; import { defineConfig } from "astro/config"; import { loadEnv } from "vite"; // Load root .env variables for the Astro build process (side-effect: populates process.env) loadEnv(process.env.NODE_ENV || "development", "../..", ""); export default defineConfig({ site: process.env.PUBLIC_APP_ORIGIN, srcDir: ".", publicDir: "./public", outDir: "./dist", output: "static", integrations: [react()], }); ================================================ FILE: apps/web/layouts/BaseLayout.astro ================================================ --- import '@/styles/globals.css'; export interface Props { title?: string; description?: string; image?: string; } const { title = 'React Starter Kit - Modern Full-Stack Web Application', description = 'Modern full-stack web application template optimized for serverless deployment to CDN edge locations. Built with React, TypeScript, and the latest web technologies.', image = '/og-image.png' } = Astro.props; --- {title}
================================================ FILE: apps/web/lib/utils.ts ================================================ // Re-export utils from the UI package export * from "@repo/ui/lib/utils"; ================================================ FILE: apps/web/package.json ================================================ { "name": "@repo/web", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview", "check": "astro check", "typecheck": "tsc --noEmit", "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local", "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local" }, "dependencies": { "@astrojs/react": "^4.4.2", "@repo/ui": "workspace:*", "astro": "^5.17.2", "hono": "^4.11.10", "react-dom": "^19.2.4", "react": "^19.2.4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260218.0", "@repo/typescript-config": "workspace:*", "@tailwindcss/postcss": "^4.2.0", "@types/react-dom": "^19.2.3", "@types/react": "^19.2.14", "postcss": "^8.5.6", "tailwindcss": "^4.2.0", "typescript": "~5.9.3", "wrangler": "^4.66.0" } } ================================================ FILE: apps/web/pages/about.astro ================================================ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Separator } from '@repo/ui'; const title = "About - React Starter Kit"; const description = "Learn about React Starter Kit, a production-ready full-stack web application template built with modern technologies."; ---

About React Starter Kit

A production-ready, full-stack web application template that combines modern development practices with cutting-edge technologies to deliver exceptional performance and developer experience.

Our Mission Empowering developers to build faster, better web applications

React Starter Kit was created to bridge the gap between prototype and production. We believe that developers should focus on building great features, not wrestling with configuration and setup.

Our template provides a solid foundation with best practices, modern tooling, and optimized performance out of the box, so you can ship your ideas faster and with confidence.

What Makes Us Different

🎯 Production-Ready Not just a demo, but a real foundation for your applications

Every component, pattern, and configuration has been battle-tested in production environments. Security, performance, and maintainability are built-in from day one.

⚡ Edge-First Architecture Optimized for global performance at CDN edge locations

Built specifically for Cloudflare Workers and edge computing. Your applications run closer to your users for lightning-fast response times.

🔧 Developer Experience Carefully crafted tooling for maximum productivity

Hot reload, TypeScript support, comprehensive testing setup, and intuitive project structure. Everything you need to stay in the flow.

🌐 Full-Stack Solution Complete backend and frontend in one cohesive package

tRPC for type-safe APIs, Better Auth for authentication and database, and WebSocket support for real-time features.

Technology Choices

Frontend Stack

  • React 19: Latest React with concurrent features
  • TypeScript: Type safety and better developer experience
  • Astro: Lightning-fast static site generation
  • TanStack Router: Type-safe routing with code splitting
  • ShadCN UI: Beautiful, accessible component library
  • Tailwind CSS: Utility-first CSS framework

Backend Stack

  • Bun: Fast JavaScript runtime and package manager
  • Hono: Ultra-fast web framework for edge computing
  • tRPC: End-to-end type safety for APIs
  • Better Auth: Authentication
  • Cloudflare Workers: Serverless edge computing
  • WebSockets: Real-time communication support

Built by Kriasoft

React Starter Kit is maintained by Kriasoft, a team of experienced developers passionate about modern web technologies and developer experience.

Ready to Get Started?

Join thousands of developers who have chosen React Starter Kit for their next project.

================================================ FILE: apps/web/pages/features.astro ================================================ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui'; const title = "Features - React Starter Kit"; const description = "Explore all the powerful features that make React Starter Kit the perfect foundation for your next project."; const featureCategories = [ { title: "Developer Experience", icon: "🛠️", features: [ { title: "TypeScript First", description: "Full TypeScript support with strict type checking and IntelliSense" }, { title: "Hot Module Replacement", description: "Instant feedback with fast refresh and state preservation" }, { title: "Monorepo Structure", description: "Organized workspace management with Bun workspaces" }, { title: "Code Generation", description: "Automated route generation and type inference" } ] }, { title: "Performance", icon: "⚡", features: [ { title: "Edge Rendering", description: "Server-side rendering at CDN edge locations worldwide" }, { title: "Code Splitting", description: "Automatic route-based code splitting for optimal loading" }, { title: "Asset Optimization", description: "Image optimization, lazy loading, and efficient bundling" }, { title: "Zero-Config CDN", description: "Built-in CDN support with Cloudflare Workers" } ] }, { title: "Full-Stack Capabilities", icon: "🚀", features: [ { title: "Type-Safe APIs", description: "End-to-end type safety with tRPC" }, { title: "Authentication", description: "Built-in auth with Better Auth and session management" }, { title: "Database Integration", description: "PostgreSQL with Drizzle ORM and migrations" }, { title: "Real-time Support", description: "WebSocket integration for live features" } ] }, { title: "UI & Design", icon: "🎨", features: [ { title: "Component Library", description: "Pre-built components with ShadCN UI" }, { title: "Tailwind CSS v4", description: "Latest Tailwind with automatic class sorting" }, { title: "Dark Mode", description: "Built-in theme support with system preference detection" }, { title: "Responsive Design", description: "Mobile-first approach with adaptive layouts" } ] }, { title: "Testing & Quality", icon: "✅", features: [ { title: "Unit Testing", description: "Vitest setup with coverage reporting" }, { title: "E2E Testing", description: "Playwright integration for browser testing" }, { title: "Type Checking", description: "Strict TypeScript configuration" }, { title: "Linting & Formatting", description: "ESLint and Prettier pre-configured" } ] }, { title: "Deployment & DevOps", icon: "☁️", features: [ { title: "CI/CD Pipeline", description: "GitHub Actions workflow included" }, { title: "Infrastructure as Code", description: "Terraform configuration for Cloudflare" }, { title: "Environment Management", description: "Multi-environment support with .env files" }, { title: "Monitoring", description: "Built-in error tracking and analytics" } ] } ]; ---

Powerful Features for Modern Development

Everything you need to build, test, and deploy production-ready web applications with confidence and speed.

{featureCategories.map((category) => (
{category.icon}

{category.title}

{category.features.map((feature) => ( {feature.title} {feature.description} ))}
))}

Built with Best-in-Class Technologies

{[ { name: "React 19", category: "UI Framework" }, { name: "TypeScript 5.9", category: "Language" }, { name: "Astro", category: "Static Site Gen" }, { name: "Bun", category: "Runtime" }, { name: "Vite", category: "Build Tool" }, { name: "TanStack Router", category: "Routing" }, { name: "tRPC", category: "API Layer" }, { name: "Hono", category: "Web Framework" }, { name: "ShadCN UI", category: "Components" }, { name: "Tailwind CSS", category: "Styling" }, { name: "Drizzle ORM", category: "Database" }, { name: "PostgreSQL", category: "Database" }, { name: "Better Auth", category: "Authentication" }, { name: "Cloudflare", category: "Platform" }, { name: "Vitest", category: "Testing" }, { name: "Terraform", category: "Infrastructure" } ].map((tech) => (
{tech.name}
{tech.category}
))}

Clean, Modern Code

Example: Type-Safe API Route Define your API with full type safety from backend to frontend
            {`// api/router.ts
export const appRouter = router({
  user: {
    get: publicProcedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        return await db.user.findUnique({
          where: { id: input.id }
        });
      }),
  },
});

// app/page.tsx
function UserProfile({ userId }: Props) {
  const { data } = trpc.user.get.useQuery({ id: userId });
  
  return 
{data?.name}
; }`}

Experience the Difference

See why developers choose React Starter Kit for their most important projects.

================================================ FILE: apps/web/pages/index.astro ================================================ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui'; const features = [ { title: "⚡ Lightning Fast", description: "SSR at CDN edge locations with ~100 Lighthouse scores", content: "Optimized for Cloudflare Workers with Bun runtime for maximum performance." }, { title: "🎨 Modern UI", description: "Beautiful components with ShadCN UI and Tailwind CSS", content: "Pre-built components following modern design principles and accessibility standards." }, { title: "🚀 Developer Experience", description: "TypeScript, hot reload, and excellent tooling", content: "Full TypeScript support with Vite, TanStack Router, and modern development tools." }, { title: "📱 Full-Stack", description: "tRPC API with PostgreSQL and authentication", content: "Complete backend solution with type-safe APIs and database integration." }, { title: "🔧 Configurable", description: "Monorepo structure with workspace management", content: "Organized codebase with separate app, API, edge, and database workspaces." }, { title: "☁️ Serverless Ready", description: "Deploy to edge locations worldwide", content: "Built for Cloudflare Workers with global distribution and automatic scaling." } ]; const technologies = [ "React 19", "TypeScript", "Astro", "TanStack Router", "ShadCN UI", "Tailwind CSS", "Bun", "Hono", "tRPC", "PostgreSQL", "Cloudflare", "Jotai" ]; ---

React Starter Kit

Modern full-stack web application template optimized for serverless deployment to CDN edge locations. Built with React, TypeScript, and the latest web technologies.

Everything you need to build modern web apps

React Starter Kit provides a solid foundation with best practices, modern tooling, and optimized performance out of the box.

{features.map((feature) => ( {feature.title} {feature.description}

{feature.content}

))}

Built with Modern Technologies

Carefully selected technologies that work together seamlessly

{technologies.map((tech) => (
{tech}
))}

Ready to start building?

Get started with React Starter Kit today and build your next project with confidence.

================================================ FILE: apps/web/pages/pricing.astro ================================================ --- import BaseLayout from '@/layouts/BaseLayout.astro'; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@repo/ui'; const title = "Pricing - React Starter Kit"; const description = "React Starter Kit is free and open source. Choose the support level that's right for your team."; const plans = [ { name: "Open Source", price: "$0", period: "forever", description: "Perfect for personal projects and learning", features: [ "Full source code access", "MIT License", "Community support", "GitHub issues", "Regular updates", "All features included" ], cta: "Get Started", href: "https://github.com/kriasoft/react-starter-kit", variant: "outline" as const }, { name: "Professional", price: "$299", period: "one-time", description: "For teams that need priority support", popular: true, features: [ "Everything in Open Source", "Priority email support", "Private Discord channel", "Code review sessions", "Architecture consultation", "Custom deployment help" ], cta: "Contact Sales", href: "mailto:hello@kriasoft.com?subject=React Starter Kit Professional", variant: "default" as const }, { name: "Enterprise", price: "Custom", period: "contact us", description: "For organizations with specific needs", features: [ "Everything in Professional", "SLA guarantees", "Custom integrations", "Training workshops", "Dedicated support team", "White-label options" ], cta: "Contact Us", href: "mailto:hello@kriasoft.com?subject=React Starter Kit Enterprise", variant: "outline" as const } ]; ---

Simple, Transparent Pricing

React Starter Kit is free and open source. Choose additional support options based on your team's needs.

{plans.map((plan) => ( {plan.popular && (
MOST POPULAR
)} {plan.name} {plan.description}
{plan.price} {plan.period && ( {plan.period} )}
    {plan.features.map((feature) => (
  • {feature}
  • ))}
))}

Frequently Asked Questions

Is React Starter Kit really free?

Yes! React Starter Kit is completely free and open source under the MIT license. You can use it for personal or commercial projects without any restrictions.

What's included in support?

Professional and Enterprise support includes direct access to our team for technical questions, code reviews, and deployment assistance.

Can I upgrade later?

Absolutely! You can start with the open source version and upgrade to Professional or Enterprise support whenever you need it.

Do you offer custom development?

Yes, our Enterprise plan includes custom development and integration services. Contact us to discuss your specific requirements.

Ready to Build Something Amazing?

Start with our free open source version and upgrade when you need professional support.

================================================ FILE: apps/web/postcss.config.js ================================================ export default { plugins: { "@tailwindcss/postcss": {}, }, }; ================================================ FILE: apps/web/public/robots.txt ================================================ # www.robotstxt.org/ # Allow crawling of all content User-agent: * Disallow: ================================================ FILE: apps/web/public/site.manifest ================================================ { "short_name": "React App", "name": "React App Sample", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" }, { "src": "logo192.png", "type": "image/png", "sizes": "192x192" }, { "src": "logo512.png", "type": "image/png", "sizes": "512x512" } ], "start_url": "/?utm_source=homescreen", "display": "standalone", "background_color": "#fafafa", "theme_color": "#fafafa" } ================================================ FILE: apps/web/styles/globals.css ================================================ @import "../tailwind.config.css"; /** * CSS Variables for ShadCN UI Theming * * These variables define the color scheme for light and dark modes. * They are referenced by the UI components and mapped to Tailwind * utilities in tailwind.config.css. * * Using oklch() for better color interpolation and consistency. * @see https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch */ :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } ================================================ FILE: apps/web/tailwind.config.css ================================================ /** * Tailwind CSS v4 configuration for the web app. * @see https://tailwindcss.com/docs/configuration */ @import "tailwindcss"; /* Content paths for Tailwind to scan */ @source "./pages/**/*.{astro,js,ts,jsx,tsx}"; @source "./layouts/**/*.{astro,js,ts,jsx,tsx}"; @source "../../packages/ui/components/**/*.{ts,tsx}"; @source "../../packages/ui/lib/**/*.{ts,tsx}"; /* Custom dark mode variant */ @custom-variant dark (&:is(.dark *)); /* Theme configuration */ @theme inline { /* Border radius values */ --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); /* Color mappings for Tailwind utilities */ --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); } ================================================ FILE: apps/web/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "compilerOptions": { "composite": true, "noEmit": true, "tsBuildInfoFile": "../../.cache/tsconfig/web.tsbuildinfo", "baseUrl": ".", "jsx": "react-jsx", "jsxImportSource": "react", "paths": { "@/*": ["./*"], "@repo/ui": ["../../packages/ui"], "@repo/ui/*": ["../../packages/ui/*"] }, "types": ["astro/client", "@cloudflare/workers-types"] }, "include": ["**/*.ts", "**/*.tsx", "**/*.json", "**/*.astro"], "exclude": ["**/dist/**/*", "**/node_modules/**/*"], "references": [{ "path": "../../packages/ui" }] } ================================================ FILE: apps/web/worker.ts ================================================ /** * Edge router for the marketing site. * * Routes "/" based on auth-hint cookie presence: * - Cookie present: proxy to app (session validated there) * - No cookie: serve marketing site * * See docs/adr/001-auth-hint-cookie.md */ import { Hono } from "hono"; import { getCookie } from "hono/cookie"; interface Env { ASSETS: Fetcher; APP_SERVICE: Fetcher; API_SERVICE: Fetcher; } const app = new Hono<{ Bindings: Env }>(); // API proxy app.all("/api/*", (c) => c.env.API_SERVICE.fetch(c.req.raw)); // App routes app.all("/_app/*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.all("/login*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.all("/signup*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.all("/settings*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.all("/analytics*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.all("/reports*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); // Home page: route based on auth-hint cookie presence // __Host-auth (HTTPS) or auth (HTTP dev) — see docs/adr/001-auth-hint-cookie.md app.on(["GET", "HEAD"], "/", async (c) => { const hasAuthHint = getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1"; const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch( c.req.raw, ); // Prevent caching — response varies by auth state const headers = new Headers(upstream.headers); headers.set("Cache-Control", "private, no-store"); headers.set("Vary", "Cookie"); return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers, }); }); // Marketing pages app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw)); export default app; ================================================ FILE: apps/web/wrangler.jsonc ================================================ { "$schema": "../../node_modules/wrangler/config-schema.json", // [METADATA] // Edge router that receives all traffic and routes to api/app via service bindings. "name": "example-web", "main": "./worker.ts", "compatibility_date": "2025-08-15", "compatibility_flags": [], "workers_dev": false, // [ASSETS] // Serves bundled JavaScript, CSS, images, and other static assets. "assets": { "directory": "./dist", "binding": "ASSETS", // Force worker execution for "/" to enable auth-aware routing // See docs/adr/001-auth-hint-cookie.md "run_worker_first": ["/"] }, // [SERVICE BINDINGS] // Connect to other workers for dynamic routing. // NOTE: services is non-inheritable — must be specified per environment. // Use naming convention: - (e.g., example-app-staging) "services": [ { "binding": "APP_SERVICE", "service": "example-app" }, { "binding": "API_SERVICE", "service": "example-api" } ], // [ENV:PRODUCTION] // Command: bun wrangler deploy // prettier-ignore "routes": [ { "pattern": "example.com/*", "zone_name": "example.com" } ], "vars": { "ENVIRONMENT": "production" }, "env": { // [ENV:DEVELOPMENT] // Command: bun wrangler dev "dev": { "vars": { "ENVIRONMENT": "development" } }, // [ENV:STAGING] // Command: bun wrangler deploy --env staging "staging": { "services": [ { "binding": "APP_SERVICE", "service": "example-app-staging" }, { "binding": "API_SERVICE", "service": "example-api-staging" } ], "vars": { "ENVIRONMENT": "staging" }, // prettier-ignore "routes": [ { "pattern": "staging.example.com/*", "zone_name": "example.com" } ] }, // [ENV:PREVIEW] // Command: bun wrangler deploy --env preview "preview": { "services": [ { "binding": "APP_SERVICE", "service": "example-app-preview" }, { "binding": "API_SERVICE", "service": "example-api-preview" } ], "vars": { "ENVIRONMENT": "preview" }, // prettier-ignore "routes": [ { "pattern": "preview.example.com/*", "zone_name": "example.com" } ] } } } ================================================ FILE: db/AGENTS.md ================================================ ## Schema Conventions - Drizzle `casing: "snake_case"` — use camelCase in TypeScript, columns map to snake_case in DB. - All primary keys: `text().primaryKey().$defaultFn(() => generateAuthId(...))` — application-generated prefixed CUID2 IDs (e.g. `usr_ght4k2jxm7pqbv01`). See `db/schema/id.ts` for prefix map. - Timestamps: `timestamp({ withTimezone: true, mode: "date" })`. Every table has `createdAt` (`.defaultNow().notNull()`) and `updatedAt` (`.defaultNow().$onUpdate(() => new Date()).notNull()`). - `identity` table = Better Auth's `account` table, renamed via `account.modelName: "identity"` in auth config. - `member.role` and `invitation.status` are free `text`, not pgEnum — avoids fragile coupling with Better Auth's values. - `organization.metadata` is `text`, not JSONB — Better Auth handles serialization. ## Extended Fields (beyond Better Auth defaults) - **Passkey:** `lastUsedAt` (security audits), `deviceName` (user-friendly label), `platform` ("platform" | "cross-platform"). - **Invitation:** `acceptedAt`/`rejectedAt` lifecycle timestamps. - **Member roles:** free text `role` ("owner", "admin", "member") — not pgEnum, to stay compatible with Better Auth's role customization. ## Indexes and Constraints - Every foreign key column gets an index: `{table}_{column}_idx`. - Composite uniques: `member(userId, organizationId)`, `invitation(organizationId, email)`, `identity(providerId, accountId)`. - `session.activeOrganizationId` has an index but no FK constraint (Better Auth design). - All foreign keys use `onDelete: "cascade"`. ## Seeds - Use `onConflictDoNothing()` for idempotent seeds (safe to rerun). ## Environment - `ENVIRONMENT` env var overrides `NODE_ENV` for env file selection. DB scripts use short names (`prod`, `staging`, `dev`); API env schema uses full names (`production`, `staging`, `development`). - DB scripts have `:staging` / `:prod` variants (e.g., `bun db:push:prod`). - Config loads `.env.{envName}.local` → `.env.local` → `.env` in priority order. ================================================ FILE: db/README.md ================================================ # Database Layer Database layer using [Drizzle ORM](https://orm.drizzle.team/) and PostgreSQL ([Neon](https://neon.tech/)) via Cloudflare Hyperdrive. [Documentation](https://reactstarter.com/database/) | [Schema](https://reactstarter.com/database/schema) | [Migrations](https://reactstarter.com/database/migrations) ## Structure ```bash db/ ├── schema/ # Table definitions and relations ├── migrations/ # Auto-generated migration files ├── seeds/ # Seed scripts (e.g., users) ├── scripts/ # DB utilities (seed/export) ├── drizzle.config.ts # Drizzle configuration └── package.json # DB-only scripts and deps ``` ## Environment - `DATABASE_URL` is required and loaded from repo root: `.env..local` → `.env.local` → `.env`. - Environment selection: `ENVIRONMENT` takes priority, otherwise `NODE_ENV=production|staging|test` falls back to `prod|staging|test`; default is `dev`. Example `.env.dev.local` (at repo root): ```txt DATABASE_URL=postgresql://user:password@host:5432/database ``` ## Commands From the repo root: ```bash bun db:push # Apply schema (drizzle-kit push) bun db:generate # Generate migration from schema changes bun db:migrate # Run pending migrations bun db:studio # Open Drizzle Studio bun db:seed # Run seed scripts bun db:check # Drift check ``` Append `:staging` or `:prod` to target other environments: ```bash bun db:push:staging # Uses .env.staging.local → .env.local → .env bun db:push:prod # Uses .env.prod.local → .env.local → .env bun db:seed:prod bun db:studio:prod ``` ## Typical Workflow 1. Update schema in `db/schema`. 2. Generate a migration: `bun db:generate --name `. 3. Apply locally: `bun db:migrate` (or `db:push` for schema sync). 4. Validate in Drizzle Studio: `bun db:studio`. 5. Apply to staging/prod with the matching `:staging` or `:prod` suffix. ## Importing Schemas ```typescript import { schema } from "@repo/db"; import { user } from "@repo/db/schema/user"; import { organization, member } from "@repo/db/schema/organization"; ``` ## ID Generation All primary keys use application-generated prefixed CUID2 IDs (e.g. `usr_ght4k2jxm7pqbv01`). IDs are generated at the application level via `$defaultFn()` -- no database-level defaults. See `db/schema/id.ts` for the prefix map. ================================================ FILE: db/backups/.gitignore ================================================ # Ignore all backup files *.sql # Keep the directory in git !.gitignore ================================================ FILE: db/drizzle.config.ts ================================================ import { configDotenv } from "dotenv"; import { defineConfig } from "drizzle-kit"; import { resolve } from "node:path"; // Environment detection: ENVIRONMENT var takes priority, then NODE_ENV mapping const envName = (() => { if (process.env.ENVIRONMENT) return process.env.ENVIRONMENT; if (process.env.NODE_ENV === "production") return "prod"; if (process.env.NODE_ENV === "staging") return "staging"; if (process.env.NODE_ENV === "test") return "test"; return "dev"; })(); // Load .env files in priority order: environment-specific → local → base for (const file of [`.env.${envName}.local`, ".env.local", ".env"]) { configDotenv({ path: resolve(__dirname, "..", file), quiet: true }); } if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL environment variable is required"); } // Validate DATABASE_URL format (accepts both postgres:// and postgresql://) if (!/^postgre(s|sql):\/\/.+/.test(process.env.DATABASE_URL)) { throw new Error("DATABASE_URL must be a valid PostgreSQL connection string"); } /** * Drizzle ORM configuration for Neon PostgreSQL database * * @see https://orm.drizzle.team/docs/drizzle-config-file * @see https://orm.drizzle.team/llms.txt */ export default defineConfig({ out: "./migrations", schema: "./schema", dialect: "postgresql", casing: "snake_case", dbCredentials: { url: process.env.DATABASE_URL, }, }); ================================================ FILE: db/index.ts ================================================ /** * @file Database schema exports. * * Re-exports Drizzle ORM schemas for users, organizations, and authentication. */ import * as schema from "./schema"; export * from "./schema"; export { schema }; export type DatabaseSchema = typeof schema; ================================================ FILE: db/migrations/0000_init.sql ================================================ CREATE TABLE "invitation" ( "id" text PRIMARY KEY NOT NULL, "email" text NOT NULL, "inviter_id" text NOT NULL, "organization_id" text NOT NULL, "role" text NOT NULL, "status" text DEFAULT 'pending' NOT NULL, "expires_at" timestamp with time zone NOT NULL, "accepted_at" timestamp with time zone, "rejected_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 "invitation_org_email_unique" UNIQUE("organization_id","email") ); --> statement-breakpoint CREATE TABLE "passkey" ( "id" text PRIMARY KEY NOT NULL, "name" text, "public_key" text NOT NULL, "user_id" text NOT NULL, "credential_id" text NOT NULL, "counter" integer DEFAULT 0 NOT NULL, "device_type" text NOT NULL, "backed_up" boolean NOT NULL, "transports" text, "aaguid" text, "last_used_at" timestamp with time zone, "device_name" text, "platform" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "passkey_credentialID_unique" UNIQUE("credential_id") ); --> statement-breakpoint CREATE TABLE "member" ( "id" text PRIMARY KEY NOT NULL, "user_id" text NOT NULL, "organization_id" text NOT NULL, "role" text NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "member_user_org_unique" UNIQUE("user_id","organization_id") ); --> statement-breakpoint CREATE TABLE "organization" ( "id" text PRIMARY KEY NOT NULL, "name" text NOT NULL, "slug" text NOT NULL, "logo" text, "metadata" text, "stripe_customer_id" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "organization_slug_unique" UNIQUE("slug") ); --> statement-breakpoint CREATE TABLE "identity" ( "id" text PRIMARY KEY NOT NULL, "account_id" text NOT NULL, "provider_id" text NOT NULL, "user_id" text NOT NULL, "access_token" text, "refresh_token" text, "id_token" text, "access_token_expires_at" timestamp with time zone, "refresh_token_expires_at" timestamp with time zone, "scope" text, "password" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "identity_provider_account_unique" UNIQUE("provider_id","account_id") ); --> statement-breakpoint CREATE TABLE "session" ( "id" text PRIMARY KEY NOT NULL, "expires_at" timestamp with time zone NOT NULL, "token" text NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, "ip_address" text, "user_agent" text, "user_id" text NOT NULL, "active_organization_id" text, CONSTRAINT "session_token_unique" UNIQUE("token") ); --> statement-breakpoint CREATE TABLE "subscription" ( "id" text PRIMARY KEY NOT NULL, "plan" text NOT NULL, "reference_id" text NOT NULL, "stripe_customer_id" text, "stripe_subscription_id" text, "status" text DEFAULT 'incomplete' NOT NULL, "period_start" timestamp with time zone, "period_end" timestamp with time zone, "trial_start" timestamp with time zone, "trial_end" timestamp with time zone, "cancel_at_period_end" boolean DEFAULT false, "cancel_at" timestamp with time zone, "canceled_at" timestamp with time zone, "ended_at" timestamp with time zone, "seats" integer, "billing_interval" text, "group_id" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "subscription_stripeSubscriptionId_unique" UNIQUE("stripe_subscription_id") ); --> statement-breakpoint CREATE TABLE "user" ( "id" text PRIMARY KEY NOT NULL, "name" text NOT NULL, "email" text NOT NULL, "email_verified" boolean DEFAULT false NOT NULL, "image" text, "is_anonymous" boolean DEFAULT false NOT NULL, "stripe_customer_id" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, CONSTRAINT "user_email_unique" UNIQUE("email") ); --> statement-breakpoint CREATE TABLE "verification" ( "id" text PRIMARY KEY NOT NULL, "identifier" text NOT NULL, "value" text NOT NULL, "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, CONSTRAINT "verification_identifier_value_unique" UNIQUE("identifier","value") ); --> statement-breakpoint ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "invitation" ADD CONSTRAINT "invitation_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "member" ADD CONSTRAINT "member_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "identity" ADD CONSTRAINT "identity_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email");--> statement-breakpoint CREATE INDEX "invitation_inviter_id_idx" ON "invitation" USING btree ("inviter_id");--> statement-breakpoint CREATE INDEX "invitation_organization_id_idx" ON "invitation" USING btree ("organization_id");--> statement-breakpoint CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "member_user_id_idx" ON "member" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "member_organization_id_idx" ON "member" USING btree ("organization_id");--> statement-breakpoint CREATE INDEX "identity_user_id_idx" ON "identity" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "session_active_org_id_idx" ON "session" USING btree ("active_organization_id");--> statement-breakpoint CREATE INDEX "subscription_reference_id_idx" ON "subscription" USING btree ("reference_id");--> statement-breakpoint CREATE INDEX "subscription_stripe_customer_id_idx" ON "subscription" USING btree ("stripe_customer_id");--> statement-breakpoint CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint CREATE INDEX "verification_value_idx" ON "verification" USING btree ("value");--> statement-breakpoint CREATE INDEX "verification_expires_at_idx" ON "verification" USING btree ("expires_at"); ================================================ FILE: db/migrations/meta/0000_snapshot.json ================================================ { "id": "2f162304-a16e-4ba9-bf5b-dac3c1e4f6c0", "prevId": "00000000-0000-0000-0000-000000000000", "version": "1", "dialect": "postgresql", "tables": { "public.invitation": { "name": "invitation", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, "inviter_id": { "name": "inviter_id", "type": "text", "primaryKey": false, "notNull": true }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, "default": "'pending'" }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, "accepted_at": { "name": "accepted_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "rejected_at": { "name": "rejected_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "invitation_email_idx": { "name": "invitation_email_idx", "columns": [ { "expression": "email", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "invitation_inviter_id_idx": { "name": "invitation_inviter_id_idx", "columns": [ { "expression": "inviter_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "invitation_organization_id_idx": { "name": "invitation_organization_id_idx", "columns": [ { "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "invitation_inviter_id_user_id_fk": { "name": "invitation_inviter_id_user_id_fk", "tableFrom": "invitation", "tableTo": "user", "columnsFrom": ["inviter_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, "invitation_organization_id_organization_id_fk": { "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", "columnsFrom": ["organization_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "invitation_org_email_unique": { "name": "invitation_org_email_unique", "nullsNotDistinct": false, "columns": ["organization_id", "email"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.passkey": { "name": "passkey", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": false }, "public_key": { "name": "public_key", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, "credential_id": { "name": "credential_id", "type": "text", "primaryKey": false, "notNull": true }, "counter": { "name": "counter", "type": "integer", "primaryKey": false, "notNull": true, "default": 0 }, "device_type": { "name": "device_type", "type": "text", "primaryKey": false, "notNull": true }, "backed_up": { "name": "backed_up", "type": "boolean", "primaryKey": false, "notNull": true }, "transports": { "name": "transports", "type": "text", "primaryKey": false, "notNull": false }, "aaguid": { "name": "aaguid", "type": "text", "primaryKey": false, "notNull": false }, "last_used_at": { "name": "last_used_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "device_name": { "name": "device_name", "type": "text", "primaryKey": false, "notNull": false }, "platform": { "name": "platform", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "passkey_user_id_idx": { "name": "passkey_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "passkey_user_id_user_id_fk": { "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "passkey_credentialID_unique": { "name": "passkey_credentialID_unique", "nullsNotDistinct": false, "columns": ["credential_id"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.member": { "name": "member", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, "organization_id": { "name": "organization_id", "type": "text", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "member_user_id_idx": { "name": "member_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "member_organization_id_idx": { "name": "member_organization_id_idx", "columns": [ { "expression": "organization_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "member_user_id_user_id_fk": { "name": "member_user_id_user_id_fk", "tableFrom": "member", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, "member_organization_id_organization_id_fk": { "name": "member_organization_id_organization_id_fk", "tableFrom": "member", "tableTo": "organization", "columnsFrom": ["organization_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "member_user_org_unique": { "name": "member_user_org_unique", "nullsNotDistinct": false, "columns": ["user_id", "organization_id"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.organization": { "name": "organization", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, "slug": { "name": "slug", "type": "text", "primaryKey": false, "notNull": true }, "logo": { "name": "logo", "type": "text", "primaryKey": false, "notNull": false }, "metadata": { "name": "metadata", "type": "text", "primaryKey": false, "notNull": false }, "stripe_customer_id": { "name": "stripe_customer_id", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "organization_slug_unique": { "name": "organization_slug_unique", "nullsNotDistinct": false, "columns": ["slug"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.identity": { "name": "identity", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "account_id": { "name": "account_id", "type": "text", "primaryKey": false, "notNull": true }, "provider_id": { "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false }, "refresh_token": { "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false }, "id_token": { "name": "id_token", "type": "text", "primaryKey": false, "notNull": false }, "access_token_expires_at": { "name": "access_token_expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "refresh_token_expires_at": { "name": "refresh_token_expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "scope": { "name": "scope", "type": "text", "primaryKey": false, "notNull": false }, "password": { "name": "password", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "identity_user_id_idx": { "name": "identity_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "identity_user_id_user_id_fk": { "name": "identity_user_id_user_id_fk", "tableFrom": "identity", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "identity_provider_account_unique": { "name": "identity_provider_account_unique", "nullsNotDistinct": false, "columns": ["provider_id", "account_id"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.session": { "name": "session", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, "token": { "name": "token", "type": "text", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "ip_address": { "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false }, "user_agent": { "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true }, "active_organization_id": { "name": "active_organization_id", "type": "text", "primaryKey": false, "notNull": false } }, "indexes": { "session_user_id_idx": { "name": "session_user_id_idx", "columns": [ { "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "session_active_org_id_idx": { "name": "session_active_org_id_idx", "columns": [ { "expression": "active_organization_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": ["user_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": { "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, "columns": ["token"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.subscription": { "name": "subscription", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "plan": { "name": "plan", "type": "text", "primaryKey": false, "notNull": true }, "reference_id": { "name": "reference_id", "type": "text", "primaryKey": false, "notNull": true }, "stripe_customer_id": { "name": "stripe_customer_id", "type": "text", "primaryKey": false, "notNull": false }, "stripe_subscription_id": { "name": "stripe_subscription_id", "type": "text", "primaryKey": false, "notNull": false }, "status": { "name": "status", "type": "text", "primaryKey": false, "notNull": true, "default": "'incomplete'" }, "period_start": { "name": "period_start", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "period_end": { "name": "period_end", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "trial_start": { "name": "trial_start", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "trial_end": { "name": "trial_end", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "cancel_at_period_end": { "name": "cancel_at_period_end", "type": "boolean", "primaryKey": false, "notNull": false, "default": false }, "cancel_at": { "name": "cancel_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "canceled_at": { "name": "canceled_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "ended_at": { "name": "ended_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false }, "seats": { "name": "seats", "type": "integer", "primaryKey": false, "notNull": false }, "billing_interval": { "name": "billing_interval", "type": "text", "primaryKey": false, "notNull": false }, "group_id": { "name": "group_id", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "subscription_reference_id_idx": { "name": "subscription_reference_id_idx", "columns": [ { "expression": "reference_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "subscription_stripe_customer_id_idx": { "name": "subscription_stripe_customer_id_idx", "columns": [ { "expression": "stripe_customer_id", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "subscription_stripeSubscriptionId_unique": { "name": "subscription_stripeSubscriptionId_unique", "nullsNotDistinct": false, "columns": ["stripe_subscription_id"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.user": { "name": "user", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true }, "email_verified": { "name": "email_verified", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, "image": { "name": "image", "type": "text", "primaryKey": false, "notNull": false }, "is_anonymous": { "name": "is_anonymous", "type": "boolean", "primaryKey": false, "notNull": true, "default": false }, "stripe_customer_id": { "name": "stripe_customer_id", "type": "text", "primaryKey": false, "notNull": false }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, "columns": ["email"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, "public.verification": { "name": "verification", "schema": "", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true }, "identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, "value": { "name": "value", "type": "text", "primaryKey": false, "notNull": true }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": { "verification_identifier_idx": { "name": "verification_identifier_idx", "columns": [ { "expression": "identifier", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "verification_value_idx": { "name": "verification_value_idx", "columns": [ { "expression": "value", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} }, "verification_expires_at_idx": { "name": "verification_expires_at_idx", "columns": [ { "expression": "expires_at", "isExpression": false, "asc": true, "nulls": "last" } ], "isUnique": false, "concurrently": false, "method": "btree", "with": {} } }, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "verification_identifier_value_unique": { "name": "verification_identifier_value_unique", "nullsNotDistinct": false, "columns": ["identifier", "value"] } }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false } }, "enums": {}, "schemas": {}, "sequences": {}, "roles": {}, "policies": {}, "views": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: db/migrations/meta/_journal.json ================================================ { "version": "7", "dialect": "postgresql", "entries": [ { "idx": 0, "version": "1", "when": 1751197781613, "tag": "0000_init", "breakpoints": true } ] } ================================================ FILE: db/package.json ================================================ { "name": "@repo/db", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts", "./schema": "./schema/index.ts", "./schema/*": "./schema/*" }, "scripts": { "generate": "bun --bun drizzle-kit generate", "generate:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit generate", "generate:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit generate", "migrate": "bun --bun drizzle-kit migrate", "migrate:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit migrate", "migrate:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit migrate", "push": "bun --bun drizzle-kit push", "push:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit push", "push:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit push", "studio": "bun --bun drizzle-kit studio", "studio:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit studio", "studio:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit studio", "seed": "bun scripts/seed.ts", "seed:staging": "bun --env ENVIRONMENT=staging scripts/seed.ts", "seed:prod": "bun --env ENVIRONMENT=prod scripts/seed.ts", "export": "bun scripts/export.ts", "export:staging": "bun --env ENVIRONMENT=staging scripts/export.ts", "export:prod": "bun --env ENVIRONMENT=prod scripts/export.ts", "introspect": "bun --bun drizzle-kit introspect", "up": "bun --bun drizzle-kit up", "check": "bun --bun drizzle-kit check", "drop": "bun --bun drizzle-kit drop", "typecheck": "tsc --noEmit" }, "peerDependencies": { "drizzle-orm": "^0.45.1" }, "devDependencies": { "@repo/typescript-config": "workspace:*", "@types/bun": "^1.3.9", "@types/node": "^25.2.3", "dotenv": "^17.3.1", "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "typescript": "~5.9.3" }, "dependencies": { "@paralleldrive/cuid2": "^3.3.0" } } ================================================ FILE: db/schema/id.ts ================================================ // Prefixed CUID2 ID generation for all database entities. // Format: {prefix}_{body} e.g. "usr_ght4k2jxm7pqbv01" (20 chars total) // See docs/specs/prefixed-ids.md for design rationale. import { init } from "@paralleldrive/cuid2"; // Keys are Better Auth's internal model names (not table names). // "account" maps to the "identity" table via account.modelName config. const AUTH_PREFIX = { user: "usr", session: "ses", account: "idn", // "identity" table — avoids confusion with user/billing account verification: "vfy", organization: "org", member: "mem", invitation: "inv", passkey: "pky", subscription: "sub", } as const; export type AuthModel = keyof typeof AUTH_PREFIX; const ID_LENGTH = 16; let _createId: (() => string) | null = null; function createId(): string { if (!_createId) _createId = init({ length: ID_LENGTH }); return _createId(); } /** Generate a prefixed ID for a Better Auth model (e.g. `"user"` → `"usr_..."`) */ export function generateAuthId(model: AuthModel): string { const prefix = AUTH_PREFIX[model]; if (!prefix) { throw new Error( `Unknown auth model "${String(model)}". Add it to AUTH_PREFIX in db/schema/id.ts`, ); } return `${prefix}_${createId()}`; } /** Generate a prefixed ID for non-auth tables (e.g. `generateId("upl")`) */ export function generateId(prefix: string): string { if (!/^[a-z]{3}$/.test(prefix)) { throw new Error( `ID prefix must be exactly 3 lowercase letters, got "${prefix}"`, ); } return `${prefix}_${createId()}`; } ================================================ FILE: db/schema/index.ts ================================================ export * from "./id"; export * from "./invitation"; export * from "./organization"; export * from "./passkey"; export * from "./subscription"; export * from "./user"; ================================================ FILE: db/schema/invitation.ts ================================================ // Better Auth invitation system for organization invites import { relations } from "drizzle-orm"; import { index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"; import { generateAuthId } from "./id"; import { organization } from "./organization"; import { user } from "./user"; /** * Invitations table for Better Auth organization plugin. * Manages pending invites to organizations. * * Lifecycle timestamps: * - acceptedAt: When the invite was accepted * - rejectedAt: When the invite was rejected or canceled */ export const invitation = pgTable( "invitation", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("invitation")), email: text().notNull(), inviterId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), role: text().notNull(), status: text().default("pending").notNull(), expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(), acceptedAt: timestamp({ withTimezone: true, mode: "date" }), rejectedAt: timestamp({ withTimezone: true, mode: "date" }), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ unique("invitation_org_email_unique").on(table.organizationId, table.email), index("invitation_email_idx").on(table.email), index("invitation_inviter_id_idx").on(table.inviterId), index("invitation_organization_id_idx").on(table.organizationId), ], ); export type Invitation = typeof invitation.$inferSelect; export type NewInvitation = typeof invitation.$inferInsert; // ————————————————————————————————————————————————————————————————————————————— // Relations for better query experience // ————————————————————————————————————————————————————————————————————————————— export const invitationRelations = relations(invitation, ({ one }) => ({ inviter: one(user, { fields: [invitation.inviterId], references: [user.id], }), organization: one(organization, { fields: [invitation.organizationId], references: [organization.id], }), })); ================================================ FILE: db/schema/organization.ts ================================================ // Multi-tenant organizations and memberships with role-based access control import { relations } from "drizzle-orm"; import { index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"; import { generateAuthId } from "./id"; import { user } from "./user"; /** * Organizations table for Better Auth organization plugin. * Each organization represents a separate tenant with isolated data. */ export const organization = pgTable("organization", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("organization")), name: text().notNull(), slug: text().notNull().unique(), logo: text(), metadata: text(), // Better Auth expects string (JSON serialized) stripeCustomerId: text(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }); export type Organization = typeof organization.$inferSelect; export type NewOrganization = typeof organization.$inferInsert; /** * Organization membership table for Better Auth organization plugin. * Links users to organizations with specific roles. * * Role values (Better Auth defaults): * - "owner": Full control, can delete organization * - "admin": Can manage members and settings * - "member": Standard access * * @see apps/api/lib/auth.ts creatorRole config */ export const member = pgTable( "member", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("member")), userId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), role: text().notNull(), // "owner" | "admin" | "member" createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ unique("member_user_org_unique").on(table.userId, table.organizationId), index("member_user_id_idx").on(table.userId), index("member_organization_id_idx").on(table.organizationId), ], ); export type Member = typeof member.$inferSelect; export type NewMember = typeof member.$inferInsert; // ————————————————————————————————————————————————————————————————————————————— // Relations for better query experience // ————————————————————————————————————————————————————————————————————————————— export const organizationRelations = relations(organization, ({ many }) => ({ members: many(member), })); export const memberRelations = relations(member, ({ one }) => ({ user: one(user, { fields: [member.userId], references: [user.id], }), organization: one(organization, { fields: [member.organizationId], references: [organization.id], }), })); ================================================ FILE: db/schema/passkey.ts ================================================ // WebAuthn passkey credentials for Better Auth // @see https://www.better-auth.com/docs/plugins/passkey import { boolean, index, integer, pgTable, text, timestamp, } from "drizzle-orm/pg-core"; import { generateAuthId } from "./id"; import { user } from "./user"; /** * Passkey credential store. * * Extended fields beyond Better Auth defaults: * - lastUsedAt: Tracks last authentication for security audits * - deviceName: User-friendly name (e.g., "MacBook Pro", "iPhone 15") * - platform: Authenticator platform ("platform" | "cross-platform") */ export const passkey = pgTable( "passkey", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("passkey")), name: text(), publicKey: text().notNull(), userId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), credentialID: text().notNull().unique(), counter: integer().default(0).notNull(), deviceType: text().notNull(), backedUp: boolean().notNull(), transports: text(), aaguid: text(), // Extended operational fields lastUsedAt: timestamp({ withTimezone: true, mode: "date" }), deviceName: text(), platform: text(), // "platform" | "cross-platform" createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [index("passkey_user_id_idx").on(table.userId)], ); export type Passkey = typeof passkey.$inferSelect; export type NewPasskey = typeof passkey.$inferInsert; ================================================ FILE: db/schema/subscription.ts ================================================ // Stripe subscription state managed by the @better-auth/stripe plugin. // referenceId is polymorphic: points to user.id or organization.id depending // on whether the subscription is personal or org-level billing. import { boolean, index, integer, pgTable, text, timestamp, } from "drizzle-orm/pg-core"; import { generateAuthId } from "./id"; export const subscription = pgTable( "subscription", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("subscription")), plan: text().notNull(), referenceId: text().notNull(), stripeCustomerId: text(), stripeSubscriptionId: text().unique(), status: text().default("incomplete").notNull(), periodStart: timestamp({ withTimezone: true, mode: "date" }), periodEnd: timestamp({ withTimezone: true, mode: "date" }), trialStart: timestamp({ withTimezone: true, mode: "date" }), trialEnd: timestamp({ withTimezone: true, mode: "date" }), cancelAtPeriodEnd: boolean().default(false), cancelAt: timestamp({ withTimezone: true, mode: "date" }), canceledAt: timestamp({ withTimezone: true, mode: "date" }), endedAt: timestamp({ withTimezone: true, mode: "date" }), seats: integer(), billingInterval: text(), groupId: text(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ index("subscription_reference_id_idx").on(table.referenceId), index("subscription_stripe_customer_id_idx").on(table.stripeCustomerId), ], ); export type Subscription = typeof subscription.$inferSelect; export type NewSubscription = typeof subscription.$inferInsert; ================================================ FILE: db/schema/user.ts ================================================ /** * Database schema for Better Auth authentication system. * * This schema is designed to be fully compatible with Better Auth's database * requirements as documented at https://www.better-auth.com/docs/concepts/database * * Tables defined: * - `user`: Core user accounts with profile information * - `session`: Active user sessions for authentication state * - `identity`: OAuth provider accounts (renamed from Better Auth's `account`) * - `verification`: Tokens for email verification and password resets * * @see https://www.better-auth.com/docs/concepts/database * @see https://www.better-auth.com/docs/adapters/drizzle */ import { relations } from "drizzle-orm"; import { boolean, index, pgTable, text, timestamp, unique, } from "drizzle-orm/pg-core"; import { generateAuthId } from "./id"; /** * User accounts table. * Matches to the `user` table in Better Auth. */ export const user = pgTable("user", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("user")), name: text().notNull(), email: text().notNull().unique(), emailVerified: boolean().default(false).notNull(), image: text(), isAnonymous: boolean().default(false).notNull(), stripeCustomerId: text(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }); export type User = typeof user.$inferSelect; export type NewUser = typeof user.$inferInsert; /** * Stores user session data for authentication. * Matches to the `session` table in Better Auth. */ export const session = pgTable( "session", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("session")), expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(), token: text().notNull().unique(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), ipAddress: text(), userAgent: text(), userId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), activeOrganizationId: text(), }, (table) => [ index("session_user_id_idx").on(table.userId), index("session_active_org_id_idx").on(table.activeOrganizationId), ], ); export type Session = typeof session.$inferSelect; export type NewSession = typeof session.$inferInsert; /** * Stores OAuth provider account information. * Matches to the `account` table in Better Auth. */ export const identity = pgTable( "identity", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("account")), accountId: text().notNull(), providerId: text().notNull(), userId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), accessToken: text(), refreshToken: text(), idToken: text(), accessTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }), refreshTokenExpiresAt: timestamp({ withTimezone: true, mode: "date" }), scope: text(), password: text(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ unique("identity_provider_account_unique").on( table.providerId, table.accountId, ), index("identity_user_id_idx").on(table.userId), ], ); export type Identity = typeof identity.$inferSelect; export type NewIdentity = typeof identity.$inferInsert; /** * Stores verification tokens (email verification, password reset, etc.) * Matches to the `verification` table in Better Auth. */ export const verification = pgTable( "verification", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("verification")), identifier: text().notNull(), value: text().notNull(), expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ unique("verification_identifier_value_unique").on( table.identifier, table.value, ), index("verification_identifier_idx").on(table.identifier), index("verification_value_idx").on(table.value), index("verification_expires_at_idx").on(table.expiresAt), ], ); export type Verification = typeof verification.$inferSelect; export type NewVerification = typeof verification.$inferInsert; // ————————————————————————————————————————————————————————————————————————————— // Relations for better query experience // ————————————————————————————————————————————————————————————————————————————— export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), identities: many(identity), })); export const sessionRelations = relations(session, ({ one }) => ({ user: one(user, { fields: [session.userId], references: [user.id], }), })); export const identityRelations = relations(identity, ({ one }) => ({ user: one(user, { fields: [identity.userId], references: [user.id], }), })); ================================================ FILE: db/scripts/export.ts ================================================ #!/usr/bin/env bun /** * PostgreSQL database export utility with schema/data options * * Usage: * bun scripts/export.ts # Schema only (default) * bun scripts/export.ts --data # Schema + data * bun scripts/export.ts --data-only # Data only * bun scripts/export.ts --table=users # Specific table * bun scripts/export.ts -- --inserts # Pass pg_dump flags directly * * Environment: * bun --env ENVIRONMENT=staging scripts/export.ts * bun --env ENVIRONMENT=prod scripts/export.ts * * REQUIREMENTS: * - DATABASE_URL environment variable must be set and valid PostgreSQL connection string * - pg_dump binary must be available in PATH (PostgreSQL client tools required, validated at runtime) * - ./backups/ directory will be created automatically if it doesn't exist * - Output filenames include timestamp, environment, and export type for uniqueness * - Process exits with code 1 on any failure for CI/CD integration * - File permissions on output SQL files are restricted (readable by owner only) * - Script handles concurrent executions without filename conflicts */ import { $ } from "bun"; import { existsSync } from "fs"; import { chmod, mkdir } from "fs/promises"; import { resolve } from "path"; // Import drizzle config to trigger environment loading and validation import "../drizzle.config"; // Parse arguments const args = process.argv.slice(2); const passThrough: string[] = []; let includeData = false; let dataOnly = false; let table: string | undefined; // Find pass-through arguments (after --) const dashIndex = args.indexOf("--"); if (dashIndex !== -1) { passThrough.push(...args.slice(dashIndex + 1)); args.splice(dashIndex); } // Parse named arguments for (const arg of args) { if (arg === "--data") { includeData = true; } else if (arg === "--data-only") { dataOnly = true; } else if (arg.startsWith("--table=")) { table = arg.split("=")[1]; } } // Build pg_dump command const pgDumpArgs: string[] = []; // pg_dump requires the connection string as the last positional argument // or through -d/--dbname flag pgDumpArgs.push("--dbname", process.env.DATABASE_URL!); // Default options pgDumpArgs.push("--format=plain", "--encoding=UTF-8"); // Handle export type if (dataOnly) { pgDumpArgs.push("--data-only"); } else if (!includeData) { pgDumpArgs.push("--schema-only"); } // Handle table selection if (table) { pgDumpArgs.push(`--table=${table}`); } // Add pass-through arguments pgDumpArgs.push(...passThrough); // Generate filename based on options with high precision timestamp const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); const envSuffix = process.env.ENVIRONMENT ? `-${process.env.ENVIRONMENT}` : ""; const typeSuffix = dataOnly ? "-data" : includeData ? "-full" : "-schema"; const tableSuffix = table ? `-${table}` : ""; // Ensure backups directory exists const backupsDir = resolve("./backups"); if (!existsSync(backupsDir)) { await mkdir(backupsDir, { recursive: true }); console.log(`📁 Created backups directory: ${backupsDir}`); } const outputPath = resolve( backupsDir, `dump${envSuffix}${typeSuffix}${tableSuffix}-${timestamp}.sql`, ); pgDumpArgs.push(`--file=${outputPath}`); // Check if pg_dump is available try { await $`which pg_dump`.quiet(); } catch { console.error( "❌ pg_dump not found. Please install PostgreSQL client tools.", ); process.exit(1); } console.log("📤 Exporting database..."); console.log(`📁 Output: ${outputPath}`); try { // Execute pg_dump await $`pg_dump ${pgDumpArgs}`; // Set file permissions to owner-only readable (600) await chmod(outputPath, 0o600); console.log(`✅ Export completed successfully!`); } catch (error) { console.error("❌ Export failed:"); console.error(error); process.exit(1); } ================================================ FILE: db/scripts/generate-auth-schema.ts ================================================ import { getAuthTables } from "better-auth/db"; import type { BetterAuthOptions } from "better-auth/types"; import { createAuth } from "../../apps/api/lib/auth"; import { env } from "../../apps/api/lib/env"; /** * Generates the complete database structure from Better Auth configuration * Outputs the schema as formatted JSON showing all tables, fields, and relationships */ async function generateAuthSchema() { // Mock database instance - Better Auth only needs this for type checking, not actual queries const mockDb = {} as Record; // Create the auth instance to get the configuration const auth = createAuth(mockDb, { APP_NAME: env.APP_NAME || "React Starter Kit", APP_ORIGIN: env.APP_ORIGIN || "http://localhost:3000", BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET || "mock-secret", GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID || "mock-client-id", GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET || "mock-client-secret", }); // WARNING: Type assertion needed as Better Auth doesn't export the auth instance type const authOptions = (auth as { options: BetterAuthOptions }).options; // Get the complete database schema const tables = getAuthTables(authOptions); // Format the output for better readability const schemaOutput = { metadata: { description: "Better Auth database schema", generatedAt: new Date().toISOString(), tableCount: Object.keys(tables).length, }, tables: {}, }; // Process each table for (const [tableKey, table] of Object.entries(tables)) { const processedFields: Record> = {}; // Process each field in the table for (const [fieldKey, field] of Object.entries(table.fields)) { processedFields[fieldKey] = { type: field.type, required: field.required || false, unique: field.unique || false, }; // Add references if they exist if (field.references) { processedFields[fieldKey].references = { model: field.references.model, field: field.references.field, }; } } (schemaOutput.tables as Record)[tableKey] = { modelName: table.modelName, fields: processedFields, }; } return schemaOutput; } // Main execution async function main() { try { const schema = await generateAuthSchema(); console.log(JSON.stringify(schema, null, 2)); } catch (error) { console.error("Error generating auth schema:", error); process.exit(1); } } if (require.main === module) { main(); } export { generateAuthSchema }; ================================================ FILE: db/scripts/seed.ts ================================================ #!/usr/bin/env bun // Usage: bun scripts/seed.ts [--env ENVIRONMENT=staging|prod] import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as schema from "../schema"; import { seedUsers } from "../seeds/users"; // Import drizzle config to trigger environment loading import "../drizzle.config"; const client = postgres(process.env.DATABASE_URL!, { max: 1 }); const db = drizzle(client, { schema, casing: "snake_case" }); console.log("🌱 Starting database seeding..."); try { await seedUsers(db); console.log("✅ Database seeding completed successfully!"); } catch (error) { console.error("❌ Database seeding failed:"); console.error(error); process.exitCode = 1; } finally { await client.end(); } ================================================ FILE: db/seeds/users.ts ================================================ import { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import * as schema from "../schema"; import { type NewUser, user } from "../schema"; /** * Seeds the database with test user accounts. */ export async function seedUsers(db: PostgresJsDatabase) { console.log("Seeding users..."); // Test user data with realistic names and email addresses const users: NewUser[] = [ { name: "Alice Johnson", email: "alice@example.com", emailVerified: true }, { name: "Bob Smith", email: "bob@example.com", emailVerified: true }, { name: "Charlie Brown", email: "charlie@example.com", emailVerified: false, }, { name: "Diana Prince", email: "diana@example.com", emailVerified: true }, { name: "Eve Davis", email: "eve@example.com", emailVerified: true }, { name: "Frank Miller", email: "frank@example.com", emailVerified: false }, { name: "Grace Lee", email: "grace@example.com", emailVerified: true }, { name: "Henry Wilson", email: "henry@example.com", emailVerified: true }, { name: "Ivy Chen", email: "ivy@example.com", emailVerified: false }, { name: "Jack Thompson", email: "jack@example.com", emailVerified: true }, ]; for (const u of users) { await db.insert(user).values(u).onConflictDoNothing(); } console.log(`✅ Seeded ${users.length} test users`); } ================================================ FILE: db/tsconfig.json ================================================ { "extends": "../packages/typescript-config/node.jsonc", "compilerOptions": { "tsBuildInfoFile": "dist/.tsbuildinfo", "composite": true, "declaration": true, "emitDeclarationOnly": true, "outDir": "./dist", "types": ["bun", "node"] }, "include": ["schema/**/*.ts", "*.ts", "*.json"], "exclude": ["**/dist/**/*", "**/node_modules/**/*", "scripts/**/*"] } ================================================ FILE: docs/.vitepress/config.ts ================================================ import { defineConfig } from "vitepress"; import llmstxt from "vitepress-plugin-llms"; /** * VitePress configuration. * @see https://vitepress.dev/reference/site-config */ export default defineConfig({ title: "React Starter Kit", description: "Production-ready monorepo for building fast web apps", markdown: { config(md) { const fence = md.renderer.rules.fence!; md.renderer.rules.fence = (tokens, idx, options, env, self) => { const token = tokens[idx]; if (token.info === "mermaid") { const code = md.utils.escapeHtml(token.content.trim()); return ``; } return fence(tokens, idx, options, env, self); }; }, }, lastUpdated: true, cleanUrls: true, metaChunk: true, ignoreDeadLinks: [/%5B.*_URL%5D/], sitemap: { hostname: "https://reactstarter.com", transformItems: (items) => { items.push({ url: "llms.txt" }, { url: "llms-full.txt" }); return items; }, }, head: [ ["meta", { name: "theme-color", content: "#6366f1" }], ["meta", { property: "og:type", content: "website" }], ["meta", { property: "og:site_name", content: "React Starter Kit" }], [ "link", { rel: "alternate", type: "text/plain", href: "/llms.txt", title: "LLM context", }, ], [ "link", { rel: "alternate", type: "text/plain", href: "/llms-full.txt", title: "LLM context (full)", }, ], ], themeConfig: { // https://vitepress.dev/reference/default-theme-config nav: [ { text: "Home", link: "/" }, { text: "Docs", link: "/getting-started/" }, ], search: { provider: "local", }, editLink: { pattern: "https://github.com/kriasoft/react-starter-kit/edit/main/docs/:path", text: "Edit this page on GitHub", }, footer: { message: 'LLM context: llms.txt · llms-full.txt
Released under the MIT License.', copyright: "Copyright © 2014-present Kriasoft", }, sidebar: [ { text: "Getting Started", items: [ { text: "Introduction", link: "/getting-started/" }, { text: "Quick Start", link: "/getting-started/quick-start" }, { text: "Project Structure", link: "/getting-started/project-structure", }, { text: "Environment Variables", link: "/getting-started/environment-variables", }, ], }, { text: "Architecture", items: [ { text: "Overview", link: "/architecture/" }, { text: "Edge", link: "/architecture/edge" }, ], }, { text: "Frontend", collapsed: true, items: [ { text: "Routing", link: "/frontend/routing" }, { text: "State & Data Fetching", link: "/frontend/state" }, { text: "UI", link: "/frontend/ui" }, { text: "Forms & Validation", link: "/frontend/forms" }, ], }, { text: "API", collapsed: true, items: [ { text: "Overview", link: "/api/" }, { text: "Procedures", link: "/api/procedures" }, { text: "Validation & Errors", link: "/api/validation-errors" }, { text: "Context & Middleware", link: "/api/context" }, ], }, { text: "Authentication", collapsed: true, items: [ { text: "Overview", link: "/auth/" }, { text: "Email & OTP", link: "/auth/email-otp" }, { text: "Social Providers", link: "/auth/social-providers" }, { text: "Passkeys", link: "/auth/passkeys" }, { text: "Organizations & Roles", link: "/auth/organizations" }, { text: "Sessions & Protected Routes", link: "/auth/sessions" }, ], }, { text: "Database", collapsed: true, items: [ { text: "Overview", link: "/database/" }, { text: "Schema", link: "/database/schema" }, { text: "Migrations", link: "/database/migrations" }, { text: "Seeding", link: "/database/seeding" }, { text: "Query Patterns", link: "/database/queries" }, ], }, { text: "Billing", collapsed: true, items: [ { text: "Overview", link: "/billing/" }, { text: "Plans & Pricing", link: "/billing/plans" }, { text: "Checkout Flow", link: "/billing/checkout" }, { text: "Webhooks", link: "/billing/webhooks" }, ], }, { text: "Email", link: "/email" }, { text: "Testing", link: "/testing" }, { text: "Deployment", collapsed: true, items: [ { text: "Overview", link: "/deployment/" }, { text: "Cloudflare Workers", link: "/deployment/cloudflare" }, { text: "Production Database", link: "/deployment/production-database", }, { text: "CI/CD", link: "/deployment/ci-cd" }, { text: "Monitoring", link: "/deployment/monitoring" }, ], }, { text: "Recipes", collapsed: true, items: [ { text: "Add a Page", link: "/recipes/new-page" }, { text: "Add a tRPC Procedure", link: "/recipes/new-procedure" }, { text: "Add a Database Table", link: "/recipes/new-table" }, { text: "Add Teams", link: "/recipes/teams" }, { text: "WebSockets", link: "/recipes/websockets" }, { text: "File Uploads", link: "/recipes/file-uploads" }, ], }, { text: "Security", collapsed: true, items: [ { text: "Security Checklist", link: "/security/checklist" }, { text: "Incident Playbook", link: "/security/incident-playbook" }, { text: "Security Policy Template", link: "/security/policy-template", }, ], }, ], socialLinks: [ { icon: { svg: '', }, link: "https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant", ariaLabel: "Ask GPT", }, { icon: "discord", link: "https://discord.gg/2nKEnKq", }, { icon: "github", link: "https://github.com/kriasoft/react-starter-kit", }, ], }, vite: { plugins: [llmstxt()], }, }); ================================================ FILE: docs/.vitepress/public/robots.txt ================================================ User-agent: * Allow: / Sitemap: https://reactstarter.com/sitemap.xml Sitemap: https://reactstarter.com/llms.txt ================================================ FILE: docs/.vitepress/theme/components/GitHubStats.vue ================================================ ================================================ FILE: docs/.vitepress/theme/components/Mermaid.vue ================================================ ================================================ FILE: docs/.vitepress/theme/index.ts ================================================ /** * Custom theme for VitePress documentation. * @see https://vitepress.dev/guide/custom-theme */ import type { Theme } from "vitepress"; import DefaultTheme from "vitepress/theme"; import { h } from "vue"; import GitHubStats from "./components/GitHubStats.vue"; import Mermaid from "./components/Mermaid.vue"; import "./style.css"; export default { extends: DefaultTheme, Layout: () => { return h(DefaultTheme.Layout, null, { // https://vitepress.dev/guide/extending-default-theme#layout-slots "nav-bar-content-after": () => h(GitHubStats), }); }, enhanceApp({ app }) { app.component("Mermaid", Mermaid); }, } satisfies Theme; ================================================ FILE: docs/.vitepress/theme/style.css ================================================ /** * Customize default theme styling by overriding CSS variables: * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css */ /** * Colors * * Each colors have exact same color scale system with 3 levels of solid * colors with different brightness, and 1 soft color. * * - `XXX-1`: The most solid color used mainly for colored text. It must * satisfy the contrast ratio against when used on top of `XXX-soft`. * * - `XXX-2`: The color used mainly for hover state of the button. * * - `XXX-3`: The color for solid background, such as bg color of the button. * It must satisfy the contrast ratio with pure white (#ffffff) text on * top of it. * * - `XXX-soft`: The color used for subtle background such as custom container * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors * on top of it. * * The soft color must be semi transparent alpha channel. This is crucial * because it allows adding multiple "soft" colors on top of each other * to create a accent, such as when having inline code block inside * custom containers. * * - `default`: The color used purely for subtle indication without any * special meanings attached to it such as bg color for menu hover state. * * - `brand`: Used for primary brand colors, such as link text, button with * brand theme, etc. * * - `tip`: Used to indicate useful information. The default theme uses the * brand color for this by default. * * - `warning`: Used to indicate warning to the users. Used in custom * container, badges, etc. * * - `danger`: Used to show error, or dangerous message to the users. Used * in custom container, badges, etc. * -------------------------------------------------------------------------- */ :root { --vp-c-default-1: var(--vp-c-gray-1); --vp-c-default-2: var(--vp-c-gray-2); --vp-c-default-3: var(--vp-c-gray-3); --vp-c-default-soft: var(--vp-c-gray-soft); --vp-c-brand-1: var(--vp-c-indigo-1); --vp-c-brand-2: var(--vp-c-indigo-2); --vp-c-brand-3: var(--vp-c-indigo-3); --vp-c-brand-soft: var(--vp-c-indigo-soft); --vp-c-tip-1: var(--vp-c-brand-1); --vp-c-tip-2: var(--vp-c-brand-2); --vp-c-tip-3: var(--vp-c-brand-3); --vp-c-tip-soft: var(--vp-c-brand-soft); --vp-c-warning-1: var(--vp-c-yellow-1); --vp-c-warning-2: var(--vp-c-yellow-2); --vp-c-warning-3: var(--vp-c-yellow-3); --vp-c-warning-soft: var(--vp-c-yellow-soft); --vp-c-danger-1: var(--vp-c-red-1); --vp-c-danger-2: var(--vp-c-red-2); --vp-c-danger-3: var(--vp-c-red-3); --vp-c-danger-soft: var(--vp-c-red-soft); } /** * Component: Button * -------------------------------------------------------------------------- */ :root { --vp-button-brand-border: transparent; --vp-button-brand-text: var(--vp-c-white); --vp-button-brand-bg: var(--vp-c-brand-3); --vp-button-brand-hover-border: transparent; --vp-button-brand-hover-text: var(--vp-c-white); --vp-button-brand-hover-bg: var(--vp-c-brand-2); --vp-button-brand-active-border: transparent; --vp-button-brand-active-text: var(--vp-c-white); --vp-button-brand-active-bg: var(--vp-c-brand-1); } /** * Component: Home * -------------------------------------------------------------------------- */ :root { --vp-home-hero-name-color: transparent; --vp-home-hero-name-background: -webkit-linear-gradient( 120deg, #bd34fe 30%, #41d1ff ); --vp-home-hero-image-background-image: linear-gradient( -45deg, #bd34fe 50%, #47caff 50% ); --vp-home-hero-image-filter: blur(44px); } @media (min-width: 640px) { :root { --vp-home-hero-image-filter: blur(56px); } } @media (min-width: 960px) { :root { --vp-home-hero-image-filter: blur(68px); } } /** * Component: Custom Block * -------------------------------------------------------------------------- */ :root { --vp-custom-block-tip-border: transparent; --vp-custom-block-tip-text: var(--vp-c-text-1); --vp-custom-block-tip-bg: var(--vp-c-brand-soft); --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); } /** * Component: Algolia * -------------------------------------------------------------------------- */ .DocSearch { --docsearch-primary-color: var(--vp-c-brand-1) !important; } ================================================ FILE: docs/adr/000-template.md ================================================ # ADR-NNN Title **Status:** Proposed | Accepted | Deprecated | Superseded **Date:** YYYY-MM-DD **Tags:** tag1, tag2 ## Problem - One or two sentences on the decision trigger or constraint. ## Decision - The chosen approach in a short paragraph. ## Alternatives (brief) - Option A – why not. - Option B – why not. ## Impact - Positive: - Negative/Risks: ## Links - Code/Docs: - Related ADRs: ================================================ FILE: docs/adr/001-auth-hint-cookie.md ================================================ # ADR-001 Auth Hint Cookie For Edge Routing **Status:** Accepted **Date:** 2025-12-28 **Tags:** auth, routing, edge ## Problem The web edge needs a fast signal to route `/` without owning auth logic. ## Decision Use a dedicated auth-hint cookie set on login and cleared on logout or invalid session. The web worker checks only cookie presence to route, while the app remains the authority. No API calls or session validation in `web`. This cookie is NOT a security boundary. It is a routing hint only. False positives are acceptable and result in one extra redirect to `/login`. ## Implementation Notes - Cookie name: `__Host-auth` in HTTPS; `auth` in HTTP dev (browsers reject `__Host-` without Secure). - Cookie lifecycle: set on new session; clear on sign-out; clear on session-check failure. - Web routing: check for either cookie name; never read session cookies. ## Alternatives Considered 1. **Validate session in web via API** – Couples edge to auth, adds latency/failure modes. 2. **Read Better Auth session cookie directly** – Brittle to auth library changes and cookie formats. ## Consequences - **Positive:** Faster edge routing, clear separation of concerns, auth-lib agnostic. - **Negative:** False positives cause one extra redirect; requires maintaining set/clear hooks. ## Links - https://github.com/kriasoft/react-starter-kit/issues/2101 ================================================ FILE: docs/api/context.md ================================================ # Context & Middleware Every tRPC procedure receives a context object (`ctx`) with request-scoped resources. The middleware chain builds this context before any procedure runs. ## TRPCContext Defined in `apps/api/lib/context.ts`, the context provides: | Field | Type | Description | | ------------- | ---------------------------------- | ------------------------------------------------------- | | `req` | `Request` | The incoming HTTP request | | `info` | `CreateHTTPContextOptions["info"]` | tRPC request metadata (headers, connection info) | | `db` | `PostgresJsDatabase` | Drizzle ORM instance via Hyperdrive (cached connection) | | `dbDirect` | `PostgresJsDatabase` | Drizzle ORM instance via Hyperdrive (direct, no cache) | | `session` | `AuthSession \| null` | Authenticated session from Better Auth | | `user` | `AuthUser \| null` | Authenticated user data | | `cache` | `Map` | Request-scoped cache (for DataLoaders, computed values) | | `res?` | `Response` | Optional HTTP response from Hono context | | `resHeaders?` | `Headers` | Response headers (for setting cookies, etc.) | | `env` | `Env` | Environment variables and secrets | ### Two Database Connections The context provides two database connections with different caching behaviors: - **`ctx.db`** – routed through Cloudflare Hyperdrive's connection pool with query caching. Use for read-heavy queries. - **`ctx.dbDirect`** – bypasses the cache. Use for writes, transactions, and reads that must see the latest data. ```ts // Read with caching const users = await ctx.db.select().from(user); // Write via direct connection await ctx.dbDirect.insert(post).values({ title: "Hello" }); ``` ## How Context is Constructed Context is created per-request in the tRPC fetch adapter (`apps/api/lib/app.ts`): ```ts app.use("/api/trpc/*", (c) => { return fetchRequestHandler({ req: c.req.raw, router: appRouter, endpoint: "/api/trpc", async createContext({ req, resHeaders, info }) { const db = c.get("db"); const dbDirect = c.get("dbDirect"); const auth = c.get("auth"); if (!db) throw new Error("Database not available in context"); if (!dbDirect) throw new Error("Direct database not available in context"); if (!auth) throw new Error("Authentication service not available in context"); const sessionData = await auth.api.getSession({ headers: req.headers, }); return { req, res: c.res, resHeaders, info, env: c.env, db, dbDirect, session: sessionData?.session ?? null, user: sessionData?.user ?? null, cache: new Map(), }; }, batching: { enabled: true }, }); }); ``` The `db`, `dbDirect`, and `auth` values come from the Hono middleware layer (set in `worker.ts`). The tRPC context adds session resolution and a fresh `cache` Map. ## Middleware Chain The Worker entrypoint (`worker.ts`) applies middleware in order: ```txt Request │ ├── errorHandler ← catches all unhandled errors ├── notFoundHandler ← returns 404 JSON for unmatched routes │ ├── secureHeaders() ← security headers (CSP, X-Frame-Options, etc.) ├── requestId() ← generates X-Request-Id (uses CF-Ray if available) ├── logger() ← logs request method, path, status, duration │ ├── context init ← creates db, dbDirect, auth; sets on Hono context │ └── app.ts routes ├── /api/auth/* ← Better Auth (session resolved internally) └── /api/trpc/* ← tRPC (session resolved in createContext) ``` ::: info The `protectedProcedure` middleware (defined in `lib/trpc.ts`) adds another layer within tRPC. It checks that `session` and `user` are non-null and narrows their types – procedures using `protectedProcedure` never need null checks. See [Procedures](./procedures#protectedprocedure). ::: ::: tip In production (`worker.ts`), the request ID generator uses the Cloudflare Ray ID when available. In local development (`dev.ts`), it falls back to the default UUID generator since `cf-ray` headers aren't present. ::: ## Request ID The request ID middleware uses the Cloudflare Ray ID when available, falling back to `crypto.randomUUID()` in local development: ```ts export function requestIdGenerator(c: Context): string { return c.req.header("cf-ray") ?? crypto.randomUUID(); } ``` The ID is available via the `X-Request-Id` response header for tracing requests across logs. ## DataLoaders DataLoaders prevent N+1 queries by batching multiple `.load(id)` calls into a single SQL `WHERE IN (...)` query. They're defined in `apps/api/lib/loaders.ts` and cached per-request via `ctx.cache`. ```ts import { userById } from "../lib/loaders.js"; members: protectedProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ ctx, input }) => { const members = await ctx.db.query.member.findMany({ where: (m, { eq }) => eq(m.organizationId, input.organizationId), }); // Batches all user lookups into one query const users = await Promise.all( members.map((m) => userById(ctx).load(m.userId)), ); return members.map((m, i) => ({ ...m, user: users[i] })); }), ``` Loaders are created with a `defineLoader` helper that handles per-request caching via `ctx.cache`: ```ts function defineLoader( key: symbol, batchFn: (ctx: TRPCContext, keys: readonly K[]) => Promise<(V | null)[]>, ): (ctx: TRPCContext) => DataLoader; ``` Each call returns a factory `(ctx) => DataLoader`. The first invocation per request creates the instance; subsequent calls return the cached one. Because `ctx.cache` is a `Map` created per-request, loaders are automatically scoped to the request lifecycle – no stale data across requests. ### Adding a DataLoader Add a `defineLoader` call in `apps/api/lib/loaders.ts`: ```ts export const postById = defineLoader( Symbol("postById"), async (ctx, ids: readonly string[]) => { const posts = await ctx.db .select() .from(post) .where(inArray(post.id, [...ids])); return mapByKey(posts, "id", ids); }, ); ``` Then call `.load(key)` or `.loadMany(keys)` in your procedures. ================================================ FILE: docs/api/index.md ================================================ --- outline: [2, 3] --- # API Overview The API server (`apps/api/`) runs as a Cloudflare Worker and handles all backend logic: authentication, data access, and billing webhooks. It combines two frameworks: - **[Hono](https://hono.dev/)** – lightweight HTTP router for auth endpoints, webhooks, and health checks - **[tRPC](https://trpc.io/)** – type-safe RPC layer for all client-facing queries and mutations Hono handles the HTTP surface. tRPC handles the typed contract between frontend and backend. They share the same Worker and middleware stack. ## How the Worker is Wired The API has two entrypoints – one for production (Cloudflare Workers) and one for local development (Bun): | File | Runtime | Description | | ----------- | ------------------ | ---------------------------------------------- | | `worker.ts` | Cloudflare Workers | Production entrypoint | | `dev.ts` | Bun | Local dev server via `wrangler` platform proxy | Both follow the same structure: ``` worker.ts / dev.ts ├── errorHandler, notFoundHandler ├── secureHeaders() ├── requestId() ├── logger() ├── context init (db, dbDirect, auth) └── mount app.ts ├── GET /api → API info (JSON) ├── GET /health → health check ├── * /api/auth/* → Better Auth handler └── * /api/trpc/* → tRPC fetch adapter ``` The top-level worker (`worker.ts`) sets up global middleware and initializes shared resources, then mounts the core Hono app (`lib/app.ts`) which defines the actual routes. ```ts // apps/api/worker.ts (simplified) const worker = new Hono(); worker.onError(errorHandler); worker.notFound(notFoundHandler); worker.use(secureHeaders()); worker.use(requestId({ generator: requestIdGenerator })); worker.use(logger()); // Initialize shared context worker.use(async (c, next) => { const db = createDb(c.env.HYPERDRIVE_CACHED); const dbDirect = createDb(c.env.HYPERDRIVE_DIRECT); c.set("db", db); c.set("dbDirect", dbDirect); c.set("auth", createAuth(db, c.env)); await next(); }); // Mount the core app worker.route("/", app); ``` ## Endpoints | Path | Method | Handler | Description | | ------------- | --------- | ----------- | ------------------------------------------------------------------------------ | | `/` | GET | Hono | Redirects to `/api` | | `/api` | GET | Hono | API metadata (name, version, endpoints) | | `/health` | GET | Hono | Health check – returns `{ status, timestamp }` | | `/api/auth/*` | GET, POST | Better Auth | Authentication routes ([docs](https://www.better-auth.com/docs/api-reference)) | | `/api/trpc/*` | \* | tRPC | Type-safe RPC – all queries and mutations | ## tRPC Router The root router merges domain-specific sub-routers: ```ts // apps/api/lib/app.ts const appRouter = router({ billing: billingRouter, user: userRouter, organization: organizationRouter, }); ``` Each sub-router lives in `routers/` and exports a single router instance. See [Procedures](./procedures) for details on adding your own. ## Project Structure ```bash apps/api/ ├── worker.ts # Cloudflare Workers entrypoint ├── dev.ts # Local dev server (Bun) ├── index.ts # Public package exports ├── lib/ │ ├── ai.ts # OpenAI provider factory │ ├── app.ts # Hono app + tRPC router composition │ ├── auth.ts # Better Auth configuration │ ├── context.ts # TRPCContext and AppContext types │ ├── db.ts # Drizzle ORM database factory │ ├── email.ts # Resend email utilities │ ├── env.ts # Environment variable schema (Zod) │ ├── loaders.ts # DataLoader instances for N+1 prevention │ ├── middleware.ts # Error handler, 404 handler, request ID │ ├── plans.ts # Subscription plan limits │ ├── stripe.ts # Stripe client factory │ └── trpc.ts # tRPC init, procedures, error formatter ├── routers/ │ ├── billing.ts # Subscription queries │ ├── billing.test.ts # Billing router tests │ ├── organization.ts # Organization CRUD │ └── user.ts # User profile queries └── wrangler.jsonc # Cloudflare Workers config ``` ## Calling the API from the Frontend The frontend app (`apps/app/`) uses `@trpc/client` with TanStack Query integration. The tRPC client is configured in `apps/app/lib/trpc.ts`: ```ts import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query"; export const api = createTRPCOptionsProxy({ client: trpcClient, queryClient, }); ``` Use `api` in components to call procedures with full type safety: ```ts import { useSuspenseQuery } from "@tanstack/react-query"; import { api } from "~/lib/trpc"; function Profile() { const { data } = useSuspenseQuery(api.user.me.queryOptions()); return

{data.name}

; } ``` See the [tRPC + TanStack Query docs](https://trpc.io/docs/client/react/tanstack-react-query) for the full client API. ================================================ FILE: docs/api/procedures.md ================================================ # Procedures tRPC procedures are the primary way the frontend communicates with the API. Each procedure is either a **query** (read data) or a **mutation** (write data), with optional input validation via Zod. ## Procedure Types The project defines two base procedures in `apps/api/lib/trpc.ts`: ### `publicProcedure` Accessible to all callers, including unauthenticated users. Context includes `db`, `env`, and `cache` but `session` and `user` may be `null`. ```ts import { publicProcedure } from "../lib/trpc.js"; export const healthRouter = router({ ping: publicProcedure.query(() => { return { status: "ok" }; }), }); ``` ### `protectedProcedure` Requires an authenticated session. Throws `UNAUTHORIZED` if the user is not logged in. Context narrows `session` and `user` to non-null types – no runtime null checks needed. ```ts import { protectedProcedure } from "../lib/trpc.js"; export const userRouter = router({ me: protectedProcedure.query(async ({ ctx }) => { return { id: ctx.user.id, // ✓ guaranteed non-null email: ctx.user.email, name: ctx.user.name, }; }), }); ``` ## Router Files Each domain gets its own router file in `apps/api/routers/`: ``` routers/ ├── billing.ts # billing.subscription ├── organization.ts # organization.list, .create, .update, .delete, ... └── user.ts # user.me, .updateProfile, .list ``` Routers are merged into the root `appRouter` in `apps/api/lib/app.ts`: ```ts const appRouter = router({ billing: billingRouter, user: userRouter, organization: organizationRouter, }); ``` The client calls procedures using the namespace: `api.user.me`, `api.billing.subscription`, etc. ## Input Validation Define inputs with Zod schemas. tRPC validates them automatically and returns structured errors on failure (see [Validation & Errors](./validation-errors)). ```ts import { z } from "zod"; export const userRouter = router({ updateProfile: protectedProcedure .input( z.object({ name: z.string().min(1).optional(), email: z.email({ error: "Invalid email address" }).optional(), }), ) .mutation(({ input, ctx }) => { // `input` is fully typed: { name?: string; email?: string } return { id: ctx.user.id, ...input }; }), }); ``` For queries with pagination: ```ts list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional(), }), ) .query(({ input }) => { // input.limit defaults to 10 if not provided return { users: [], nextCursor: null }; }), ``` ## Adding a New Procedure **1. Create the router file** (or add to an existing one): ```ts // apps/api/routers/post.ts import { z } from "zod"; import { protectedProcedure, router } from "../lib/trpc.js"; export const postRouter = router({ list: protectedProcedure .input(z.object({ limit: z.number().max(50).default(20) })) .query(async ({ ctx, input }) => { return ctx.db.query.post.findMany({ limit: input.limit }); }), create: protectedProcedure .input(z.object({ title: z.string().min(1), body: z.string() })) .mutation(async ({ ctx, input }) => { // Insert into database }), }); ``` **2. Register the router** in `apps/api/lib/app.ts`: ```ts import { postRouter } from "../routers/post.js"; const appRouter = router({ billing: billingRouter, user: userRouter, organization: organizationRouter, post: postRouter, // [!code ++] }); ``` **3. Call from the frontend** – the types propagate automatically: ```ts const { data } = useSuspenseQuery(api.post.list.queryOptions({ limit: 10 })); ``` ## Naming Conventions - **Router files**: singular noun matching the domain (`user.ts`, `billing.ts`, `organization.ts`) - **Router variables**: `{domain}Router` – `userRouter`, `billingRouter` - **Procedure names**: verb or short phrase – `me`, `list`, `create`, `updateProfile` - **Namespace key**: matches the domain – `user:`, `billing:`, `organization:` ## Testing Procedures Use `createCallerFactory` to test procedures without HTTP: ```ts import { createCallerFactory } from "../lib/trpc"; import { billingRouter } from "./billing"; const createCaller = createCallerFactory(billingRouter); it("returns free plan defaults", async () => { const caller = createCaller(mockContext()); const result = await caller.subscription(); expect(result.plan).toBe("free"); }); ``` ================================================ FILE: docs/api/validation-errors.md ================================================ # Validation & Errors Input validation and error handling follow one flow: Zod schemas validate procedure inputs, validation failures produce tRPC errors, and the error formatter attaches structured details for the client. ## Input Validation Every tRPC procedure can define a Zod schema via `.input()`. tRPC runs validation automatically before the procedure body executes. ```ts updateProfile: protectedProcedure .input( z.object({ name: z.string().min(1).optional(), email: z.email({ error: "Invalid email address" }).optional(), }), ) .mutation(({ input }) => { // Only runs if input passes validation }), ``` When validation fails, tRPC returns a `BAD_REQUEST` error with the Zod error attached (see [Error Formatter](#error-formatter) below). ## Error Formatter The tRPC initialization in `apps/api/lib/trpc.ts` includes a custom error formatter that attaches Zod validation details to the response: ```ts const t = initTRPC.context().create({ errorFormatter({ shape, error }) { return { ...shape, data: { ...shape.data, zodError: error.cause instanceof ZodError ? flattenError(error.cause) : null, }, }; }, }); ``` This means every error response includes a `zodError` field – either a [flattened Zod error](https://zod.dev/api?id=flattenerror) object or `null`. Clients can use this for field-level error display. Example error response for a failed validation: ```json { "error": { "message": "...", "code": -32600, "data": { "code": "BAD_REQUEST", "zodError": { "formErrors": [], "fieldErrors": { "email": ["Invalid email address"] } } } } } ``` ## Throwing Errors in Procedures For business logic errors, throw `TRPCError` with an appropriate code: ```ts import { TRPCError } from "@trpc/server"; create: protectedProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const existing = await ctx.db.query.organization.findFirst({ where: (o, { eq }) => eq(o.name, input.name), }); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "Organization name already taken", }); } // ... create organization }), ``` Common tRPC error codes: | Code | HTTP Status | When to Use | | ----------------------- | ----------- | ------------------------------------------------------- | | `BAD_REQUEST` | 400 | Invalid input (automatic from Zod) | | `UNAUTHORIZED` | 401 | Not authenticated (automatic from `protectedProcedure`) | | `FORBIDDEN` | 403 | Authenticated but lacking permission | | `NOT_FOUND` | 404 | Resource doesn't exist | | `CONFLICT` | 409 | Duplicate or conflicting state | | `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error | See the full list in the [tRPC error codes reference](https://trpc.io/docs/server/error-handling#error-codes). ## HTTP Error Handling Hono middleware in `apps/api/lib/middleware.ts` catches errors outside the tRPC layer: ```ts export const errorHandler: ErrorHandler = (err, c) => { if (err instanceof HTTPException) { // Merge middleware headers (CORS, security) into the exception response const res = err.getResponse(); const headers = new Headers(res.headers); c.res.headers.forEach((v, k) => headers.set(k, v)); return new Response(res.body, { status: res.status, statusText: res.statusText, headers, }); } console.error(`[${c.req.method}] ${c.req.path}:`, err); return c.json({ error: "Internal Server Error" }, 500); }; ``` - **`HTTPException`** (from Hono) – merges middleware headers (security, CORS) into the exception's response before returning it. Used by Better Auth and webhook handlers. - **Unexpected errors** – logged and returned as a generic 500. The tRPC adapter also logs errors independently: ```ts onError({ error, path }) { console.error("tRPC error on path", path, ":", error); }, ``` ## Client-Side Error Handling The frontend app provides three utilities in `apps/app/lib/errors.ts` for working with errors from both tRPC and Better Auth: ### `getErrorStatus(error)` Extracts the HTTP status code from various error shapes: ```ts import { getErrorStatus } from "~/lib/errors"; try { await trpcClient.organization.create.mutate({ name: "" }); } catch (err) { const status = getErrorStatus(err); // 400 } ``` ### `isUnauthenticatedError(error)` Checks if the error indicates a 401 / `UNAUTHORIZED` state. Useful for triggering redirects to login: ```ts import { isUnauthenticatedError } from "~/lib/errors"; if (isUnauthenticatedError(error)) { navigate({ to: "/login" }); } ``` ::: tip `isUnauthenticatedError` checks for HTTP 401 and tRPC `UNAUTHORIZED` code. It does **not** match 403 (Forbidden) – that means authenticated but lacking permission. ::: ### `getErrorMessage(error)` Safely extracts a human-readable message from any thrown value: ```ts import { getErrorMessage } from "~/lib/errors"; const message = getErrorMessage(error); // "Organization name already taken" or "An unexpected error occurred" ``` ================================================ FILE: docs/architecture/edge.md ================================================ # Edge Implementation details for the Cloudflare Workers deployment. Read the [Architecture Overview](./) first for the mental model. ## Workers Configuration Each worker has its own `wrangler.jsonc` in its workspace directory: | Worker | Config | `nodejs_compat` | Static assets | Service bindings | | ------ | ------------------------- | :-------------: | :-------------: | :----------------------: | | web | `apps/web/wrangler.jsonc` | No | Marketing pages | APP_SERVICE, API_SERVICE | | app | `apps/app/wrangler.jsonc` | No | SPA bundle | – | | api | `apps/api/wrangler.jsonc` | Yes | – | – | The API worker enables `nodejs_compat` for packages that depend on Node.js built-ins (e.g. `postgres`, `crypto`). The web and app workers don't need it – they only serve static assets and proxy requests. ## Service Bindings Service bindings are **non-inheritable** in Wrangler – the top-level declaration only applies to production. Each environment must redeclare its bindings with the correct worker names. ```jsonc // apps/web/wrangler.jsonc { // Production (top-level) "services": [ { "binding": "APP_SERVICE", "service": "example-app" }, { "binding": "API_SERVICE", "service": "example-api" }, ], "env": { "staging": { "services": [ { "binding": "APP_SERVICE", "service": "example-app-staging" }, { "binding": "API_SERVICE", "service": "example-api-staging" }, ], }, "preview": { "services": [ { "binding": "APP_SERVICE", "service": "example-app-preview" }, { "binding": "API_SERVICE", "service": "example-api-preview" }, ], }, }, } ``` Worker naming convention: `--`. Production omits the environment suffix. | Environment | Web | App | API | | ----------- | --------------------- | --------------------- | --------------------- | | Production | `example-web` | `example-app` | `example-api` | | Staging | `example-web-staging` | `example-app-staging` | `example-api-staging` | | Preview | `example-web-preview` | `example-app-preview` | `example-api-preview` | ## Hyperdrive [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) provides connection pooling between Workers and Neon PostgreSQL. The API worker declares two bindings per environment: | Binding | Caching | Purpose | | ------------------- | -------- | -------------------------------------- | | `HYPERDRIVE_CACHED` | Enabled | Read-heavy queries | | `HYPERDRIVE_DIRECT` | Disabled | Writes and consistency-sensitive reads | ```jsonc // apps/api/wrangler.jsonc "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id-here" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-hyperdrive-direct-id-here" } ] ``` Each environment has its own Hyperdrive IDs pointing to the corresponding Neon database branch. The connection code in `apps/api/lib/db.ts`: ```ts import { schema } from "@repo/db"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; export function createDb(db: Hyperdrive) { const client = postgres(db.connectionString, { max: 1, // Workers are single-request; one connection is enough prepare: false, // Avoids prepared statement caching issues in Workers connect_timeout: 10, idle_timeout: 20, max_lifetime: 60 * 30, transform: { undefined: null }, onnotice: () => {}, // Suppress PostgreSQL NOTICE messages }); return drizzle(client, { schema, casing: "snake_case" }); } ``` Key settings: `max: 1` because each Worker invocation handles a single request. `prepare: false` prevents issues with Hyperdrive's connection reuse where prepared statements from a previous request may not exist on the pooled connection. ## Static Assets ### Web Worker The web worker serves marketing pages from `apps/web/dist/`. The `run_worker_first` setting forces specific paths through the worker script before falling back to static assets: ```jsonc // apps/web/wrangler.jsonc "assets": { "directory": "./dist", "binding": "ASSETS", "run_worker_first": ["/"] } ``` This is required for the `/` route where the worker checks the auth hint cookie to decide between the marketing page and the app dashboard. All other paths either match explicit worker routes (`/api/*`, `/login*`) or fall through to static assets. ### App Worker The app worker is a pure static asset worker with SPA fallback – no custom worker script: ```jsonc // apps/app/wrangler.jsonc "assets": { "directory": "./dist", "not_found_handling": "single-page-application" } ``` `not_found_handling: "single-page-application"` returns `index.html` for any path that doesn't match a static file, enabling TanStack Router's client-side routing. ## Auth Hint Cookie Routing The web worker's `/` route uses the auth hint cookie to choose between two upstream workers: ```ts // apps/web/worker.ts app.on(["GET", "HEAD"], "/", async (c) => { const hasAuthHint = getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1"; const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch( c.req.raw, ); // Prevent caching – response varies by auth state const headers = new Headers(upstream.headers); headers.set("Cache-Control", "private, no-store"); headers.set("Vary", "Cookie"); return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers, }); }); ``` The `Cache-Control: private, no-store` and `Vary: Cookie` headers prevent CDN and browser caches from serving the wrong version (marketing page to a logged-in user or vice versa). See [ADR-001](/adr/001-auth-hint-cookie) for the full decision record. ## Infrastructure Worker metadata and Hyperdrive bindings are provisioned with Terraform. Wrangler handles code deployment and route configuration. ``` infra/ ├── stacks/ │ ├── edge/ # Workers, Hyperdrive, DNS │ │ ├── main.tf │ │ ├── variables.tf │ │ └── outputs.tf │ └── hybrid/ # Database and other resources ├── modules/ │ ├── cloudflare/ # Worker, Hyperdrive, DNS modules │ └── gcp/ ├── envs/ # Per-environment Terraform root modules └── templates/ ``` The edge stack (`infra/stacks/edge/main.tf`) creates all three workers, a Hyperdrive binding pair, and DNS records: ```hcl module "worker_api" { source = "../../modules/cloudflare/worker" name = "${var.project_slug}-api${local.worker_suffix}" # ... } module "hyperdrive" { source = "../../modules/cloudflare/hyperdrive" name = "${var.project_slug}-${var.environment}" database_url = var.neon_database_url } ``` The `worker_suffix` local resolves to `""` for production and `"-${var.environment}"` for other environments, matching the naming convention used in service bindings. ## Local Development `bun dev` starts all three workers concurrently with Wrangler's dev mode: | Worker | Port | Notes | | ------ | ------ | --------------------------------------- | | web | `5173` | Entry point – open this in your browser | | app | `5174` | Accessed via service binding from web | | api | `5175` | Accessed via service binding from web | In development, Wrangler simulates service bindings locally – requests between workers happen in-process rather than over the network. The `dev` environment in each `wrangler.jsonc` provides development-specific variables (`APP_ORIGIN: http://localhost:5173`, etc.). ::: tip Email templates must be built before starting the API dev server. The `bun dev` script handles this automatically by running `bun email:build` first. ::: ================================================ FILE: docs/architecture/index.md ================================================ # Architecture Overview React Starter Kit runs on three Cloudflare Workers connected by [service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). A single domain receives all traffic – the **web** worker routes each request to the right destination without any cross-worker public URLs. ## Request Flow ```mermaid sequenceDiagram participant Browser participant Web as Web Worker participant App as App Worker participant API as API Worker participant DB as Neon PostgreSQL Browser->>Web: GET / alt auth-hint cookie present Web->>App: service binding App-->>Web: SPA (dashboard) else no cookie Web-->>Browser: marketing page end Browser->>Web: GET /settings Web->>App: service binding App-->>Web: SPA assets Browser->>Web: POST /api/trpc/user.me Web->>API: service binding API->>DB: Hyperdrive DB-->>API: query result API-->>Web: JSON response Web-->>Browser: JSON response ``` ## Workers | Worker | Workspace | Purpose | Has `nodejs_compat` | | ------- | ---------- | ----------------------------------------------------- | :-----------------: | | **web** | `apps/web` | Edge router – receives all traffic, routes to app/api | No | | **app** | `apps/app` | SPA static assets (React, TanStack Router) | No | | **api** | `apps/api` | Hono server – tRPC, Better Auth, webhooks | Yes | ### Web Worker The web worker is the only worker with a public route (`example.com/*`). It decides where each request goes: - `/api/*` – forwarded to the API worker - `/login`, `/signup`, `/settings`, `/analytics`, `/reports`, `/_app/*` – forwarded to the app worker - `/` – routed by [auth hint cookie](#auth-hint-cookie) (app if signed in, marketing site if not) - Everything else – served from the web worker's own static assets (marketing pages) ```ts // apps/web/worker.ts (simplified) app.all("/api/*", (c) => c.env.API_SERVICE.fetch(c.req.raw)); app.all("/login*", (c) => c.env.APP_SERVICE.fetch(c.req.raw)); app.on(["GET", "HEAD"], "/", async (c) => { const hasAuthHint = getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1"; const upstream = await (hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS).fetch( c.req.raw, ); // ... }); ``` ### App Worker A static asset worker with `not_found_handling: "single-page-application"` – any path that doesn't match a file returns `index.html`, enabling client-side routing via TanStack Router. The app worker has no custom worker script. It is accessed only through service bindings from the web worker. ### API Worker Runs the Hono HTTP server with the following middleware chain: ```ts // apps/api/worker.ts (simplified) worker.onError(errorHandler); worker.notFound(notFoundHandler); worker.use(secureHeaders()); worker.use(requestId({ generator: requestIdGenerator })); worker.use(logger()); // Initialize shared context worker.use(async (c, next) => { const db = createDb(c.env.HYPERDRIVE_CACHED); c.set("db", db); c.set("dbDirect", createDb(c.env.HYPERDRIVE_DIRECT)); c.set("auth", createAuth(db, c.env)); await next(); }); worker.route("/", app); // Mounts tRPC + auth + health routes ``` Primary endpoints: | Path | Handler | | ------------- | ------------------------------------------------------ | | `/api/auth/*` | Better Auth (login, signup, sessions, OAuth callbacks) | | `/api/trpc/*` | tRPC procedures (batching enabled) | | `/api` | API info (name, version, endpoint list) | | `/health` | Health check | ## Service Bindings Service bindings let workers call each other directly over Cloudflare's internal network – no HTTP round-trip through the public internet. ```jsonc // apps/web/wrangler.jsonc "services": [ { "binding": "APP_SERVICE", "service": "example-app" }, { "binding": "API_SERVICE", "service": "example-api" } ] ``` ::: warning Service bindings are **non-inheritable** in Wrangler – they must be declared in every environment block. Forgetting this causes staging/preview workers to bind to production services. ::: Naming convention: `--` (e.g. `example-api-staging`). See [Edge > Service Bindings](./edge#service-bindings) for the full per-environment config. ## Database Connection The API worker connects to [Neon PostgreSQL](https://neon.tech) via [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) – a connection pool that sits between Workers and your database. Two bindings are available: | Binding | Caching | Use case | | ------------------- | -------- | ------------------------------------- | | `HYPERDRIVE_CACHED` | Enabled | Default reads – most queries go here | | `HYPERDRIVE_DIRECT` | Disabled | Writes and reads that need fresh data | Both bindings are initialized in the API worker middleware and available on every request context as `db` and `dbDirect`. See [Database](/database/) for schema and query patterns. ## Auth Hint Cookie The `/` route serves two different experiences – a marketing page for visitors and the app dashboard for signed-in users. The web worker needs a fast signal to choose without owning auth logic. **How it works:** Better Auth sets a lightweight `__Host-auth=1` cookie on sign-in and clears it on sign-out. The web worker checks only for cookie _presence_ – it never validates sessions. If the cookie exists, the request goes to the app worker; otherwise it serves the marketing page. This cookie is a **routing hint only**, not a security boundary. A false positive (stale cookie) results in one extra redirect to `/login` – the app worker validates the real session. ::: info In local development the cookie is named `auth` (HTTP), since browsers reject the `__Host-` prefix without HTTPS. ::: See [ADR-001](/adr/001-auth-hint-cookie) for the full decision record and [Sessions & Protected Routes](/auth/sessions) for the auth flow. ## Environments | Environment | Workers | Domain | Database | Deploy command | | ----------- | --------------- | --------------------- | -------------- | ------------------------------- | | Development | `wrangler dev` | `localhost:5173` | Dev branch | `bun dev` | | Preview | `*-preview` | `preview.example.com` | Preview branch | `wrangler deploy --env preview` | | Staging | `*-staging` | `staging.example.com` | Staging branch | `wrangler deploy --env staging` | | Production | `*` (no suffix) | `example.com` | Main branch | `wrangler deploy` | Each environment has its own Hyperdrive bindings, service binding targets, and `APP_ORIGIN` / `ALLOWED_ORIGINS` variables. See [Edge > Service Bindings](./edge#service-bindings) for the full wrangler config. ## Build Order The workspaces must build in dependency order: ``` email → web → api → app ``` Email templates are compiled first because the API server imports them. The `bun build` command handles this automatically. ## Key Invariants - The **API worker is the sole authority** for authentication and data access – the web worker never validates sessions or queries the database. - Only the **web worker** has public routes. App and API workers are accessed exclusively through service bindings. - **Service bindings are non-inheritable** – every Wrangler environment must declare its own bindings. - The auth hint cookie is a **routing optimization**, not a security mechanism. - The API worker is the only worker with `nodejs_compat` enabled. ================================================ FILE: docs/auth/email-otp.md ================================================ --- outline: [2, 3] --- # Email & OTP The primary sign-in method is passwordless email OTP. Users enter their email, receive a 6-digit code, and enter it to authenticate. The same flow handles both login and signup – if the email doesn't exist, Better Auth creates the account automatically. ## Server Configuration The `emailOTP` plugin is configured in `apps/api/lib/auth.ts`: ```ts emailOTP({ async sendVerificationOTP({ email, otp, type }) { await sendOTP(env, { email, otp, type }); }, otpLength: 6, expiresIn: 300, // 5 minutes allowedAttempts: 3, // max wrong guesses before code is invalidated }), ``` OTP codes are stored in the `verification` table and automatically expire. After 3 failed attempts, the code is invalidated and the user must request a new one. ### Email Delivery OTP emails are sent via [React Email](https://react.email/) templates rendered to HTML + plain text, delivered through [Resend](https://resend.com/): ```ts // apps/api/lib/email.ts export async function sendOTP(env, { email, otp, type }) { // In development, OTP is also printed to the console if (env.ENVIRONMENT === "development") { console.log(`OTP code for ${email}: ${otp}`); } const component = OTPEmail({ otp, type, appName: env.APP_NAME }); const html = await renderEmailToHtml(component); const text = await renderEmailToText(component); return sendEmail(env, { to: email, subject: `Your Sign In code`, html, text, }); } ``` ::: tip During local development, OTP codes are logged to the terminal – you don't need a real Resend API key to test the flow. ::: ## Client Flow The auth form implements a 3-step state machine: ``` method → email → otp ``` Each step is a separate UI component orchestrated by `AuthForm`: | Step | Component | What Happens | | -------- | ----------------- | -------------------------------------------------- | | `method` | `MethodSelection` | User picks sign-in method (Google, email, passkey) | | `email` | `EmailInput` | User enters email, OTP is sent | | `otp` | `OtpVerification` | User enters 6-digit code to complete sign-in | ### State Machine The state transitions are defined in `apps/app/components/auth/use-auth-form.ts`: ```ts const VALID_TRANSITIONS: Record = { method: ["email"], email: ["method", "otp"], otp: ["email"], }; ``` Transitions are validated – invalid step jumps are silently ignored. This prevents race conditions from concurrent auth operations (e.g., passkey conditional UI completing while the user clicks a button). ### Sending the OTP When the user submits their email, the `sendOtp` function normalizes the input and calls the Better Auth client: ```ts // "sign-in" type handles both login and signup const result = await auth.emailOtp.sendVerificationOtp({ email: normalizedEmail, type: "sign-in", }); ``` The `sign-in` type is used for both login and signup flows. Better Auth creates the user account if the email is new. ### Verifying the Code The `OtpVerification` component handles code entry and verification: ```ts const result = await auth.signIn.emailOtp({ email, otp }); ``` The input field restricts to 6 numeric digits with `inputMode="numeric"` and `autoComplete="one-time-code"` for mobile OTP autofill. ## Error Handling The OTP plugin returns specific error codes that map to user-friendly messages: | Error Code | User Message | Behavior | | ------------------- | ------------------------------------------------------ | ----------------------------- | | `TOO_MANY_ATTEMPTS` | "Too many failed attempts. Please request a new code." | Returns to email step | | `OTP_EXPIRED` | "Code has expired. Please request a new one." | Returns to email step | | `INVALID_OTP` | Server message or "Invalid verification code" fallback | Stays on OTP step (can retry) | When `TOO_MANY_ATTEMPTS` or `OTP_EXPIRED` occurs, the form automatically returns to the email step so the user can request a fresh code. ### Resend Cooldown After the initial OTP is sent, users can request a new code with a 30-second cooldown: ```ts const RESEND_COOLDOWN_SECONDS = 30; ``` The resend button shows a countdown timer and is disabled during the cooldown period. ## Component Architecture ``` AuthForm ├── MethodSelection Step 1: choose sign-in method │ ├── GoogleLogin OAuth redirect │ ├── "Continue with email" button │ └── PasskeyLogin WebAuthn (login only) ├── EmailInput Step 2: enter email, send OTP └── OtpStep Step 3: wraps OTP UI with back link (internal to AuthForm) └── OtpVerification Code entry and verification ``` The `AuthForm` accepts a `mode` prop (`"login"` or `"signup"`) that controls copy and available methods. Both modes use the same OTP flow – the difference is cosmetic (headings, ToS display, passkey availability). ::: info Passkeys are only shown during login. They require an existing account with a registered passkey – see [Passkeys](./passkeys). ::: ================================================ FILE: docs/auth/index.md ================================================ --- outline: [2, 3] --- # Authentication Overview Authentication is handled by [Better Auth](https://www.better-auth.com/) – a TypeScript-native auth framework that runs entirely in the API worker. The project ships with multiple sign-in methods, organization-based multi-tenancy, and Stripe billing integration out of the box. ## What's Included | Method | Description | | ---------------------------------- | ----------------------------------------- | | [Email & OTP](./email-otp) | Passwordless 6-digit code via email | | Email & Password | Traditional email/password with reset | | [Google OAuth](./social-providers) | Social login with redirect flow | | [Passkeys](./passkeys) | WebAuthn biometric / security key | | Anonymous | Guest sessions that can be upgraded later | All methods produce the same session format. Users can link multiple methods to one account. ## Plugins Better Auth's functionality is extended through plugins. The server and client must enable matching plugins: | Plugin | Server | Client | Purpose | | -------------- | ---------------- | ---------------------- | --------------------------- | | `emailOTP` | `emailOTP()` | `emailOTPClient()` | Passwordless OTP sign-in | | `organization` | `organization()` | `organizationClient()` | Multi-tenant orgs and roles | | `passkey` | `passkey()` | `passkeyClient()` | WebAuthn authentication | | `anonymous` | `anonymous()` | `anonymousClient()` | Guest sessions | | `stripe` | `stripe()` | `stripeClient()` | Subscription billing | The Stripe plugin is conditionally loaded – it only activates when `STRIPE_SECRET_KEY` and related env vars are set. Without them, the app works normally but billing endpoints return 404. ## Server Configuration The auth instance is created per-request in `apps/api/lib/auth.ts`: ```ts // apps/api/lib/auth.ts export function createAuth(db: DB, env: AuthEnv) { return betterAuth({ baseURL: `${env.APP_ORIGIN}/api/auth`, trustedOrigins: [env.APP_ORIGIN], secret: env.BETTER_AUTH_SECRET, database: drizzleAdapter(db, { provider: "pg", schema: { ... } }), emailAndPassword: { enabled: true, sendResetPassword: async ({ user, url }) => { await sendPasswordReset(env, { user, url }); }, }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendVerificationEmail(env, { user, url }); }, }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, plugins: [ anonymous(), organization({ allowUserToCreateOrganization: true, organizationLimit: 5, creatorRole: "owner", }), passkey({ rpID, rpName: env.APP_NAME, origin: env.APP_ORIGIN }), emailOTP({ otpLength: 6, expiresIn: 300, allowedAttempts: 3 }), ...stripePlugin(db, env), ], }); } ``` The `account` model is renamed to `identity` to better describe its purpose (OAuth provider credentials): ```ts account: { modelName: "identity" }, ``` ### ID Generation All auth tables use prefixed CUID2 IDs generated at the application level: ```ts advanced: { database: { generateId: ({ model }) => generateAuthId(model), }, }, ``` This produces IDs like `usr_cm...`, `ses_cm...`, `org_cm...` – making it easy to identify what kind of record an ID refers to. ## Client Configuration The auth client lives in `apps/app/lib/auth.ts`: ```ts // apps/app/lib/auth.ts import { createAuthClient } from "better-auth/react"; export const auth = createAuthClient({ baseURL: baseURL + "/api/auth", plugins: [ anonymousClient(), emailOTPClient(), organizationClient(), passkeyClient(), stripeClient({ subscription: true }), ], }); ``` ::: warning Do not use `auth.useSession()` directly. Session state is managed exclusively through TanStack Query – see [Sessions & Protected Routes](./sessions). ::: ## Auth Routes Better Auth exposes HTTP endpoints at `/api/auth/*`. These are mounted in the Hono app alongside tRPC: ``` /api/auth/sign-in/* Sign-in endpoints (email, social, passkey) /api/auth/sign-up/* Sign-up endpoints /api/auth/sign-out Session termination /api/auth/get-session Current session data /api/auth/callback/* OAuth callbacks /api/auth/email-otp/* OTP send and verify /api/auth/passkey/* WebAuthn registration and authentication /api/auth/organization/* Organization CRUD and membership ``` See the [Better Auth API reference](https://www.better-auth.com/docs/api-reference) for the full endpoint list. ## Database Tables Authentication uses 9 database tables defined in `db/schema/`: | Table | File | Description | | -------------- | ----------------- | ---------------------------------------------------------- | | `user` | `user.ts` | User accounts with profile info | | `session` | `user.ts` | Active sessions with `activeOrganizationId` | | `identity` | `user.ts` | OAuth provider credentials (Better Auth's `account` model) | | `verification` | `user.ts` | Email verification and OTP tokens | | `organization` | `organization.ts` | Tenant organizations | | `member` | `organization.ts` | Organization memberships with roles | | `invitation` | `invitation.ts` | Pending org invitations | | `passkey` | `passkey.ts` | WebAuthn credential store | | `subscription` | `subscription.ts` | Stripe subscription state | ## Auth Hint Cookie The API worker sets a lightweight cookie (`__Host-auth` in HTTPS, `auth` in HTTP dev) on sign-in and clears it on sign-out. The web edge worker reads this cookie to route `/` – authenticated users get the app, anonymous users get the marketing page. This cookie is a routing hint only, not a security boundary. See [ADR-001](/adr/001-auth-hint-cookie) for the full rationale. ## Environment Variables | Variable | Required | Description | | ---------------------- | -------- | ------------------------------------------------- | | `BETTER_AUTH_SECRET` | Yes | Secret for signing sessions and tokens | | `GOOGLE_CLIENT_ID` | Yes | Google OAuth client ID | | `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth client secret | | `RESEND_API_KEY` | Yes | API key for sending OTP emails | | `RESEND_EMAIL_FROM` | Yes | Sender address for auth emails | | `APP_NAME` | Yes | Display name (used in emails and passkey prompts) | | `APP_ORIGIN` | Yes | Full origin URL (e.g., `https://example.com`) | ================================================ FILE: docs/auth/organizations.md ================================================ --- outline: [2, 3] --- # Organizations & Roles Organizations provide multi-tenant isolation. Each organization is a separate tenant with its own members, roles, and billing. Users can belong to multiple organizations and switch between them. ## Server Configuration The organization plugin is configured in `apps/api/lib/auth.ts`: ```ts organization({ allowUserToCreateOrganization: true, organizationLimit: 5, creatorRole: "owner", }), ``` | Setting | Value | Description | | ------------------------------- | --------- | ----------------------------------------- | | `allowUserToCreateOrganization` | `true` | Any user can create organizations | | `organizationLimit` | `5` | Max organizations per user | | `creatorRole` | `"owner"` | Creator automatically gets the owner role | ## Database Tables ### `organization` Defined in `db/schema/organization.ts`: | Column | Type | Description | | ------------------ | ------ | ------------------------------------- | | `id` | `text` | Prefixed CUID2 (`org_cm...`) | | `name` | `text` | Display name | | `slug` | `text` | URL-safe unique identifier | | `logo` | `text` | Logo URL (optional) | | `metadata` | `text` | JSON string for custom data | | `stripeCustomerId` | `text` | Stripe customer for org-level billing | ### `member` Links users to organizations with a role: | Column | Type | Description | | ---------------- | ------ | ----------------------------------- | | `id` | `text` | Prefixed CUID2 (`mem_cm...`) | | `userId` | `text` | References `user.id` | | `organizationId` | `text` | References `organization.id` | | `role` | `text` | `"owner"`, `"admin"`, or `"member"` | A unique constraint on `(userId, organizationId)` prevents duplicate memberships. ### `invitation` Manages pending invitations, defined in `db/schema/invitation.ts`: | Column | Type | Description | | ---------------- | ----------- | -------------------------------------------------------- | | `id` | `text` | Prefixed CUID2 (`inv_cm...`) | | `email` | `text` | Invitee's email address | | `inviterId` | `text` | References `user.id` | | `organizationId` | `text` | References `organization.id` | | `role` | `text` | Role assigned upon acceptance | | `status` | `text` | `"pending"`, `"accepted"`, `"rejected"`, or `"canceled"` | | `expiresAt` | `timestamp` | Invitation expiration | | `acceptedAt` | `timestamp` | When the invite was accepted | | `rejectedAt` | `timestamp` | When the invite was rejected or canceled | A unique constraint on `(organizationId, email)` prevents duplicate invitations to the same person. ## Roles Three built-in roles with hierarchical permissions: | Role | Can manage members | Can manage settings | Can delete org | | ---------- | ------------------ | ------------------- | -------------- | | **owner** | Yes | Yes | Yes | | **admin** | Yes | Yes | No | | **member** | No | No | No | ### Role Checks in API Procedures Use the session's `activeOrganizationId` with a membership query to check roles: ```ts // apps/api/routers/organization.ts const [row] = await ctx.db .select({ role: Db.member.role }) .from(Db.member) .where( and( eq(Db.member.organizationId, referenceId), eq(Db.member.userId, user.id), ), ); const isAdmin = row?.role === "owner" || row?.role === "admin"; ``` ## Active Organization The session tracks which organization is currently active via `activeOrganizationId`: ```ts export type AuthSession = SessionResponse["session"] & { activeOrganizationId?: string; }; ``` This field is stored in the `session` table and persists across requests. When the user switches organizations, Better Auth updates this field. ## Billing Integration Subscriptions scope to the active organization. The billing router uses `activeOrganizationId` as the billing reference, falling back to the user's own ID for personal billing: ```ts // apps/api/routers/billing.ts const referenceId = ctx.session.activeOrganizationId ?? ctx.user.id; ``` The Stripe plugin's `authorizeReference` hook enforces that only owners and admins can manage an organization's subscription: ```ts authorizeReference: async ({ user, referenceId }) => { if (referenceId === user.id) return true; // Personal billing const [row] = await db .select({ role: Db.member.role }) .from(Db.member) .where( and( eq(Db.member.organizationId, referenceId), eq(Db.member.userId, user.id), ), ); return row?.role === "owner" || row?.role === "admin"; }, ``` ## Invitation Lifecycle 1. **Owner/admin invites** – sends invitation to email with assigned role 2. **Invitation pending** – stored in `invitation` table with `status: "pending"` and an expiration 3. **Invitee accepts** – Better Auth creates a `member` record and updates invitation status 4. **Or invitee rejects / invitation expires** – invitation status is updated, no member created Each organization can only have one pending invitation per email address. ## Client API The `organizationClient()` plugin adds organization methods to the auth client: ```ts // Create an organization await auth.organization.create({ name: "Acme Inc", slug: "acme" }); // List user's organizations const { data } = await auth.organization.list(); // Set active organization await auth.organization.setActive({ organizationId: "org_cm..." }); // Invite a member await auth.organization.inviteMember({ email: "jane@example.com", role: "member", organizationId: "org_cm...", }); ``` See the [Better Auth organization plugin docs](https://www.better-auth.com/docs/plugins/organization) for the complete client API. ================================================ FILE: docs/auth/passkeys.md ================================================ --- outline: [2, 3] --- # Passkeys Passkey authentication uses the [WebAuthn](https://webauthn.io/) standard to let users sign in with biometrics (Touch ID, Face ID) or hardware security keys. It's the most secure sign-in method – no shared secrets leave the device. ::: info Passkeys are available for **login only**. Users must first create an account via email OTP or Google OAuth, then register a passkey from their account settings. The sign-up form does not show the passkey option. ::: ## Server Configuration The passkey plugin is configured in `apps/api/lib/auth.ts`: ```ts passkey({ rpID, // Domain name (e.g., "example.com" or "localhost") rpName: env.APP_NAME, // Human-readable name shown in browser prompts origin: env.APP_ORIGIN, }), ``` The `rpID` (Relying Party ID) is extracted from `APP_ORIGIN`: ```ts const appUrl = new URL(env.APP_ORIGIN); const rpID = appUrl.hostname; ``` This means passkeys are bound to the domain – a passkey registered on `example.com` won't work on `staging.example.com`. The `rpName` appears in the browser's passkey dialog (e.g., "Sign in to My App"). ### Database Table Passkey credentials are stored in `db/schema/passkey.ts`: | Column | Description | | -------------- | ------------------------------------------------------- | | `publicKey` | WebAuthn public key | | `credentialID` | Unique credential identifier | | `counter` | Signature counter (replay protection) | | `deviceType` | `"singleDevice"` or `"multiDevice"` | | `backedUp` | Whether the credential is synced across devices | | `transports` | Communication methods (USB, BLE, NFC, internal) | | `deviceName` | User-friendly label (e.g., "MacBook Pro") | | `platform` | `"platform"` (built-in) or `"cross-platform"` (USB key) | ## Client Component The `PasskeyLogin` component in `apps/app/components/auth/passkey-login.tsx` handles two modes: ### Explicit Login When the user clicks "Log in with passkey", the component checks for WebAuthn support and triggers the browser's credential picker: ```ts const handlePasskeyLogin = async () => { if (!window.PublicKeyCredential) { onError(authConfig.errors.passkeyNotSupported); return; } const result = await auth.signIn.passkey(); if (result.data) { onSuccess(); } else if (result.error) { const errorCode = "code" in result.error ? result.error.code : undefined; if (errorCode === "AUTH_CANCELLED") { onError("Passkey authentication was cancelled."); } else { onError(result.error.message || authConfig.errors.genericError); } } }; ``` ### Conditional UI (Autofill) When enabled, passkey autofill shows saved credentials in the browser's autocomplete dropdown – similar to how password managers work. This runs passively on mount: ```ts useEffect(() => { if (!authConfig.passkey.enableConditionalUI) return; const setupConditionalUI = async () => { if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return; const isAvailable = await window.PublicKeyCredential.isConditionalMediationAvailable(); if (!isAvailable) return; const result = await auth.signIn.passkey({ autoFill: true }); if (result.data && !aborted) { onSuccessRef.current(); } }; setupConditionalUI(); }, []); ``` Conditional UI is controlled by the `authConfig.passkey.enableConditionalUI` flag (default: `true`). Errors from conditional UI are silently ignored since the user hasn't explicitly requested authentication. ## Client Configuration Passkey behavior is configured in `apps/app/lib/auth-config.ts`: ```ts passkey: { enableConditionalUI: true, timeout: 60_000, // 60 seconds for user interaction userVerification: "preferred", }, ``` | Setting | Default | Description | | --------------------- | ------------- | ----------------------------------------------------------- | | `enableConditionalUI` | `true` | Show passkeys in browser autocomplete | | `timeout` | `60000` | Max time (ms) for user to interact with the WebAuthn dialog | | `userVerification` | `"preferred"` | Request biometric/PIN when available, but don't require it | ## Error Handling | Error | Cause | Behavior | | --------------------- | -------------------------------------------------- | ----------------------------- | | `AUTH_CANCELLED` | User dismissed the WebAuthn prompt or it timed out | Shows cancellation message | | `passkeyNotSupported` | `window.PublicKeyCredential` is undefined | Shows browser support message | | Network error | Offline or DNS failure | Shows network error message | | Server error | No passkey found, invalid credential | Shows server error message | ## Browser Support Passkeys require WebAuthn support. All modern browsers support it: - Chrome 67+, Edge 18+, Firefox 60+, Safari 13+ - iOS 16+ (synced via iCloud Keychain) - Android 9+ (synced via Google Password Manager) The component checks `window.PublicKeyCredential` before attempting authentication and shows a clear message on unsupported browsers. ================================================ FILE: docs/auth/sessions.md ================================================ --- outline: [2, 3] --- # Sessions & Protected Routes Session state is managed exclusively through TanStack Query. The auth client fetches session data, TanStack Query caches it, and route guards use the cache to protect pages – no direct `auth.useSession()` calls or local storage. ## Session Query The session query is defined in `apps/app/lib/queries/session.ts`: ```ts export function sessionQueryOptions() { return queryOptions({ queryKey: ["auth", "session"], queryFn: async () => { const response = await auth.getSession(); if (response.error) throw response.error; return response.data; }, staleTime: 30_000, // 30 seconds retry(failureCount, error) { const status = getErrorStatus(error); if (status === 401 || status === 403) return false; return failureCount < 3; }, }); } ``` Key behaviors: - Returns `null` when unauthenticated (not an error) - 30-second stale time keeps auth state current without excessive requests - 401/403 errors are not retried – retrying won't help for auth failures - Inherits global `gcTime`, `refetchOnWindowFocus`, and `refetchOnReconnect` from QueryClient defaults ### Session Data Shape ```ts interface SessionData { user: User; // id, name, email, emailVerified, image, ... session: Session; // id, token, expiresAt, activeOrganizationId, ... } ``` Both `user` and `session` must be present for valid auth state. Partial data (only user, only session) is treated as unauthenticated. ### Reading Session Data ```ts // In components – triggers fetch if stale const { data } = useSessionQuery(); // With Suspense const { data } = useSuspenseSessionQuery(); // Sync check of cache only – no network request const session = getCachedSession(queryClient); const loggedIn = isAuthenticated(queryClient); ``` ## Protected Route Guard The `(app)/route.tsx` layout route protects all app pages with a cache-first auth check: ```ts // apps/app/routes/(app)/route.tsx export const Route = createFileRoute("/(app)")({ beforeLoad: async ({ context, location }) => { // Check cache first – instant navigation if session is cached let session = getCachedSession(context.queryClient); // Fetch only if cache is empty (first load or after cache clear) if (session === undefined) { session = await context.queryClient.fetchQuery(sessionQueryOptions()); } if (!session?.user || !session?.session) { throw redirect({ to: "/login", search: { returnTo: location.href }, }); } return { user: session.user, session }; }, component: AppLayout, }); ``` This pattern means: - **Cached session** → navigation is instant (no network request) - **No cache** → fetches session, redirects to `/login` if unauthenticated - **`returnTo`** → preserves the original URL so users land back after login The session data is returned from `beforeLoad` and available to all child routes via `Route.useRouteContext()`. ## Login Page The login route (`(auth)/login.tsx`) handles the inverse – redirecting authenticated users away: ```ts // apps/app/routes/(auth)/login.tsx export const Route = createFileRoute("/(auth)/login")({ validateSearch: searchSchema, beforeLoad: async ({ context, search }) => { try { const session = await context.queryClient.fetchQuery( sessionQueryOptions(), ); if (session?.user && session?.session) { throw redirect({ to: search.returnTo ?? "/" }); } } catch (error) { if (isRedirect(error)) throw error; // Fetch errors → show login form } }, }); ``` After successful authentication, the login page revalidates the session and navigates: ```ts async function handleSuccess() { await revalidateSession(queryClient, router); await router.navigate({ to: search.returnTo ?? "/" }); } ``` `revalidateSession` removes the cached session (forcing a fresh fetch) and invalidates the router so `beforeLoad` re-runs with new data. ## Sign Out The `signOut` function clears the server session, updates the cache, and performs a hard redirect: ```ts // apps/app/lib/queries/session.ts export async function signOut( queryClient: QueryClient, options?: { redirect?: boolean }, ) { try { await auth.signOut(); } finally { queryClient.setQueryData(sessionQueryKey, null); if (options?.redirect !== false) { window.location.href = "/login"; } } } ``` The hard redirect (`window.location.href`) resets all in-memory state – Jotai atoms, component state, TanStack Query cache – ensuring a clean slate between user sessions. Pass `{ redirect: false }` for programmatic sign-out without navigation. `setQueryData(null)` is used instead of `invalidateQueries` to avoid a wasted refetch of a session that no longer exists. ## Auth Error Boundary The `AuthErrorBoundary` wraps protected route layouts and catches authentication errors that occur during rendering (e.g., a tRPC call returns 401): ```ts // apps/app/components/auth/auth-error-boundary.tsx export function AuthErrorBoundary({ children }) { return ( { if (isUnauthenticatedError(error)) { queryClient.removeQueries({ queryKey: sessionQueryKey }); } }} > {children} ); } ``` The fallback UI shows two options: - **Try Again** – resets the error boundary and refetches the session - **Sign In** – clears session cache and redirects to `/login` with `returnTo` Auth errors (401) get the auth-specific fallback. Other errors (500, network) get a generic error fallback with a retry button. ## Auth Hint Cookie The API worker manages a lightweight routing cookie alongside the session. On sign-in, it sets `__Host-auth=1` (HTTPS) or `auth=1` (HTTP dev). On sign-out or invalid session, it clears it. The web edge worker reads this cookie to decide how to route `/`: ```ts // apps/web/worker.ts const hasAuthHint = getCookie(c, "__Host-auth") === "1" || getCookie(c, "auth") === "1"; const upstream = hasAuthHint ? c.env.APP_SERVICE : c.env.ASSETS; ``` This cookie is **not a security boundary** – it's a performance optimization. False positives (stale cookie after session expiry) cause one extra redirect to `/login`. The app worker is always the authority for session validation. The cookie lifecycle is managed by Better Auth hooks: | Event | Action | | ------------------------------------- | ------------------ | | New session (sign-in, sign-up, OAuth) | Set cookie | | Sign-out | Clear cookie | | Session check with no valid session | Clear stale cookie | See [ADR-001](/adr/001-auth-hint-cookie) for the design rationale. ================================================ FILE: docs/auth/social-providers.md ================================================ --- outline: [2, 3] --- # Social Providers Google OAuth is configured out of the box. The flow redirects users to Google's consent screen, then back to your app where Better Auth creates or links the account. ## Server Configuration Google OAuth credentials are set in `apps/api/lib/auth.ts`: ```ts socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, }, }, ``` ### Setting Up Google OAuth 1. Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) 2. Create an OAuth 2.0 Client ID (Web application type) 3. Add authorized redirect URI: `https://your-domain.com/api/auth/callback/google` - For local development: `http://localhost:5173/api/auth/callback/google` 4. Copy the client ID and secret to your `.env.local`: ```sh GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret ``` ## Client Component The `GoogleLogin` component in `apps/app/components/auth/google-login.tsx` handles the OAuth redirect: ```ts const handleGoogleLogin = async () => { // Clear stale session before OAuth redirect queryClient.removeQueries({ queryKey: sessionQueryKey }); // OAuth redirects to /login which validates session and redirects to returnTo const callbackURL = returnTo ? `/login?returnTo=${encodeURIComponent(returnTo)}` : "/login"; const result = await auth.signIn.social({ provider: "google", callbackURL, }); }; ``` The flow works as follows: 1. User clicks "Continue with Google" 2. Stale session cache is cleared (prevents showing old data after redirect) 3. `auth.signIn.social()` redirects to Google's consent screen 4. After consent, Google redirects back to `/api/auth/callback/google` 5. Better Auth creates/links the account and sets the session cookie 6. The callback redirects to `callbackURL` (`/login?returnTo=...`) 7. The login page detects the active session and redirects to `returnTo` ### Preserving Return URL The `returnTo` parameter survives the OAuth round-trip by being encoded into the `callbackURL`. When the user lands back on `/login`, the search params schema validates and sanitizes the URL: ```ts const searchSchema = z.object({ returnTo: z .string() .optional() .transform((val) => { const safe = getSafeRedirectUrl(val); return safe === "/" ? undefined : safe; }) .catch(undefined), }); ``` Only same-origin relative paths are accepted – absolute URLs and protocol-relative URLs (`//evil.com`) are rejected. ## Adding Another Provider Better Auth supports [30+ OAuth providers](https://www.better-auth.com/docs/concepts/oauth). To add one: **1. Add server config** in `apps/api/lib/auth.ts`: ```ts socialProviders: { google: { ... }, github: { // [!code ++] clientId: env.GITHUB_CLIENT_ID, // [!code ++] clientSecret: env.GITHUB_CLIENT_SECRET, // [!code ++] }, // [!code ++] }, ``` **2. Add env vars** to `apps/api/lib/env.ts` and your `.env.local`. **3. Update the providers list** in `apps/app/lib/auth-config.ts`: ```ts oauth: { providers: ["google", "github"] as const, // [!code ++] }, ``` **4. Create a login button component** following the `GoogleLogin` pattern – clear session cache, call `auth.signIn.social({ provider: "github" })`, handle errors. **5. Add the button** to the `MethodSelection` component in `auth-form.tsx`. ================================================ FILE: docs/billing/checkout.md ================================================ --- outline: [2, 3] --- # Checkout Flow Upgrades and subscription management use Stripe's hosted pages – Stripe Checkout for new subscriptions and the Customer Portal for changes. No Stripe.js client dependency is needed. ## Upgrade Flow The auth client handles the redirect to Stripe Checkout: ```ts // apps/app/routes/(app)/settings.tsx async function handleUpgrade(plan: "starter" | "pro") { await auth.subscription.upgrade({ plan, successUrl: returnUrl, cancelUrl: returnUrl, }); } ``` `auth.subscription.upgrade()` calls the Better Auth endpoint, which creates a Stripe Checkout session and redirects the browser. After payment, Stripe redirects back to `successUrl`. The subscription is activated asynchronously via [webhook](./webhooks). For the Pro plan, if `STRIPE_PRO_ANNUAL_PRICE_ID` is configured, Stripe Checkout shows both monthly and annual options automatically. ## Customer Portal Existing subscribers manage their subscription (cancel, change payment method, switch plans) through Stripe's hosted portal: ```ts // apps/app/routes/(app)/settings.tsx async function handleManageBilling() { await auth.subscription.billingPortal({ returnUrl }); } ``` Configure the portal appearance and allowed actions in the [Stripe Dashboard → Customer Portal settings](https://dashboard.stripe.com/settings/billing/portal). ## Authorization The plugin's `authorizeReference` callback controls who can manage billing: | Context | Who can upgrade/manage | | ------------------------ | ---------------------- | | Personal (no active org) | The user themselves | | Organization | Org owner or admin | ```ts // apps/api/lib/auth.ts authorizeReference: async ({ user, referenceId }) => { // Personal billing if (referenceId === user.id) return true; // Org billing: check membership role const [row] = await db .select({ role: Db.member.role }) .from(Db.member) .where( and( eq(Db.member.organizationId, referenceId), eq(Db.member.userId, user.id), ), ); return row?.role === "owner" || row?.role === "admin"; }, ``` Regular org members see the billing status but cannot modify the subscription. ## Billing UI The `BillingCard` component in `apps/app/routes/(app)/settings.tsx` handles all billing states: | State | UI | | --------------- | -------------------------------------------------------------- | | Loading | Muted loading text | | Free plan | "You are on the Free plan" + upgrade buttons | | Active/trialing | Plan name, status badge, renewal date, "Manage Billing" button | | Canceling | Amber warning with access end date, portal link to restore | ## Data Fetching Billing state is fetched via a tRPC query wrapped in TanStack Query: ```ts // apps/app/lib/queries/billing.ts export function billingQueryOptions(activeOrgId?: string | null) { return queryOptions({ queryKey: [...billingQueryKey, activeOrgId ?? null], queryFn: () => trpcClient.billing.subscription.query(), }); } ``` The query key includes `activeOrgId` so switching organizations automatically triggers a refetch. Use the `billingQueryKey` prefix for bulk invalidation after subscription changes. ================================================ FILE: docs/billing/index.md ================================================ --- outline: [2, 3] --- # Billing Stripe subscriptions are integrated via the [`@better-auth/stripe`](https://www.better-auth.com/docs/plugins/stripe) plugin. The auth system manages the full subscription lifecycle – customer creation, checkout, webhooks, and status tracking – so billing state lives alongside sessions and organizations in the same database. Billing is **optional** – without the `STRIPE_*` environment variables the app works normally; billing endpoints return 404 and the UI falls back to the free plan. ## What's Included | Feature | Implementation | | --------------------------------------- | ------------------------------------------- | | Three-tier plans (Free / Starter / Pro) | Config in `apps/api/lib/plans.ts` | | Stripe hosted checkout | `auth.subscription.upgrade()` client method | | Customer portal (cancel, change card) | `auth.subscription.billingPortal()` | | Org-level and personal billing | `referenceId` derived from session | | Webhook-driven status sync | Plugin-managed endpoint | | 14-day free trial on Pro | `freeTrial: { days: 14 }` in plan config | | Annual discount pricing | `annualDiscountPriceId` on Pro plan | ## Architecture ```text ┌─────────────┐ POST /api/auth/subscription/upgrade ┌───────────────┐ │ Browser │ ──────────────────────────────────────────→ │ API Worker │ │ (app) │ │ (Hono) │ │ │ ←── 302 redirect │ │ │ │──→ Stripe Checkout (hosted) │ Better Auth │ │ │ │ + stripe() │ │ │ POST /api/auth/stripe/webhook │ plugin │ │ │ Stripe ────────→│ webhook ──→ │ │ │ │ update DB │ │ │ GET /api/trpc/billing.subscription │ │ │ │ ──────────────────────────────────────────→ │ tRPC router │ └─────────────┘ ←── subscription data (TanStack Query) └───────────────┘ ``` 1. User clicks **Upgrade** – auth client calls `auth.subscription.upgrade()` 2. Plugin creates a Stripe Checkout session – redirects browser to Stripe 3. User completes payment – Stripe sends webhook to `/api/auth/stripe/webhook` 4. Plugin verifies signature, updates `subscription` table 5. Client refetches billing state via tRPC + TanStack Query Mutations (upgrade, portal) go through the auth client because the plugin handles Stripe API calls, session validation, and org authorization internally. Reads go through tRPC to benefit from TanStack Query caching and org-aware cache keys. ## Billing Reference Billing is tied to `session.activeOrganizationId` when present; otherwise falls back to `user.id` for personal use. One active subscription per reference ID. | Context | `referenceId` | Who can manage | | ------------------- | ---------------------- | -------------- | | Organization active | `activeOrganizationId` | Owner or admin | | No organization | `user.id` | The user | The server derives `referenceId` from the session – no client-side parameter needed. The billing query key includes `activeOrgId`, so switching organizations refetches automatically. ## Plans Three tiers with enforced member limits: | Plan | Members | Trial | Price ID env var | | ------- | ------- | ------- | ------------------------- | | Free | 1 | – | – | | Starter | 5 | – | `STRIPE_STARTER_PRICE_ID` | | Pro | 50 | 14 days | `STRIPE_PRO_PRICE_ID` | See [Plans & Pricing](./plans) for configuration details. ## Environment Variables | Variable | Required | Description | | ---------------------------- | ----------- | ------------------------------------------------- | | `STRIPE_SECRET_KEY` | For billing | Stripe secret key (`sk_test_...` / `sk_live_...`) | | `STRIPE_WEBHOOK_SECRET` | For billing | Webhook signing secret (`whsec_...`) | | `STRIPE_STARTER_PRICE_ID` | For billing | Stripe price ID for Starter plan (`price_...`) | | `STRIPE_PRO_PRICE_ID` | For billing | Stripe price ID for Pro plan (`price_...`) | | `STRIPE_PRO_ANNUAL_PRICE_ID` | Optional | Annual discount price for Pro plan (`price_...`) | Set in `.env.local` for development, Cloudflare secrets for staging/production. See [Environment Variables](/getting-started/environment-variables). ## File Map | Layer | Files | | ------ | ------------------------------------------------------------------------------------------ | | Schema | `db/schema/subscription.ts`, `stripeCustomerId` on user + organization tables | | Server | `apps/api/lib/plans.ts`, `apps/api/lib/stripe.ts`, stripe plugin in `apps/api/lib/auth.ts` | | Router | `apps/api/routers/billing.ts` | | Client | `stripeClient` in `apps/app/lib/auth.ts`, `apps/app/lib/queries/billing.ts` | | UI | Billing card in `apps/app/routes/(app)/settings.tsx` | ================================================ FILE: docs/billing/plans.md ================================================ --- outline: [2, 3] --- # Plans & Pricing Plan limits are defined once in `apps/api/lib/plans.ts` and referenced by the auth plugin config (plan definitions) and the tRPC billing router (query responses). ## Plan Limits ```ts // apps/api/lib/plans.ts export const planLimits = { free: { members: 1 }, starter: { members: 5 }, pro: { members: 50 }, } as const; ``` This is the single source of truth for what each plan includes. Add new limit fields here – they'll automatically flow to both the auth plugin and tRPC responses. ## Auth Plugin Configuration Plans are registered with the `@better-auth/stripe` plugin in `apps/api/lib/auth.ts`: ```ts // apps/api/lib/auth.ts (stripe plugin config) stripe({ stripeClient: createStripeClient(env), stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "starter", priceId: env.STRIPE_STARTER_PRICE_ID, limits: planLimits.starter, }, { name: "pro", priceId: env.STRIPE_PRO_PRICE_ID, annualDiscountPriceId: env.STRIPE_PRO_ANNUAL_PRICE_ID, limits: planLimits.pro, freeTrial: { days: 14 }, }, ], }, }); ``` The free tier has no Stripe plan – users without an active subscription are treated as free. The `limits` objects are stored on the Stripe subscription metadata and returned by the plugin. ## Stripe Dashboard Setup For each paid plan, create a **Product** and **Price** in the [Stripe Dashboard](https://dashboard.stripe.com/products): 1. Create a product (e.g., "Starter Plan") 2. Add a recurring price (e.g., $9/month) 3. Copy the price ID (`price_...`) to the corresponding environment variable | Plan | Environment variable | Product example | | ------------- | ---------------------------- | ------------------------- | | Starter | `STRIPE_STARTER_PRICE_ID` | "Starter Plan" – $9/month | | Pro (monthly) | `STRIPE_PRO_PRICE_ID` | "Pro Plan" – $29/month | | Pro (annual) | `STRIPE_PRO_ANNUAL_PRICE_ID` | "Pro Plan" – $290/year | ::: info Use Stripe **test mode** during development. The price IDs are different between test and live modes. ::: ## How Limits Are Exposed The `billing.subscription` tRPC procedure returns the current plan and its limits: ```ts // apps/api/routers/billing.ts const sub = await ctx.db.query.subscription.findFirst({ where: (s, { eq, and, inArray }) => and( eq(s.referenceId, referenceId), inArray(s.status, ["active", "trialing"]), ), }); return { plan, status: sub?.status ?? null, limits: planLimits[plan as PlanName], // ... }; ``` When no active subscription exists, it defaults to the `free` plan limits. Enforce limits in your application logic – tRPC middleware for server-side checks, UI guards for client-side gating. ## Adding or Modifying Plans 1. **Update limits** – edit `planLimits` in `apps/api/lib/plans.ts` 2. **Update auth config** – add/edit the plan entry in `apps/api/lib/auth.ts` 3. **Create Stripe product** – add the product and price in the Stripe Dashboard 4. **Set env var** – add the new `STRIPE_*_PRICE_ID` to `.env.local` and Cloudflare secrets 5. **Update UI** – add the plan option to the billing card in `apps/app/routes/(app)/settings.tsx` ================================================ FILE: docs/billing/webhooks.md ================================================ --- outline: [2, 3] --- # Webhooks The `@better-auth/stripe` plugin registers a webhook endpoint at `POST /api/auth/stripe/webhook` and handles signature verification, event parsing, and database updates automatically. ## Events Handled | Stripe Event | Plugin Action | | ------------------------------- | --------------------------------------------------- | | `checkout.session.completed` | Activates the subscription | | `customer.subscription.created` | Records a new subscription | | `customer.subscription.updated` | Syncs status, period dates, cancellation scheduling | | `customer.subscription.deleted` | Marks the subscription as canceled | The plugin updates the `subscription` table in the database – no manual event handling code is needed. ## Stripe Dashboard Configuration Register the webhook endpoint in [Stripe Dashboard → Webhooks](https://dashboard.stripe.com/webhooks): | Field | Value | | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | | Endpoint URL | `https:///api/auth/stripe/webhook` | | Events | `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted` | Copy the signing secret (`whsec_...`) to `STRIPE_WEBHOOK_SECRET`. ## Local Development Use the [Stripe CLI](https://docs.stripe.com/stripe-cli) to forward webhook events to your local dev server: ```bash stripe listen --forward-to localhost:5173/api/auth/stripe/webhook ``` The CLI prints a webhook signing secret (`whsec_...`) – copy it to your `.env.local`: ```ini STRIPE_WEBHOOK_SECRET=whsec_... ``` ::: warning The local signing secret changes each time you restart `stripe listen`. Update `.env.local` and restart the dev server if webhook verification fails. ::: ## Raw Body Handling Stripe webhook verification requires the raw (unparsed) request body. The plugin reads the body via `request.text()` before Hono's body parser runs, so no special middleware configuration is needed. ## Production Setup Store the webhook secret as a Cloudflare Worker secret: ```bash wrangler secret put STRIPE_WEBHOOK_SECRET ``` After deploying, send a test event from the Stripe Dashboard to verify the endpoint is reachable and the signature validates correctly. ================================================ FILE: docs/database/index.md ================================================ # Database The `db/` workspace manages the data layer with [Drizzle ORM](https://orm.drizzle.team/) and [Neon PostgreSQL](https://neon.tech/). In production, [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) pools and caches connections at the edge. ## Workspace Structure ```bash db/ ├── schema/ # Table definitions and relations ├── migrations/ # Auto-generated SQL migrations ├── seeds/ # Seed data scripts ├── scripts/ # Utilities (seed runner, export) ├── drizzle.config.ts # Drizzle Kit configuration └── index.ts # Re-exports schema + DatabaseSchema type ``` Schema files are organized by domain – one file per entity group (e.g., `user.ts` contains the user, session, identity, and verification tables). All tables are re-exported from `schema/index.ts`. ## Connection Architecture The API worker connects to Neon through Cloudflare Hyperdrive, which provides connection pooling and optional query caching at the edge. Two Hyperdrive bindings are available: | Binding | Cache | Use for | | ------------------- | ---------------- | ------------------------------------------------------- | | `HYPERDRIVE_CACHED` | 60 s query cache | Read-heavy queries where slight staleness is acceptable | | `HYPERDRIVE_DIRECT` | None | Writes, real-time reads, anything requiring fresh data | Both are exposed in [tRPC context](/api/context) as `ctx.db` (cached) and `ctx.dbDirect` (direct): ```ts // apps/api/lib/db.ts (simplified) export function createDb(hyperdrive: Hyperdrive) { const client = postgres(hyperdrive.connectionString, { max: 1, // single connection per Worker isolate prepare: false, // required for Hyperdrive compatibility }); return drizzle(client, { schema, casing: "snake_case" }); } ``` ::: info In development, Wrangler's `getPlatformProxy()` emulates the Hyperdrive bindings locally, resolving them to your `DATABASE_URL`. Your code uses the same `HYPERDRIVE_CACHED` / `HYPERDRIVE_DIRECT` bindings in both environments – no conditional connection logic needed. ::: ## Commands Run from the repo root. Append `:staging` or `:prod` to target other environments. | Command | Description | | ------------------ | --------------------------------------------------- | | `bun db:generate` | Generate migration SQL from schema changes | | `bun db:migrate` | Apply pending migrations | | `bun db:push` | Push schema directly (skips migration files) | | `bun db:studio` | Open Drizzle Studio browser UI | | `bun db:seed` | Run seed scripts | | `bun db:check` | Check for drift between schema and migrations | | `bun db:export` | Export database via pg_dump to `db/backups/` | | `bun db:typecheck` | Run TypeScript type-checking on the `db/` workspace | ## Environment Targeting Database scripts select the environment through the `ENVIRONMENT` variable (falls back to `NODE_ENV`). Each environment loads env files in priority order: ``` .env.{env}.local → .env.local → .env ``` For example, `bun db:push:staging` loads `.env.staging.local` first. The `DATABASE_URL` variable must be a valid `postgres://` or `postgresql://` connection string. See [Environment Variables](/getting-started/environment-variables) for full details. ## Importing Schemas The `@repo/db` package exports two entry points: ```ts import * as schema from "@repo/db"; // full schema + DatabaseSchema type import { user, session } from "@repo/db/schema"; // individual tables ``` ================================================ FILE: docs/database/migrations.md ================================================ # Migrations Drizzle Kit generates SQL migrations by diffing your TypeScript schema against the latest snapshot. Migration files live in `db/migrations/` alongside a journal that tracks applied versions. ## Workflow **1. Edit the schema** in `db/schema/`. **2. Generate a migration:** ```bash bun db:generate ``` This produces a numbered SQL file (e.g., `0001_add_product_table.sql`) in `db/migrations/`. **3. Review the generated SQL.** Drizzle Kit's output is generally correct, but always check for destructive operations – column drops, type changes, or data loss. **4. Apply the migration:** ```bash bun db:migrate ``` **5. Verify in Drizzle Studio:** ```bash bun db:studio ``` ## Push vs Migrate | Command | What it does | Use when | | ---------------- | ----------------------------------------- | ------------------------------------- | | `bun db:migrate` | Applies pending migration files in order | Production, staging, shared databases | | `bun db:push` | Syncs schema directly, no migration files | Local development, rapid prototyping | `push` is faster during development since it skips migration file generation. Switch to `migrate` when you need reproducible, reviewable changes. ## Targeting Environments Append `:staging` or `:prod` to run against other databases: ```bash bun db:migrate:staging bun db:migrate:prod ``` These set `ENVIRONMENT` internally, which controls which `.env.{env}.local` file is loaded. Double-check the target before running migrations against production. ## Drift Detection If schema files and migration snapshots diverge (e.g., after a manual DB change or a merge conflict), run: ```bash bun db:check ``` This reports discrepancies between your TypeScript schema and the migration history. Resolve by either updating the schema to match or generating a new migration to cover the gap. ## Tips - **Name your migrations** – `bun db:generate --name add-product-table` produces clearer filenames than auto-numbered defaults. - **One concern per migration** – avoid bundling unrelated schema changes. Smaller migrations are easier to review and roll back. - **Never edit applied migrations** – if a migration has already run in staging or production, create a new migration to correct issues. - **Review before applying** – `db:generate` writes SQL to disk. Read the file before running `db:migrate`. ================================================ FILE: docs/database/queries.md ================================================ --- outline: [2, 3] --- # Query Patterns Common patterns for querying the database in tRPC procedures. All examples use Drizzle ORM's relational query API and assume access to `ctx.db` from [tRPC context](/api/context). ## Multi-tenant Queries Every query that returns user data must be scoped to the current organization. The active organization ID is available on the session: ```ts const products = await ctx.db.query.product.findMany({ where: eq(product.organizationId, ctx.session.activeOrganizationId), }); ``` ::: warning Forgetting the organization filter leaks data across tenants. Treat this as a security invariant – every table with an `organizationId` column must filter by it. ::: ## Relations Drizzle's `with` clause loads related records in a single query: ```ts const org = await ctx.db.query.organization.findFirst({ where: eq(organization.id, orgId), with: { members: { with: { user: true }, }, }, }); ``` Select only the columns you need to reduce payload size: ```ts const products = await ctx.db.query.product.findMany({ where: eq(product.organizationId, orgId), columns: { id: true, name: true, price: true }, with: { creator: { columns: { id: true, name: true }, }, }, }); ``` ## DataLoader Pattern The API uses a [DataLoader](https://github.com/graphql/dataloader) pattern to batch lookups and prevent N+1 queries. Loaders are defined with `defineLoader` and cached per-request in `ctx.cache`: ```ts // apps/api/lib/loaders.ts (simplified) export const userById = defineLoader( Symbol("userById"), async (ctx, ids: readonly string[]) => { const users = await ctx.db .select() .from(user) .where(inArray(user.id, [...ids])); return mapByKey(users, "id", ids); }, ); ``` Use loaders when a procedure needs to fetch the same entity type for multiple IDs: ```ts const creator = await userById(ctx).load(product.createdBy); ``` See [Context & Middleware – DataLoaders](/api/context#dataloaders) for the full pattern and how to add new loaders. ## Access Control Verify organization membership before returning data: ```ts const membership = await ctx.db.query.member.findFirst({ where: and(eq(member.userId, ctx.user.id), eq(member.organizationId, orgId)), }); if (!membership) { throw new TRPCError({ code: "FORBIDDEN" }); } ``` Check roles for privileged operations: ```ts if (membership.role !== "owner" && membership.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } ``` ## Design Patterns ### Multi-tenant Data Isolation Every domain table should reference an organization with cascade delete: ```ts export const yourTable = pgTable("your_table", { id: text() .primaryKey() .$defaultFn(() => generateId("xxx")), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), // ... }); ``` ### Soft Deletes When you need to preserve records for auditing: ```ts // Schema deletedAt: timestamp({ withTimezone: true, mode: "date" }), // Query – exclude soft-deleted records const active = await ctx.db.query.product.findMany({ where: and( eq(product.organizationId, orgId), isNull(product.deletedAt), ), }); // Soft delete await ctx.db .update(product) .set({ deletedAt: new Date() }) .where(eq(product.id, productId)); ``` ### Audit Fields Track who created and modified records: ```ts createdBy: text().references(() => user.id), updatedBy: text().references(() => user.id), ``` ### Batch Inserts Use array values for bulk operations: ```ts await ctx.db.insert(product).values([ { name: "Product A", price: 1000, organizationId: orgId }, { name: "Product B", price: 2000, organizationId: orgId }, ]); ``` ================================================ FILE: docs/database/schema.md ================================================ --- outline: [2, 3] --- # Schema The database schema lives in `db/schema/`, with one file per entity group. Drizzle ORM's `casing: "snake_case"` option maps camelCase TypeScript properties to snake_case database columns automatically. ## Conventions **Primary keys** – All tables use application-generated prefixed CUID2 IDs (e.g., `usr_ght4k2jxm7pqbv01`). The 3-character prefix encodes the entity type for recognition in logs, URLs, and support tickets. | Model | Prefix | Table | | ------------ | ------ | -------------- | | user | `usr` | `user` | | session | `ses` | `session` | | account | `idn` | `identity` | | verification | `vfy` | `verification` | | organization | `org` | `organization` | | member | `mem` | `member` | | invitation | `inv` | `invitation` | | passkey | `pky` | `passkey` | | subscription | `sub` | `subscription` | IDs are generated at the application level via `$defaultFn()` – no database sequences or UUID functions. See `db/schema/id.ts` for the implementation and [Prefixed CUID2 IDs](/specs/prefixed-ids) for design rationale. **Timestamps** – Every table has `createdAt` and `updatedAt` columns using `timestamp({ withTimezone: true, mode: "date" })`. `createdAt` defaults to `now()`; `updatedAt` auto-updates via `$onUpdate(() => new Date())`. **Foreign keys** – All FKs use `onDelete: "cascade"`. Every FK column gets a btree index named `{table}_{column}_idx`. **No enums** – `member.role` and `invitation.status` are plain `text` columns, not `pgEnum`. This avoids fragile coupling with Better Auth's role values. ## Entity Relationship Diagram ```mermaid erDiagram user ||--o{ session : "has" user ||--o{ identity : "authenticates with" user ||--o{ passkey : "registers" user ||--o{ member : "belongs to" user ||--o{ invitation : "invited by" user ||--o{ subscription : "subscribes" organization ||--o{ member : "has members" organization ||--o{ invitation : "receives" organization ||--o{ subscription : "subscribes" user { text id PK "usr_..." text name text email UK boolean email_verified text image boolean is_anonymous text stripe_customer_id } session { text id PK "ses_..." timestamp expires_at text token UK text ip_address text user_agent text user_id FK text active_organization_id } identity { text id PK "idn_..." text account_id text provider_id text user_id FK text access_token text refresh_token text id_token timestamp access_token_expires_at timestamp refresh_token_expires_at text scope text password } verification { text id PK "vfy_..." text identifier text value timestamp expires_at } passkey { text id PK "pky_..." text name text public_key text credential_id UK text user_id FK integer counter text device_type boolean backed_up text transports text aaguid timestamp last_used_at text device_name text platform } organization { text id PK "org_..." text name text slug UK text logo text metadata text stripe_customer_id } member { text id PK "mem_..." text user_id FK text organization_id FK text role } invitation { text id PK "inv_..." text email text inviter_id FK text organization_id FK text role text status timestamp expires_at timestamp accepted_at timestamp rejected_at } subscription { text id PK "sub_..." text plan text reference_id text stripe_customer_id text stripe_subscription_id UK text status timestamp period_start timestamp period_end timestamp trial_start timestamp trial_end boolean cancel_at_period_end integer seats text billing_interval } ``` ## Table Groups ### Authentication Tables Managed by [Better Auth](https://www.better-auth.com/docs/concepts/database). Extend with care – changes must stay compatible with the auth framework. | Table | File | Purpose | | -------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- | | `user` | `schema/user.ts` | User accounts – name, email, verification status, Stripe customer ID | | `session` | `schema/user.ts` | Active sessions with device tracking and [active organization context](/auth/sessions) | | `identity` | `schema/user.ts` | OAuth credentials and email/password (Better Auth's `account` table, [renamed](/auth/#identity-table-rename)) | | `verification` | `schema/user.ts` | OTP codes, email verification tokens | | `passkey` | `schema/passkey.ts` | WebAuthn credentials for [passwordless auth](/auth/passkeys) | ::: warning Authentication tables follow [Better Auth's schema requirements](https://www.better-auth.com/docs/concepts/database). When adding columns, register them in the auth config's `additionalFields` to ensure proper data handling. ::: ::: details user table – TypeScript definition ```ts // db/schema/user.ts export const user = pgTable("user", { id: text() .primaryKey() .$defaultFn(() => generateAuthId("user")), name: text().notNull(), email: text().notNull().unique(), emailVerified: boolean().default(false).notNull(), image: text(), isAnonymous: boolean().default(false).notNull(), stripeCustomerId: text(), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }); ``` ::: ### Organization Tables Multi-tenancy via Better Auth's [organization plugin](https://www.better-auth.com/docs/plugins/organization). | Table | File | Purpose | | -------------- | ------------------------ | ---------------------------------------------------------------- | | `organization` | `schema/organization.ts` | Tenants / workspaces – name, slug, logo, metadata | | `member` | `schema/organization.ts` | User ↔ organization membership with roles (owner, admin, member) | | `invitation` | `schema/invitation.ts` | Pending org invitations with status lifecycle | Key constraints: - `member(userId, organizationId)` is unique – one membership per user per org - `invitation(organizationId, email)` is unique – one pending invite per email per org - `session.activeOrganizationId` has an index but no FK constraint (Better Auth design) - `organization.metadata` is `text`, not JSONB – Better Auth serializes it as a string ### Billing Tables Managed by the [`@better-auth/stripe`](https://www.better-auth.com/docs/plugins/stripe) plugin. Do not insert or update records manually – the plugin handles the subscription lifecycle via Stripe webhooks. | Table | File | Purpose | | -------------- | ------------------------ | ----------------------------------------------- | | `subscription` | `schema/subscription.ts` | Stripe subscription state, plan, billing period | The `referenceId` column is polymorphic: it points to `user.id` for personal billing or `organization.id` for org-level billing. ## Extended Fields Several tables include columns beyond Better Auth's defaults: - **passkey:** `lastUsedAt` (security audits), `deviceName` (user-friendly label like "MacBook Pro"), `platform` ("platform" or "cross-platform") - **invitation:** `acceptedAt` / `rejectedAt` lifecycle timestamps ## Adding a New Table **1. Create a schema file** in `db/schema/`: ```ts // db/schema/product.ts import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { generateId } from "./id"; import { organization } from "./organization"; import { user } from "./user"; export const product = pgTable("product", { id: text() .primaryKey() .$defaultFn(() => generateId("prd")), name: text().notNull(), description: text(), price: integer().notNull(), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdBy: text() .notNull() .references(() => user.id), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }); export const productRelations = relations(product, ({ one }) => ({ organization: one(organization, { fields: [product.organizationId], references: [organization.id], }), creator: one(user, { fields: [product.createdBy], references: [user.id], }), })); ``` **2. Export from the barrel file:** ```ts // db/schema/index.ts export * from "./product"; // [!code ++] ``` **3. Generate and apply the migration:** ```bash bun db:generate bun db:migrate ``` See [Migrations](./migrations) for the full workflow. ## Extending Auth Tables To add custom columns to authentication tables, update both the Drizzle schema and the Better Auth config: ```ts // db/schema/user.ts – add the column export const user = pgTable("user", { // ... existing fields ... phoneNumber: text(), // [!code ++] }); ``` ```ts // apps/api/lib/auth.ts – register with Better Auth betterAuth({ user: { additionalFields: { phoneNumber: { type: "string", required: false }, // [!code ++] }, }, }); ``` Then [generate and apply migrations](./migrations) as usual. ================================================ FILE: docs/database/seeding.md ================================================ # Seeding Seed scripts populate your database with test data for development. They live in `db/seeds/` and are orchestrated by `db/scripts/seed.ts`. ## Running Seeds ```bash bun db:seed # seed development database bun db:seed:staging # seed staging bun db:seed:prod # seed production ``` Seeds use `onConflictDoNothing()`, so they're safe to rerun without duplicating data. ## Project Structure ``` db/ ├── seeds/ │ └── users.ts # Creates 10 test user accounts └── scripts/ └── seed.ts # Entry point – connects to DB, calls seed functions ``` The seed runner imports your Drizzle config for environment resolution, creates a single-connection client, and calls each seed function in sequence. ## Writing a Custom Seed **1. Create a seed file** in `db/seeds/`: ```ts // db/seeds/products.ts import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type * as schema from "../schema"; import { product } from "../schema"; export async function seedProducts(db: PostgresJsDatabase) { const data = [ { name: "Starter Plan Guide", price: 0, organizationId: "org_..." }, { name: "Pro Onboarding Kit", price: 4900, organizationId: "org_..." }, ]; await db.insert(product).values(data).onConflictDoNothing(); console.log(`Seeded ${data.length} products`); } ``` **2. Register in the seed runner:** ```ts // db/scripts/seed.ts import { seedUsers } from "../seeds/users"; import { seedProducts } from "../seeds/products"; // [!code ++] // In the main function: await seedUsers(db); await seedProducts(db); // [!code ++] ``` ## Guidelines - Use realistic but obviously fake data (`alice@example.com`, not real addresses) - Always include `onConflictDoNothing()` so seeds are idempotent - Provide variety – mix of verified/unverified users, different roles, multiple orgs - Keep seed datasets small but representative of real usage patterns - Order seed calls by dependency – users before organizations before memberships ================================================ FILE: docs/deployment/ci-cd.md ================================================ # CI/CD GitHub Actions automates building, testing, and deploying. The pipeline uses two workflows: `ci.yml` for the build and conditional deploys, and `deploy.yml` as a reusable deployment workflow. ## Pipeline Overview ``` Pull request → build + lint + test → deploy to preview Push to main → build + test → deploy to staging Manual dispatch (production) → deploy to production ``` The `ci.yml` workflow runs a single **build** job, then conditionally triggers one of three **deploy** jobs depending on the event: | Trigger | Condition | Environment | | ------------------- | --------------------------------- | ----------- | | `pull_request` | Any PR to `main` | Preview | | `push` | Merge to `main` | Staging | | `workflow_dispatch` | Manual, `environment: production` | Production | ## Build Job The build job runs in every trigger scenario: ```yaml # .github/workflows/ci.yml – build job (simplified) steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile # Lint (PRs only – merged code was already checked) - run: bun prettier --check . - run: bun lint # Validate Terraform formatting - run: terraform fmt -check -recursive infra/ # Build and test - run: bun email:build # Email templates (needed for types) - run: bun tsc --build # Type checking - run: bun run test -- --run # Vitest - run: bun --filter @repo/web build - run: bun --filter @repo/api build - run: bun --filter @repo/app build # Upload artifacts for deploy jobs - uses: actions/upload-artifact@v6 ``` Concurrency is configured so only one run per PR or branch executes at a time, cancelling in-progress runs. ## Deploy Workflow The reusable `deploy.yml` workflow is called by each deploy job with environment-specific inputs: ```yaml # .github/workflows/ci.yml – deploy job example deploy-staging: needs: [build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: ./.github/workflows/deploy.yml with: name: Staging environment: staging url: https://staging.example.com secrets: inherit ``` The deploy workflow downloads build artifacts and deploys each worker via Wrangler: ```yaml # .github/workflows/deploy.yml (simplified) steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile # Deploy each worker - run: bun wrangler deploy --config apps/api/wrangler.jsonc --env ${{ inputs.environment }} - run: bun wrangler deploy --config apps/app/wrangler.jsonc --env ${{ inputs.environment }} - run: bun wrangler deploy --config apps/web/wrangler.jsonc --env ${{ inputs.environment }} ``` ::: warning The `wrangler deploy` steps in `deploy.yml` are currently commented out as TODOs. Uncomment them once your Cloudflare infrastructure is provisioned and `CLOUDFLARE_API_TOKEN` is set in GitHub secrets. ::: ## Preview Deployments Preview deploys use [pr-codename](https://github.com/kriasoft/pr-codename) to generate unique subdomains for each PR (e.g., `brave-fox.example.com`). The codename is stable across pushes to the same PR. ## Required Secrets Configure these in your GitHub repository settings under **Settings → Secrets and variables → Actions**: | Secret | Required | Description | | ---------------------- | -------- | ----------------------------------------- | | `CLOUDFLARE_API_TOKEN` | Yes | API token with Workers deploy permissions | Worker-level secrets (`BETTER_AUTH_SECRET`, Stripe keys, etc.) are set via `wrangler secret put` – not GitHub secrets. See [Cloudflare Workers: Secrets](/deployment/cloudflare#secrets). ## Additional Workflow A separate `conventional-commits.yml` workflow validates PR titles against the [Conventional Commits](https://www.conventionalcommits.org/) spec using `amannn/action-semantic-pull-request`. ================================================ FILE: docs/deployment/cloudflare.md ================================================ # Cloudflare Workers Each app has its own `wrangler.jsonc` with per-environment configuration for variables, service bindings, and Hyperdrive. ## Wrangler Configuration The **web** worker is the edge router. It receives all traffic via route patterns and forwards requests to **app** and **api** workers through service bindings: ```jsonc // apps/web/wrangler.jsonc (simplified) { "name": "example-web", "routes": [{ "pattern": "example.com/*", "zone_name": "example.com" }], "services": [ { "binding": "APP_SERVICE", "service": "example-app" }, { "binding": "API_SERVICE", "service": "example-api" }, ], "assets": { "directory": "./dist", "run_worker_first": ["/"], }, } ``` The **api** worker has `nodejs_compat` enabled and connects to Neon through two Hyperdrive bindings (cached and direct): ```jsonc // apps/api/wrangler.jsonc (simplified) { "name": "example-api", "compatibility_flags": ["nodejs_compat"], "hyperdrive": [ { "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id" }, { "binding": "HYPERDRIVE_DIRECT", "id": "your-hyperdrive-direct-id" }, ], } ``` The **app** worker serves the SPA with `not_found_handling: "single-page-application"` so all routes resolve to `index.html`. ::: info Service bindings are non-inheritable in Wrangler – each environment (`staging`, `preview`) must declare its own `services` array with the correct worker names (e.g., `example-app-staging`). ::: See [Architecture: Edge](/architecture/edge) for details on the service binding model. ## Environment Variables Each worker declares `vars` per environment in `wrangler.jsonc`. The API worker has the most: | Variable | Worker | Description | | ------------------- | -------- | ------------------------------------------------- | | `ENVIRONMENT` | all | `development`, `preview`, `staging`, `production` | | `APP_NAME` | api | Display name used in emails | | `APP_ORIGIN` | api | Full origin URL (e.g., `https://example.com`) | | `ALLOWED_ORIGINS` | api, app | Comma-separated list for CORS | | `RESEND_EMAIL_FROM` | api | Sender address for transactional emails | See [Environment Variables](/getting-started/environment-variables) for the complete reference. ## Secrets Secrets are set per worker via the Wrangler CLI. For the API worker: ```bash # Generate a secret for Better Auth openssl rand -hex 32 # Set secrets (repeat for each environment: --env staging, --env preview) wrangler secret put BETTER_AUTH_SECRET wrangler secret put GOOGLE_CLIENT_ID wrangler secret put GOOGLE_CLIENT_SECRET wrangler secret put RESEND_API_KEY wrangler secret put STRIPE_SECRET_KEY wrangler secret put STRIPE_WEBHOOK_SECRET ``` ::: warning Run `wrangler secret put` from the workspace directory (e.g., `apps/api/`) or pass `--config apps/api/wrangler.jsonc` so secrets bind to the correct worker. ::: ## Build and Deploy Build order matters – email templates must compile before the API worker bundles them: ```bash # Build all workspaces in dependency order bun build # email → web → api → app # Deploy each worker bun api:deploy bun app:deploy bun web:deploy # Or deploy to a specific environment bun wrangler deploy --config apps/api/wrangler.jsonc --env staging bun wrangler deploy --config apps/app/wrangler.jsonc --env staging bun wrangler deploy --config apps/web/wrangler.jsonc --env staging ``` ## Custom Domain 1. Add your domain to Cloudflare and update nameservers at your registrar 2. Update `routes` in `apps/web/wrangler.jsonc` with your domain 3. Set SSL/TLS encryption mode to **Full (strict)** in the Cloudflare dashboard 4. Enable **Always Use HTTPS** Routes are declared in `wrangler.jsonc` and applied automatically on deploy. Terraform manages DNS records if `cloudflare_zone_id` and `hostname` are set in your environment variables. ## Infrastructure with Terraform Terraform creates worker metadata, Hyperdrive configs, and DNS records. Worker code is deployed separately via Wrangler. ```bash # Plan changes for staging bun infra:staging:edge:plan # Apply changes bun infra:staging:edge:apply ``` Each environment has its own Terraform state in `infra/envs/{dev,preview,staging,prod}/edge/`. ================================================ FILE: docs/deployment/index.md ================================================ # Deployment React Starter Kit deploys as three Cloudflare Workers backed by a Neon PostgreSQL database. Infrastructure is managed with Terraform. ## What Gets Deployed | Component | Target | Description | | ------------------ | ------------------ | -------------------------------------------------------------------------- | | **Web Worker** | Cloudflare Workers | Edge router – receives all traffic, routes to app/api via service bindings | | **App Worker** | Cloudflare Workers | Serves the React SPA and static assets | | **API Worker** | Cloudflare Workers | Hono + tRPC server, authentication, database access | | **Database** | Neon PostgreSQL | Managed Postgres with Hyperdrive connection pooling | | **Infrastructure** | Terraform | Worker metadata, Hyperdrive configs, DNS records | See [Architecture Overview](/architecture/) for how these components connect. ## Prerequisites - **Cloudflare account** with Workers enabled - **Neon account** for PostgreSQL hosting ([sign up](https://get.neon.com/HD157BR)) - **Terraform** installed (`brew install terraform` or [download](https://developer.hashicorp.com/terraform/install)) - **Domain** added to Cloudflare DNS (optional for initial setup) ## Environments | Environment | Trigger | URL pattern | Purpose | | ----------- | --------------- | ------------------------ | ---------------------------------------------------------------------------- | | Development | `bun dev` | `localhost:5173` | Local development | | Preview | Pull request | `{codename}.example.com` | Isolated PR testing ([pr-codename](https://github.com/kriasoft/pr-codename)) | | Staging | Push to `main` | `staging.example.com` | Pre-production validation | | Production | Manual dispatch | `example.com` | Live environment | Each environment has its own Wrangler config, Hyperdrive bindings, and Terraform state. See [CI/CD](/deployment/ci-cd) for how deployments are triggered. ## Deployment Checklist 1. **Provision infrastructure** – run Terraform to create workers, Hyperdrive, and DNS records 2. **Set secrets** – configure `BETTER_AUTH_SECRET`, Stripe keys, and other secrets via Wrangler. See [Cloudflare Workers](/deployment/cloudflare) for the full list 3. **Run migrations** – apply schema to your production database. See [Production Database](/deployment/production-database) 4. **Build and deploy** – push code to workers. See [CI/CD](/deployment/ci-cd) or deploy manually: ```bash bun build # email → web → api → app bun api:deploy # Deploy API worker bun app:deploy # Deploy App worker bun web:deploy # Deploy Web worker ``` ## Section Pages - [Cloudflare Workers](/deployment/cloudflare) – Wrangler config, secrets, build and deploy - [Production Database](/deployment/production-database) – Neon setup, Hyperdrive, running migrations - [CI/CD](/deployment/ci-cd) – GitHub Actions pipelines, preview deployments - [Monitoring](/deployment/monitoring) – Logs, analytics, rollbacks, troubleshooting ================================================ FILE: docs/deployment/monitoring.md ================================================ # Monitoring Monitor your Workers in production using Cloudflare's built-in tools and roll back quickly when issues arise. ## Wrangler Tail Stream live logs from any worker: ```bash # Tail production API logs wrangler tail --config apps/api/wrangler.jsonc # Filter to specific paths wrangler tail --config apps/api/wrangler.jsonc --search-str="/api/trpc" # Tail staging wrangler tail --config apps/api/wrangler.jsonc --env=staging ``` Logs include request metadata, `console.log` output, and uncaught exceptions. ## Cloudflare Analytics The Cloudflare dashboard provides per-worker metrics: - **Workers → Analytics** – request count, error rate, CPU time, duration percentiles - **Workers → Logs** – real-time and historical log streams - Set up **notification policies** for error rate spikes or latency increases ## Rollback If a deploy introduces issues, roll back to the previous version: ```bash # List recent deployments wrangler deployments list --config apps/api/wrangler.jsonc # Roll back to the previous stable version wrangler rollback --config apps/api/wrangler.jsonc \ --message="Reverting due to auth regression" ``` Repeat for each affected worker (`apps/app/`, `apps/web/`). ::: warning Wrangler rollback reverts worker code but not database migrations. If a deploy included schema changes that the previous code depends on differently, you may need to deploy a fix-forward migration instead. See [Database: Migrations](/database/migrations). ::: ## Troubleshooting **Worker size limit** – Cloudflare Workers have a 10 MB compressed size limit (3 MB on the free plan). If you hit it: - Check for accidentally bundled dependencies - Move large assets to R2 storage - Ensure tree shaking is working (check for side-effect imports) **Database connection issues** – If queries fail or time out: - Verify Hyperdrive IDs in `wrangler.jsonc` match Terraform output - Check Neon dashboard for connection limit exhaustion - Confirm the database isn't in auto-suspended state (first request after suspend is slower) **Authentication problems** – If sign-in fails in production: - Verify `BETTER_AUTH_SECRET` is set (`wrangler secret list --config apps/api/wrangler.jsonc`) - Check `APP_ORIGIN` matches your actual domain (affects cookie domain) - Confirm OAuth redirect URIs include your production URL. See [Social Providers](/auth/social-providers) ## Cost Overview | Service | Free tier | Paid | | ------------------ | ------------------------------------ | ---------------------------------------------------------------------------------------- | | Cloudflare Workers | 100,000 requests/day | [$5/month for 10M requests](https://developers.cloudflare.com/workers/platform/pricing/) | | Neon PostgreSQL | 0.5 GB storage, auto-suspend compute | [Scale-to-zero billing](https://neon.tech/pricing) | | Hyperdrive | Included with Workers paid plan | – | | Resend | 100 emails/day | [$20/month for 50K emails](https://resend.com/pricing) | A typical growth-stage project runs around **~$45/month** (Workers $5 + Neon $19 + Resend $20). Free tiers are sufficient through early production – monitor usage in the Cloudflare and Neon dashboards as traffic grows. ================================================ FILE: docs/deployment/production-database.md ================================================ # Production Database The production database runs on [Neon PostgreSQL](https://neon.tech/) with [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/) providing connection pooling at the edge. ## Neon Setup 1. Create a Neon project at [console.neon.tech](https://console.neon.tech/) (or via [referral](https://get.neon.com/HD157BR)) 2. Create separate databases for staging and production (or use Neon branching) 3. Copy the connection strings – you'll need them for Hyperdrive and migrations The connection string format: `postgresql://user:pass@host/dbname?sslmode=require` ## Hyperdrive Configuration Hyperdrive is provisioned via Terraform. The module in `infra/modules/cloudflare/hyperdrive/` parses the Neon connection string and creates a Hyperdrive config with connection pooling: ```bash # Provision Hyperdrive for staging bun infra:staging:edge:apply ``` This creates two Hyperdrive bindings per environment: | Binding | Caching | Use for | | ------------------- | ------------------- | -------------------------------------------------- | | `HYPERDRIVE_CACHED` | Disabled by default | Read-heavy queries (enable in Terraform if needed) | | `HYPERDRIVE_DIRECT` | None | Writes, real-time reads | After Terraform applies, copy the Hyperdrive IDs from the output into `apps/api/wrangler.jsonc` for each environment. See [Database: Connection Architecture](/database/#connection-architecture) for how these bindings are used in application code. ## Running Migrations Migrations run directly against Neon (not through Hyperdrive). The `db/` workspace provides environment-specific commands: ```bash # Staging bun db:migrate:staging # Production bun db:migrate:prod ``` These commands read connection strings from `.env.staging.local` and `.env.prod.local` respectively. See [Database: Migrations](/database/migrations) for the full workflow. ::: warning Always review generated migration SQL before running against production. Use `bun db:generate` to preview changes, then inspect the files in `db/migrations/` before applying. ::: ## Database Performance - **Connection pooling** – Hyperdrive maintains a pool at the edge, reducing cold-start latency - **Indexes** – add indexes for frequently queried columns, especially foreign keys used in multi-tenant filters - **Monitor slow queries** – use the Neon dashboard to identify and optimize slow queries - **Compute auto-suspend** – Neon suspends idle compute after inactivity; first request after suspend has higher latency ================================================ FILE: docs/email.md ================================================ # Email Transactional emails are built with [React Email](https://react.email/) and delivered through [Resend](https://resend.com/). The `apps/email/` workspace owns all templates and rendering – the API imports compiled templates and sends them via the Resend SDK. ## Workspace Structure ```bash apps/email/ ├── components/ │ └── BaseTemplate.tsx # Shared header, footer, and styling ├── templates/ │ ├── otp-email.tsx # OTP codes (sign-in, verification, password reset) │ ├── email-verification.tsx # Link-based email verification │ └── password-reset.tsx # Link-based password reset ├── emails/ # Preview files for dev server (sample data) ├── utils/ │ └── render.ts # renderEmailToHtml() / renderEmailToText() ├── index.ts # Public exports └── package.json ``` ## Templates Three templates ship out of the box, all wrapped in `BaseTemplate` for consistent branding: | Template | Used By | Trigger | | ------------------- | ---------------------------------------- | -------------------------- | | `OTPEmail` | [Email & OTP](/auth/email-otp) auth flow | `emailOTP` plugin callback | | `EmailVerification` | Link-based email verification | `sendVerificationEmail()` | | `PasswordReset` | Password reset flow | `sendPasswordReset()` | `OTPEmail` handles three types via a single `type` prop – `"sign-in"`, `"email-verification"`, and `"forget-password"` – each with different copy. Password resets include an additional security warning. The separate `PasswordReset` template uses a red button to emphasize the security-sensitive action. ## Development Preview templates locally with hot reload: ```bash bun email:dev ``` This starts the React Email preview server at `http://localhost:3001`. Files in `emails/` provide sample data for each template – edit them to test different states. ::: tip The email workspace must be built before the API can import templates. The root `bun dev` handles this automatically, but if you run the API standalone, run `bun email:build` first. ::: ## Sending Emails The API sends emails through helper functions in `apps/api/lib/email.ts`. Each helper renders a template to both HTML and plain text, then sends via Resend: ```ts // apps/api/lib/email.ts import { OTPEmail, renderEmailToHtml, renderEmailToText } from "@repo/email"; const component = OTPEmail({ otp, type, appName: env.APP_NAME, appUrl: env.APP_ORIGIN, }); const html = await renderEmailToHtml(component); const text = await renderEmailToText(component); await sendEmail(env, { to: email, subject: `Your ${typeLabel} code`, html, text, }); ``` Available sender functions: | Function | Purpose | | ------------------------- | ------------------------------------------------------------------------------ | | `sendOTP()` | OTP codes for all auth flows | | `sendVerificationEmail()` | Link-based email verification | | `sendPasswordReset()` | Password reset links | | `sendEmail()` | Low-level sender (validates recipients, requires plain text fallback for HTML) | ::: warning `sendEmail()` throws if you provide HTML without a plain text fallback. Always render both versions using `renderEmailToHtml()` and `renderEmailToText()`. ::: ### Development Shortcut In development, `sendOTP()` also prints the code to the terminal for convenience: ```txt OTP code for user@example.com: 482901 ``` A valid `RESEND_API_KEY` is still required – the console output supplements the email, it doesn't replace it. ## Adding a Template 1. Create the template in `apps/email/templates/`: ```tsx // apps/email/templates/invitation.tsx import { Button, Heading, Text } from "@react-email/components"; import { BaseTemplate } from "../components/BaseTemplate"; interface InvitationProps { inviterName: string; orgName: string; acceptUrl: string; appName?: string; appUrl?: string; } export function Invitation({ inviterName, orgName, acceptUrl, appName, appUrl, }: InvitationProps) { return ( You're invited {inviterName} invited you to join {orgName}. ); } ``` 2. Export it from `apps/email/index.ts`: ```ts export { Invitation } from "./templates/invitation.js"; ``` 3. Add a preview file in `apps/email/emails/` with sample props for the dev server. 4. Create a sender function in `apps/api/lib/email.ts` following the same render-then-send pattern. ## Environment Variables | Variable | Required | Description | | ------------------- | --------- | ------------------------------------------------------------------ | | `RESEND_API_KEY` | For email | Resend API key (`re_...`) | | `RESEND_EMAIL_FROM` | For email | Sender address (e.g., `noreply@example.com`) | | `APP_NAME` | No | Used in email subject lines and branding (defaults to `"Example"`) | | `APP_ORIGIN` | Yes | Used for links in email footer | Set in `.env.local` for development, Cloudflare secrets for staging/production. See [Environment Variables](/getting-started/environment-variables). ## File Map | Layer | Files | | ---------------- | --------------------------------------------- | | Templates | `apps/email/templates/*.tsx` | | Shared layout | `apps/email/components/BaseTemplate.tsx` | | Rendering | `apps/email/utils/render.ts` | | Sending | `apps/api/lib/email.ts` | | Auth integration | `emailOTP` callback in `apps/api/lib/auth.ts` | ================================================ FILE: docs/frontend/forms.md ================================================ # Forms & Validation Forms use controlled React inputs with Zod for validation. There's no form library – the patterns are simple enough that a direct approach keeps things explicit. ## Basic Pattern A typical form uses `useState` for input values and a tRPC mutation for submission: ```tsx import { Button, Input, Label } from "@repo/ui"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { trpcClient } from "@/lib/trpc"; function CreateProjectForm() { const [name, setName] = useState(""); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (input: { name: string }) => trpcClient.project.create.mutate(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["project"] }); setName(""); }, }); return (
{ e.preventDefault(); mutation.mutate({ name }); }} > setName(e.target.value)} required />
); } ``` ## Zod Schema Sharing Zod schemas are defined on tRPC procedures and can be shared with the frontend for search param validation or client-side checks. The login route uses a Zod schema with `validateSearch` to sanitize the `returnTo` param at parse time – see [Routing > Search Params](./routing.md#search-params) for the full example. ## Auth Form The auth form (`apps/app/components/auth/auth-form.tsx`) demonstrates a multi-step form pattern. It uses a state machine with three steps: ``` method → email → otp ↑ ↑ │ └────────┘ │ ←───────┘ ``` The `useAuthForm` hook manages transitions between steps: ```tsx const VALID_TRANSITIONS: Record = { method: ["email"], email: ["method", "otp"], otp: ["email"], }; ``` Each step renders conditionally based on the current state: ```tsx export function AuthForm({ mode = "login", onSuccess, returnTo }) { const { step, email, isDisabled, error /* actions */ } = useAuthForm({ onSuccess, mode, }); return (
{error && (
{error}
)} {step === "method" && } {step === "email" && } {step === "otp" && }
); } ``` Key design decisions in `useAuthForm`: - **Counter-based pending ops** – handles overlapping child operations (e.g., passkey conditional UI running alongside manual click) - **Success guard** (`hasSucceededRef`) – prevents concurrent auth completion from multiple methods - **Email normalization** – trims whitespace and lowercases before API calls - **Error orthogonal to steps** – errors can occur at any step and are displayed at the form level ## Error Display Errors are shown as alert boxes with `role="alert"` for screen reader announcements: ```tsx { error && (
{error}
); } ``` For mutation errors, check `mutation.error`: ```tsx { mutation.error && (
{mutation.error.message}
); } ``` ## Loading States Coordinate disabled state across form elements to prevent double-submission: ```tsx // useAuthForm combines multiple sources into one flag const isDisabled = isLoading || pendingOps > 0 || !!isExternallyLoading; ``` Apply to all interactive elements: ```tsx ``` For mutations, use `isPending` from the mutation object: ```tsx ``` ## Post-Submission After successful form submission, the caller handles cache invalidation and navigation – not the form itself: ```tsx // apps/app/routes/(auth)/login.tsx async function handleSuccess() { await revalidateSession(queryClient, router); await router.navigate({ to: search.returnTo ?? "/" }); } ; ``` This keeps the form reusable – `AuthForm` works in both the login page and a login dialog because the caller controls what happens after success. ================================================ FILE: docs/frontend/routing.md ================================================ # Routing The app uses [TanStack Router](https://tanstack.com/router/latest) with file-based routing. Routes are defined as files in `apps/app/routes/` and TanStack Router generates a typed route tree automatically. ## Route File Convention Each file in `routes/` becomes a route. The file path determines the URL: ```bash apps/app/routes/ ├── __root.tsx → Root layout (wraps everything) ├── (auth)/ │ ├── login.tsx → /login │ └── signup.tsx → /signup └── (app)/ ├── route.tsx → Layout for all (app) routes ├── index.tsx → / (dashboard) ├── settings.tsx → /settings ├── users.tsx → /users ├── analytics.tsx → /analytics ├── reports.tsx → /reports ├── dashboard.tsx → /dashboard (redirects to /) └── about.tsx → /about ``` Parenthesized directories like `(app)` and `(auth)` are **route groups** – they create layout boundaries without affecting the URL. `/settings` is the URL, not `/(app)/settings`. The generated route tree lives at `apps/app/lib/routeTree.gen.ts`. Don't edit it – run `bun app:dev` and TanStack Router regenerates it on file changes. ## Route Groups The two route groups serve different auth requirements: | Group | Purpose | Auth behavior | | -------- | ------------------- | ----------------------------------------- | | `(app)` | Protected app pages | Redirects to `/login` if unauthenticated | | `(auth)` | Login/signup pages | Redirects to `/` if already authenticated | ## Root Route The root route (`__root.tsx`) creates the router context and wraps everything in an error boundary: ```tsx // apps/app/routes/__root.tsx export const Route = createRootRouteWithContext<{ queryClient: QueryClient; }>()({ component: Root, }); function Root() { return ( {import.meta.env.DEV && } ); } ``` The `queryClient` in context is what makes `beforeLoad` guards possible – route guards can prefetch or read cached data before rendering. ## Auth Guards ### Protecting app routes The `(app)/route.tsx` layout guard uses a cache-first strategy for instant navigation: ```tsx // apps/app/routes/(app)/route.tsx export const Route = createFileRoute("/(app)")({ beforeLoad: async ({ context, location }) => { // Check cache first – instant when data exists let session = getCachedSession(context.queryClient); // Fetch only if cache is empty (first visit or after sign-out) if (session === undefined) { session = await context.queryClient.fetchQuery(sessionQueryOptions()); } // Both user and session must exist for valid auth state if (!session?.user || !session?.session) { throw redirect({ to: "/login", search: { returnTo: location.href }, }); } return { user: session.user, session }; }, component: AppLayout, }); ``` This pattern makes subsequent navigations between protected routes instant – the session is already cached from the first load. ### Redirecting authenticated users Login and signup routes redirect authenticated users away: ```tsx // apps/app/routes/(auth)/login.tsx export const Route = createFileRoute("/(auth)/login")({ validateSearch: searchSchema, beforeLoad: async ({ context, search }) => { try { const session = await context.queryClient.fetchQuery( sessionQueryOptions(), ); if (session?.user && session?.session) { throw redirect({ to: search.returnTo ?? "/" }); } } catch (error) { if (isRedirect(error)) throw error; // Show login form for fetch errors } }, component: LoginPage, }); ``` ## Search Params Validate and transform search params with Zod. The login route sanitizes `returnTo` to prevent open redirects: ```tsx const searchSchema = z.object({ returnTo: z .string() .optional() .transform((val) => { const safe = getSafeRedirectUrl(val); return safe === "/" ? undefined : safe; }) .catch(undefined), }); ``` Access validated search params in the component: ```tsx function LoginPage() { const search = Route.useSearch(); // search.returnTo is guaranteed safe – validated at parse time } ``` ## Navigation Use the `` component for type-safe navigation: ```tsx import { Link } from "@tanstack/react-router"; Settings // Active styling Settings // With search params Log in ``` For programmatic navigation: ```tsx const router = useRouter(); await router.navigate({ to: "/settings" }); ``` ## Adding a New Route 1. Create a route file: ```tsx // apps/app/routes/(app)/projects.tsx import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/(app)/projects")({ component: Projects, }); function Projects() { return (

Projects

); } ``` 2. The route tree regenerates automatically during `bun app:dev`. The new page is available at `/projects` and protected by the `(app)` layout guard. 3. Add navigation in the sidebar or header as needed. See [State & Data Fetching](./state.md) for loading data in your new route. For more on TanStack Router, see the [official docs](https://tanstack.com/router/latest/docs/framework/react/overview). ================================================ FILE: docs/frontend/state.md ================================================ # State & Data Fetching Server state is managed with [TanStack Query](https://tanstack.com/query/latest) through a tRPC integration. Client state uses [Jotai](https://jotai.org/) atoms when needed. ## tRPC Client The tRPC client in `apps/app/lib/trpc.ts` provides two exports: ```tsx import { trpcClient } from "@/lib/trpc"; // Raw tRPC client import { api } from "@/lib/trpc"; // TanStack Query integration ``` - **`trpcClient`** – call procedures directly (useful in query functions, `beforeLoad`, and non-React code) - **`api`** – creates `queryOptions` objects for use with TanStack Query hooks The client sends requests to `/api/trpc` with batched HTTP transport and includes credentials for cookie-based auth. A logger link is added in development. ## TanStack Query Defaults The `QueryClient` in `apps/app/lib/query.ts` is configured with sensible defaults: | Option | Value | Rationale | | ---------------------- | ---------- | ---------------------------------------------------- | | `staleTime` | 2 min | Prevents redundant API calls during typical sessions | | `gcTime` | 5 min | Balances memory with instant data on back-navigation | | `retry` | 3 | Exponential backoff: 1s, 2s, 4s (capped at 30s) | | `refetchOnWindowFocus` | `true` | Keeps data current after tab switches | | `refetchOnReconnect` | `"always"` | Overrides `staleTime` after connectivity loss | Mutations retry once with a 1s delay. ## Session Query The session query (`apps/app/lib/queries/session.ts`) is the canonical example of a query module. It overrides global defaults where auth requires different behavior: ```tsx export function sessionQueryOptions() { return queryOptions({ queryKey: ["auth", "session"], queryFn: async () => { const response = await auth.getSession(); if (response.error) throw response.error; return response.data; }, // Auth state should stay fresher than general data staleTime: 30_000, // Don't retry 401/403 – retrying won't help retry(failureCount, error) { const status = getErrorStatus(error); if (status === 401 || status === 403) return false; return failureCount < 3; }, }); } ``` Returns `null` when unauthenticated – not an error. The module also exports helpers for cache access: | Export | Purpose | | ---------------------------------------- | ----------------------------------------------------------- | | `useSessionQuery()` | Basic hook | | `useSuspenseSessionQuery()` | Suspense-enabled version | | `getCachedSession(queryClient)` | Sync cache read (no network) | | `isAuthenticated(queryClient)` | Binary check – requires both `user` and `session` | | `signOut(queryClient)` | Clears server session, sets cache to `null`, hard redirects | | `revalidateSession(queryClient, router)` | Removes cached query so `beforeLoad` fetches fresh | ## Billing Query The billing query demonstrates multi-tenant key design – including `activeOrgId` in the key causes automatic refetch when the user switches organizations: ```tsx // apps/app/lib/queries/billing.ts export function billingQueryOptions(activeOrgId?: string | null) { return queryOptions({ queryKey: ["billing", "subscription", activeOrgId ?? null], queryFn: () => trpcClient.billing.subscription.query(), }); } ``` Usage in a component: ```tsx function BillingCard() { const { data: session } = useSessionQuery(); const activeOrgId = session?.session?.activeOrganizationId; const { data: billing, isLoading } = useBillingQuery(activeOrgId); // ... } ``` ## Calling Procedures from Components Use the `api` proxy to create query options, then pass them to TanStack Query hooks: ```tsx import { useSuspenseQuery } from "@tanstack/react-query"; import { api } from "@/lib/trpc"; function UserList() { const { data: users } = useSuspenseQuery(api.user.list.queryOptions()); return (
    {users.map((user) => (
  • {user.name}
  • ))}
); } ``` For mutations: ```tsx import { useMutation, useQueryClient } from "@tanstack/react-query"; import { trpcClient } from "@/lib/trpc"; function CreateUserButton() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (input: { name: string; email: string }) => trpcClient.user.create.mutate(input), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user"] }); }, }); return ( ); } ``` ## Cache Invalidation Invalidate by query key prefix to refresh related data after mutations: ```tsx // Invalidate all user queries queryClient.invalidateQueries({ queryKey: ["user"] }); // Invalidate all billing queries (any org) queryClient.invalidateQueries({ queryKey: ["billing", "subscription"] }); ``` For session changes, use `removeQueries` instead of `invalidateQueries` – this forces `beforeLoad` guards to fetch fresh data rather than serving stale cache: ```tsx queryClient.removeQueries({ queryKey: ["auth", "session"] }); await router.invalidate(); ``` ## Jotai Store A global Jotai store is set up in `apps/app/lib/store.ts` for cross-component client state. It's wired into the app via `StoreProvider` but not heavily used – TanStack Query handles most state needs. Use Jotai for UI state that doesn't belong in server cache (theme preference, sidebar open/closed, local filters). ```tsx import { atom, useAtom } from "jotai"; const sidebarOpenAtom = atom(true); function Sidebar() { const [open, setOpen] = useAtom(sidebarOpenAtom); // ... } ``` See [Forms & Validation](./forms.md) for mutation patterns in form submissions. For library reference, see the [TanStack Query docs](https://tanstack.com/query/latest/docs/framework/react/overview), [tRPC docs](https://trpc.io/docs/client/react), and [Jotai docs](https://jotai.org/docs/introduction). ================================================ FILE: docs/frontend/ui.md ================================================ # UI The project uses [shadcn/ui](https://ui.shadcn.com/) (new-york style) with [Tailwind CSS v4](https://tailwindcss.com/) for styling. Components live in `packages/ui/` and are shared across all apps in the monorepo. ## Component Management Add components from the shadcn/ui registry: ```bash # Add a single component bun ui:add button # Add multiple components bun ui:add dialog card select # Interactive mode – browse and select bun ui:add # List installed components bun ui:list # Update all installed components bun ui:update ``` Run `bun ui:list` to see which components are currently installed. ## Component Structure Components are stored directly in `packages/ui/components/` – one file per component: ```bash packages/ui/ ├── components/ │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── ... │ └── textarea.tsx ├── lib/ │ └── utils.ts # cn() utility ├── scripts/ # CLI tooling (add, list, update) ├── index.ts # Barrel export └── package.json ``` All components and utilities are re-exported from the package root (`index.ts`), so importing is straightforward: ```tsx import { Button, Card, CardHeader, CardTitle, Input, cn } from "@repo/ui"; ``` ## Using Components ```tsx import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@repo/ui"; function FeatureCard({ title, description, children }) { return ( {title} {description} {children} ); } ``` ## The `cn()` Utility Use `cn()` (from `clsx` + `tailwind-merge`) for conditional and merged class names: ```tsx import { Button, cn } from "@repo/ui"; ; } ``` ## Reference - [Procedures](/api/procedures) – query vs mutation, public vs protected - [Validation & Errors](/api/validation-errors) – Zod input schemas and error handling - [State & Data Fetching](/frontend/state) – TanStack Query patterns ================================================ FILE: docs/recipes/new-table.md ================================================ # Add a Database Table This recipe walks through adding a new database table, from schema definition to querying it in the API. ## 1. Define the schema Create a file in `db/schema/` with your table definition: ```ts // db/schema/project.ts import { relations } from "drizzle-orm"; import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { generateId } from "./id"; import { organization } from "./organization"; export const project = pgTable( "project", { id: text() .primaryKey() .$defaultFn(() => generateId("prj")), name: text().notNull(), description: text(), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [index("project_organization_id_idx").on(table.organizationId)], ); export const projectRelations = relations(project, ({ one }) => ({ organization: one(organization, { fields: [project.organizationId], references: [organization.id], }), })); export type Project = typeof project.$inferSelect; export type NewProject = typeof project.$inferInsert; ``` Key conventions: - **IDs** – use `generateId("xxx")` with a unique 3-letter prefix (see [Schema](/database/schema) for existing prefixes) - **Timestamps** – always include `createdAt` and `updatedAt` with timezone - **Foreign keys** – use `onDelete: "cascade"` for owned resources - **Indexes** – add indexes on columns used in `WHERE` or `JOIN` clauses - **Casing** – write TypeScript in camelCase; Drizzle converts to snake_case automatically ## 2. Export from the barrel file ```ts // db/schema/index.ts export * from "./project"; // [!code ++] ``` ## 3. Generate and apply the migration ```bash bun db:generate # Creates a new SQL migration file in db/migrations/ bun db:push # Applies it to your local database ``` Review the generated SQL in `db/migrations/` before applying to staging or production. ## 4. Add seed data (optional) Create a seed function: ```ts // db/seeds/projects.ts import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import type * as schema from "../schema"; import { project } from "../schema"; export async function seedProjects(db: PostgresJsDatabase) { const projects = [ { name: "Acme Dashboard", organizationId: "org_..." }, { name: "Mobile App", organizationId: "org_..." }, ]; for (const p of projects) { await db.insert(project).values(p).onConflictDoNothing(); } console.log(`Seeded ${projects.length} projects`); } ``` Call it from `db/scripts/seed.ts`: ```ts import { seedProjects } from "../seeds/projects"; await seedProjects(db); ``` ## 5. Query in a tRPC procedure ```ts // apps/api/routers/project.ts import { protectedProcedure, router } from "../lib/trpc.js"; export const projectRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.query.project.findMany({ where: (p, { eq }) => eq(p.organizationId, ctx.session.activeOrganizationId!), orderBy: (p, { desc }) => desc(p.createdAt), }); }), }); ``` See [Add a tRPC Procedure](/recipes/new-procedure) for the full frontend wiring. ## Reference - [Schema](/database/schema) – column conventions, ID prefixes, entity reference - [Migrations](/database/migrations) – migration workflow and best practices - [Query Patterns](/database/queries) – multi-tenant queries, joins, transactions ================================================ FILE: docs/recipes/teams.md ================================================ # Add Teams Teams let you create subgroups within organizations. This recipe enables Better Auth's [teams feature](https://www.better-auth.com/docs/plugins/organization#teams) and wires it into the existing schema. ## 1. Add the schema Create `db/schema/team.ts`: ```typescript import { relations } from "drizzle-orm"; import { index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"; import { generateId } from "./id"; import { organization } from "./organization"; import { user } from "./user"; export const team = pgTable( "team", { id: text() .primaryKey() .$defaultFn(() => generateId("tea")), name: text().notNull(), organizationId: text() .notNull() .references(() => organization.id, { onDelete: "cascade" }), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [index("team_organization_id_idx").on(table.organizationId)], ); export const teamMember = pgTable( "team_member", { id: text() .primaryKey() .$defaultFn(() => generateId("tmb")), teamId: text() .notNull() .references(() => team.id, { onDelete: "cascade" }), userId: text() .notNull() .references(() => user.id, { onDelete: "cascade" }), createdAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .notNull(), updatedAt: timestamp({ withTimezone: true, mode: "date" }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }, (table) => [ unique("team_member_team_user_unique").on(table.teamId, table.userId), index("team_member_team_id_idx").on(table.teamId), index("team_member_user_id_idx").on(table.userId), ], ); export const teamRelations = relations(team, ({ one, many }) => ({ organization: one(organization, { fields: [team.organizationId], references: [organization.id], }), members: many(teamMember), })); export const teamMemberRelations = relations(teamMember, ({ one }) => ({ team: one(team, { fields: [teamMember.teamId], references: [team.id], }), user: one(user, { fields: [teamMember.userId], references: [user.id], }), })); ``` Export it from `db/schema/index.ts`: ```typescript export * from "./team"; // [!code ++] ``` ## 2. Extend session and invitation tables Add `activeTeamId` to the session table in `db/schema/user.ts`: ```typescript export const session = pgTable( "session", { // ...existing columns activeOrganizationId: text(), activeTeamId: text(), // [!code ++] }, // ... ); ``` Add `teamId` to the invitation table in `db/schema/invitation.ts` for team-scoped invitations: ```typescript export const invitation = pgTable( "invitation", { // ...existing columns teamId: text().references(() => team.id, { onDelete: "cascade" }), // [!code ++] }, // ... ); ``` ## 3. Enable the teams plugin In `apps/api/lib/auth.ts`, add the new tables to the Drizzle adapter schema and enable teams in the organization plugin: ```typescript database: drizzleAdapter(db, { provider: "pg", schema: { // ...existing mappings team: Db.team, // [!code ++] teamMember: Db.teamMember, // [!code ++] }, }), // ... plugins: [ organization({ allowUserToCreateOrganization: true, organizationLimit: 5, creatorRole: "owner", teams: { enabled: true }, // [!code ++] }), ], ``` In `apps/app/lib/auth.ts`, enable teams on the client: ```typescript export const auth = createAuthClient({ // ... plugins: [ organizationClient({ teams: { enabled: true }, // [!code ++] }), // ...other plugins ], }); ``` ## 4. Apply the migration ```bash bun db:generate bun db:push ``` ## 5. Use the teams API Create a team within the active organization: ```ts await auth.organization.createTeam({ name: "Engineering", }); ``` Set the active team for the current session: ```ts await auth.organization.setActiveTeam({ teamId: "tea_...", }); ``` List teams and manage members: ```ts // List teams in the active organization const { data: teams } = await auth.organization.listTeams(); // Add a member to a team await auth.organization.addTeamMember({ teamId: "tea_...", userId: "usr_...", }); // Remove a member from a team await auth.organization.removeTeamMember({ teamId: "tea_...", userId: "usr_...", }); ``` The active team ID is available in the session as `session.activeTeamId`, alongside the existing `session.activeOrganizationId`. ## Reference - [Better Auth organization plugin – Teams](https://www.better-auth.com/docs/plugins/organization#teams) - [Organizations & Roles](/auth/organizations) – base organization setup ================================================ FILE: docs/recipes/websockets.md ================================================ # WebSockets This recipe adds real-time WebSocket communication using the `@repo/ws-protocol` package and [WS-Kit](https://github.com/kriasoft/ws-kit). ## 1. Define a message Add a new message schema in `packages/ws-protocol/messages.ts`: ```ts // packages/ws-protocol/messages.ts import { message, z } from "@ws-kit/zod"; export const ChatMessage = message("CHAT_MESSAGE", { channelId: z.string(), text: z.string().min(1).max(2000), sentAt: z.number(), }); ``` Messages follow the envelope structure `{ type, meta, payload }` and are validated with Zod at runtime. For request/response patterns, use `rpc()` instead: ```ts import { rpc, z } from "@ws-kit/zod"; export const GetMessages = rpc( "GET_MESSAGES", { channelId: z.string(), limit: z.number().default(50) }, "MESSAGES", { messages: z.array(z.object({ id: z.string(), text: z.string() })) }, ); ``` ## 2. Add a handler Register the message handler in `packages/ws-protocol/router.ts`: ```ts // packages/ws-protocol/router.ts import { ChatMessage } from "./messages"; export function createAppRouter(): Router { const router = createRouter() .plugin(withZod()) // ... existing handlers .on(ChatMessage, (ctx) => { // Broadcast to all clients subscribed to this channel ctx.publish(`channel:${ctx.payload.channelId}`, ChatMessage, { channelId: ctx.payload.channelId, text: ctx.payload.text, sentAt: ctx.payload.sentAt, }); }); return router; } ``` Publishing requires the pub/sub plugin – see step 3. ## 3. Start the server Create a WebSocket server entry point using Bun's native WebSocket support: ```ts import { createBunHandler } from "@ws-kit/bun"; import { memoryPubSub } from "@ws-kit/memory"; import { withPubSub } from "@ws-kit/pubsub"; import { createAppRouter } from "@repo/ws-protocol/router"; const router = createAppRouter().plugin( withPubSub({ adapter: memoryPubSub() }), ); const { fetch: handleWebSocket, websocket } = createBunHandler(router, { authenticate(req) { // Validate auth token, return initial connection data return { connectedAt: Date.now() }; }, }); Bun.serve({ port: 3001, fetch(req, server) { if (new URL(req.url).pathname === "/ws") { return handleWebSocket(req, server); } return new Response("WebSocket server"); }, websocket, }); ``` For Cloudflare Workers, use `@ws-kit/cloudflare` with Durable Objects instead of `@ws-kit/bun`. ## 4. Connect from the frontend ```ts import { Ping, Pong, ChatMessage } from "@repo/ws-protocol"; const ws = new WebSocket("ws://localhost:3001/ws"); // Listen for messages ws.addEventListener("message", (event) => { const msg = JSON.parse(event.data); if (msg.type === ChatMessage.type) { console.log("Chat:", msg.payload.text); } }); // Send a message ws.send( JSON.stringify({ type: "CHAT_MESSAGE", meta: {}, payload: { channelId: "general", text: "Hello!", sentAt: Date.now(), }, }), ); ``` For a type-safe client with automatic reconnection, use `@ws-kit/client`: ```ts import { wsClient } from "@ws-kit/client/zod"; import { ChatMessage, Pong } from "@repo/ws-protocol"; const client = wsClient({ url: "ws://localhost:3001/ws", reconnect: { enabled: true }, }); client.on(ChatMessage, (msg) => { console.log("Chat:", msg.payload.text); }); await client.connect(); client.send(ChatMessage, { channelId: "general", text: "Hello!", sentAt: Date.now(), }); ``` ## 5. Run the example The `packages/ws-protocol/` workspace includes a working example server: ```bash bun --filter @repo/ws-protocol example ``` Connect with any WebSocket client (e.g., `wscat -c ws://localhost:3000/ws`) and send: ```json {"type": "PING", "meta": {}, "payload": {}} {"type": "ECHO", "meta": {}, "payload": {"text": "Hello"}} ``` ## Reference - [WS-Kit documentation](https://github.com/kriasoft/ws-kit) – message schemas, router API, pub/sub - [Architecture Overview](/architecture/) – worker boundaries and service bindings - [Add a tRPC Procedure](/recipes/new-procedure) – for HTTP-based API endpoints ================================================ FILE: docs/security/checklist.md ================================================ # Security Best Practices Checklist A comprehensive security checklist for React Starter Kit applications. Review this checklist during development, before deployment, and regularly in production. ## Development Phase ### Code Security #### Input Validation - [ ] Validate all user inputs on both client and server - [ ] Use Zod schemas for type-safe validation - [ ] Sanitize HTML content to prevent XSS - [ ] Validate file uploads (type, size, content) - [ ] Implement rate limiting on forms and APIs ```typescript // Example: tRPC input validation with Zod export const userRouter = router({ create: publicProcedure .input( z.object({ email: z.string().email(), name: z.string().min(1).max(100), age: z.number().int().positive().max(120), }), ) .mutation(async ({ input }) => { // Input is already validated }), }); ``` #### Authentication & Authorization - [ ] Use Better Auth for authentication - [ ] Implement proper session management - [ ] Use secure session storage (httpOnly cookies) - [ ] Implement CSRF protection - [ ] Check permissions on every protected route - [ ] Log authentication events ```typescript // Example: Protected tRPC procedure export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ ctx: { ...ctx, user: ctx.session.user } }); }); ``` #### Data Protection - [ ] Never log sensitive data (passwords, tokens, PII) - [ ] Use parameterized queries (Drizzle ORM) - [ ] Encrypt sensitive data at rest - [ ] Implement proper error handling without data leaks - [ ] Use HTTPS for all communications - [ ] Validate and sanitize database queries ```typescript // Example: Safe database query with Drizzle const users = await db .select() .from(usersTable) .where(eq(usersTable.email, email)); // Parameterized, prevents SQL injection ``` ### Secret Management #### Environment Variables - [ ] Store secrets in `.env.local` (never commit) - [ ] Use `.env` only for non-sensitive defaults - [ ] Document required variables in `.env` - [ ] Validate environment variables at startup - [ ] Use different secrets for each environment ```typescript // Example: Environment validation const env = z .object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), SMTP_PASSWORD: z.string(), PUBLIC_API_URL: z.string().url(), // Safe for client }) .parse(process.env); ``` #### Production Secrets - [ ] Use Cloudflare Workers secrets for production - [ ] Rotate secrets regularly - [ ] Never hardcode secrets in code - [ ] Audit secret access logs - [ ] Use secret scanning in CI/CD ### Dependencies #### Package Management - [ ] Run `bun audit` regularly - [ ] Review new dependencies before adding - [ ] Check dependency licenses - [ ] Enable Dependabot alerts - [ ] Keep dependencies up to date - [ ] Use lock files (`bun.lockb`) ```bash # Security audit commands bun audit # Check for vulnerabilities bun update --latest # Update dependencies bun pm ls # List all dependencies ``` #### Supply Chain Security - [ ] Verify package authenticity - [ ] Use specific versions (not wildcards) - [ ] Review dependency source code for critical packages - [ ] Monitor for dependency hijacking - [ ] Use SubResource Integrity (SRI) for CDN resources ## Pre-Deployment Phase ### Security Headers #### Configure Headers - [ ] Content Security Policy (CSP) - [ ] X-Frame-Options - [ ] X-Content-Type-Options - [ ] Strict-Transport-Security (HSTS) - [ ] Referrer-Policy - [ ] Permissions-Policy ```typescript // Example: Security headers in Hono app.use("*", async (c, next) => { await next(); c.header("X-Frame-Options", "DENY"); c.header("X-Content-Type-Options", "nosniff"); c.header("Strict-Transport-Security", "max-age=31536000"); c.header("Content-Security-Policy", "default-src 'self'"); }); ``` ### API Security #### tRPC Security - [ ] Validate all inputs with Zod - [ ] Implement rate limiting - [ ] Use proper error codes - [ ] Don't expose internal errors - [ ] Log suspicious activities - [ ] Implement request timeouts ```typescript // Example: Rate limiting middleware const rateLimiter = new Map(); export const rateLimit = middleware(async ({ ctx, next }) => { const key = ctx.ip; const limit = rateLimiter.get(key) || 0; if (limit > 10) { throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); } rateLimiter.set(key, limit + 1); setTimeout(() => rateLimiter.delete(key), 60000); return next(); }); ``` #### CORS Configuration - [ ] Configure allowed origins explicitly - [ ] Don't use wildcard (\*) in production - [ ] Validate Origin header - [ ] Configure allowed methods and headers - [ ] Use credentials carefully ### Client Security #### React Security - [ ] Avoid dangerouslySetInnerHTML - [ ] Sanitize user-generated content - [ ] Use Content Security Policy - [ ] Validate URLs before navigation - [ ] Implement proper error boundaries - [ ] Don't expose sensitive data in state ```typescript // Example: Safe HTML rendering import DOMPurify from 'isomorphic-dompurify' function SafeHTML({ content }: { content: string }) { const sanitized = DOMPurify.sanitize(content) return
} ``` #### Browser Storage - [ ] Don't store sensitive data in localStorage - [ ] Use httpOnly cookies for sessions - [ ] Encrypt sensitive client-side data - [ ] Clear storage on logout - [ ] Implement storage quotas ## Deployment Phase ### Infrastructure Security #### Cloudflare Workers - [ ] Configure WAF rules - [ ] Enable DDoS protection - [ ] Set up rate limiting - [ ] Configure security headers - [ ] Enable bot protection - [ ] Monitor security events ```toml # Example: wrangler.toml security config [env.production] compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] [env.production.rate_limiting] enabled = true requests_per_minute = 60 ``` #### CI/CD Security - [ ] Use least privilege for CI/CD tokens - [ ] Store secrets securely (GitHub Secrets) - [ ] Enable branch protection - [ ] Require code reviews - [ ] Run security checks in pipeline - [ ] Sign commits and releases ```yaml # Example: GitHub Actions security - name: Run security audit run: | bun audit bun test:security - name: SAST Scan uses: github/super-linter@v5 env: VALIDATE_JAVASCRIPT_ES: true VALIDATE_TYPESCRIPT_ES: true ``` ### Monitoring & Logging #### Security Monitoring - [ ] Log authentication attempts - [ ] Monitor for suspicious patterns - [ ] Set up security alerts - [ ] Track rate limit violations - [ ] Monitor dependency vulnerabilities - [ ] Review logs regularly ```typescript // Example: Security event logging function logSecurityEvent(event: { type: string; user?: string; ip: string; details: Record; }) { console.log( JSON.stringify({ timestamp: new Date().toISOString(), severity: "SECURITY", ...event, }), ); } ``` #### Incident Response - [ ] Have incident response plan ready - [ ] Configure security notifications - [ ] Set up backup and recovery - [ ] Document security contacts - [ ] Test incident procedures - [ ] Keep security playbook updated ## Production Phase ### Ongoing Security #### Regular Tasks - [ ] Weekly: Review security alerts - [ ] Monthly: Run dependency audits - [ ] Quarterly: Security assessment - [ ] Annually: Penetration testing - [ ] Ongoing: Security training #### Security Updates - [ ] Monitor security advisories - [ ] Apply patches promptly - [ ] Test updates in staging - [ ] Document security changes - [ ] Communicate with users about security ### Compliance #### Data Protection - [ ] Implement GDPR compliance (if applicable) - [ ] Add privacy policy - [ ] Implement data deletion - [ ] Log data access - [ ] Encrypt personal data #### Security Documentation - [ ] Maintain SECURITY.md - [ ] Document security procedures - [ ] Keep incident log - [ ] Update security checklist - [ ] Train team on security ## Quick Security Wins For immediate security improvements: 1. **Run Security Audit** ```bash bun audit ``` 2. **Add Security Headers** ```typescript // apps/api/src/index.ts app.use(securityHeaders()); ``` 3. **Implement Rate Limiting** ```typescript // apps/api/src/middleware.ts app.use(rateLimit({ limit: 100, window: "1m" })); ``` 4. **Enable HTTPS Redirect** ```typescript // apps/web/src/index.ts if (location.protocol === "http:") { location.replace("https:" + window.location.href.substring(5)); } ``` 5. **Add Input Validation** ```typescript // Use Zod everywhere const schema = z.object({ /* ... */ }); const validated = schema.parse(input); ``` ## Security Resources ### Tools - [OWASP ZAP](https://www.zaproxy.org/) – Security scanning - [Snyk](https://snyk.io/) – Dependency scanning - [GitHub Security](https://github.com/security) – Security features - [Mozilla Observatory](https://observatory.mozilla.org/) – Security assessment ### Documentation - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [React Security](https://react.dev/learn/security) - [Cloudflare Security](https://developers.cloudflare.com/workers/platform/security/) - [Better Auth Docs](https://better-auth.com/docs/security) ### Emergency Contacts - Security Issues: `security@kriasoft.com` - GitHub Security: [Security Advisories](https://github.com/kriasoft/react-starter-kit/security) - CVE Database: [MITRE CVE](https://cve.mitre.org/) --- _Review this checklist regularly and update based on new threats and best practices._ ================================================ FILE: docs/security/incident-playbook.md ================================================ # Security Incident Response Playbook This playbook provides step-by-step procedures for handling security incidents in React Starter Kit projects. Each procedure includes specific actions, tools, and decision criteria. ## Quick Reference - **Security Email**: `security@kriasoft.com` - **Incident Tracking**: GitHub Security Advisories - **Communication Channel**: Email (encrypted when possible) - **Escalation**: Project maintainers via GitHub ## Incident Classification ### Determining Severity Use this decision tree to classify incidents: ``` Is remote code execution possible? ├─ Yes → CRITICAL (P0) └─ No → Can authentication be bypassed? ├─ Yes → CRITICAL (P0) └─ No → Is sensitive data exposed? ├─ Yes (all users) → CRITICAL (P0) ├─ Yes (subset) → HIGH (P1) └─ No → Is privilege escalation possible? ├─ Yes → HIGH (P1) └─ No → Is XSS present? ├─ Yes (auth flow) → HIGH (P1) ├─ Yes (other) → MEDIUM (P2) └─ No → Is CSRF possible? ├─ Yes → MEDIUM (P2) └─ No → LOW (P3) ``` ## Phase 1: Initial Response ### Step 1.1: Acknowledge Report (0-2 hours) **Actions:** 1. Send acknowledgment email with template: ``` Subject: [RSK-SEC-YYYY-NNN] Security Report Received Thank you for your security report. We have received your submission and assigned tracking ID: RSK-SEC-YYYY-NNN We will begin our initial assessment and respond within [TIMEFRAME]. Please keep this vulnerability confidential while we investigate. ``` 2. Create private GitHub issue for tracking 3. Assign initial responder **Tools:** Email client, GitHub Issues (private) ### Step 1.2: Initial Assessment (2-24 hours) **Actions:** 1. Review report for completeness 2. Attempt to reproduce vulnerability 3. Determine affected components 4. Classify severity using decision tree **Decision Points:** - If cannot reproduce – Request clarification - If critical – Immediately notify maintainers - If valid – Proceed to Phase 2 ### Step 1.3: Form Response Team **For Critical/High severity:** - Lead: Project maintainer - Developer: Fix implementation - Reviewer: Code review and testing - Communicator: External updates **For Medium/Low severity:** - Lead: Available maintainer - Developer: Fix implementation ## Phase 2: Investigation & Containment ### Step 2.1: Deep Dive Analysis (Day 1-2) **Actions:** 1. Set up isolated test environment 2. Reproduce vulnerability with minimal test case 3. Identify root cause 4. Document attack vectors 5. Check for similar vulnerabilities **Checklist:** - [ ] Vulnerability reproduced - [ ] Root cause identified - [ ] Attack surface mapped - [ ] Similar code patterns checked - [ ] Impact assessment complete ### Step 2.2: Temporary Mitigation (If Critical) **Actions:** 1. Develop temporary workaround 2. Test workaround doesn't break functionality 3. Document workaround for users 4. Publish security bulletin with mitigation **Template for Security Bulletin:** ```markdown ## Security Bulletin: [TITLE] **Date**: [DATE] **Severity**: [CRITICAL/HIGH] **Status**: Under Investigation ### Summary We are investigating a security vulnerability in React Starter Kit. ### Temporary Mitigation Until a patch is available, users should: 1. [Specific mitigation steps] 2. [Additional steps] ### Timeline - Patch expected: [DATE] - Full disclosure: After patch ### Contact Report issues to: `security@kriasoft.com` ``` ## Phase 3: Development & Testing ### Step 3.1: Develop Fix (Varies by severity) **Actions:** 1. Create private branch for fix 2. Implement minimal fix (no refactoring) 3. Add regression tests 4. Document code changes **Code Review Checklist:** - [ ] Fix addresses root cause - [ ] No new vulnerabilities introduced - [ ] Tests cover vulnerability scenario - [ ] Changes are minimal and focused - [ ] No sensitive info in comments/commits ### Step 3.2: Testing Protocol **Test Environments:** 1. Local development 2. Isolated staging 3. Integration testing 4. Performance impact **Test Cases:** - [ ] Original PoC no longer works - [ ] Legitimate functionality preserved - [ ] No performance regression - [ ] No new error conditions - [ ] Edge cases handled ### Step 3.3: Prepare Release **Actions:** 1. Update version numbers 2. Write release notes 3. Prepare security advisory 4. Request CVE (if applicable) **CVE Request Template:** ``` [Contact GitHub Security for CVE] Repository: react-starter-kit Vulnerability Type: [TYPE] Affected Versions: < X.Y.Z Fixed Version: X.Y.Z Description: [DESCRIPTION] ``` ## Phase 4: Release & Disclosure ### Step 4.1: Coordinated Release **Release Checklist:** - [ ] Code merged to main branch - [ ] Version tagged and released - [ ] Security advisory drafted - [ ] Reporter notified of release date - [ ] Release notes prepared ### Step 4.2: Public Disclosure **Actions:** 1. Publish GitHub Security Advisory 2. Update SECURITY.md if needed 3. Send notification to users (if critical) 4. Credit reporter **Security Advisory Template:** ```markdown ## [CVE-YYYY-NNNNN] [Vulnerability Title] **Severity**: [Critical/High/Medium/Low] **Affected Versions**: < X.Y.Z **Patched Version**: X.Y.Z ### Description [Clear description of vulnerability] ### Impact [Potential impact on users] ### Patches Update to version X.Y.Z or later. ### Workarounds [If any temporary workarounds exist] ### References - [Links to fixes] - [Links to documentation] ### Credit Reported by [Name] ([Organization]) ``` ### Step 4.3: User Communication **For Critical vulnerabilities:** 1. Email registered users (if applicable) 2. Post on project blog/website 3. Social media announcement 4. Update documentation **Communication Template:** ``` Subject: [ACTION REQUIRED] Security Update for React Starter Kit A critical security vulnerability has been discovered and patched. Action Required: 1. Update to version X.Y.Z immediately 2. Review security advisory: [LINK] 3. Apply any additional mitigations Details: [BRIEF DESCRIPTION] Questions: `security@kriasoft.com` ``` ## Phase 5: Post-Incident Review ### Step 5.1: Incident Retrospective (Within 1 week) **Meeting Agenda:** 1. Timeline review 2. What went well 3. What could improve 4. Action items 5. Policy updates needed **Questions to Answer:** - How was the vulnerability introduced? - Why wasn't it caught earlier? - How can we prevent similar issues? - Was our response adequate? - What tools/processes need improvement? ### Step 5.2: Implement Improvements **Common Improvements:** - Add security linting rules - Enhance test coverage - Update coding guidelines - Improve dependency management - Add security checkpoints to CI/CD ### Step 5.3: Documentation Updates **Update as needed:** - This playbook - SECURITY.md - Development guidelines - CI/CD configurations - Security checklist ## Appendix A: Tools & Resources ### Security Tools - **Dependency Scanning**: `bun audit`, Dependabot - **Static Analysis**: ESLint security plugins - **Secret Scanning**: GitHub secret scanning, truffleHog - **SAST**: Semgrep, CodeQL - **Testing**: Vitest for security tests ### Communication Tools - **Encrypted Email**: PGP/GPG - **Secure File Transfer**: Age encryption - **Private Issues**: GitHub Security Advisories ### External Resources - [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories) - [CVE Request Process](https://cve.mitre.org/cve/request_id.html) - [OWASP Incident Response](https://owasp.org/www-project-incident-response) - [NIST Incident Handling Guide](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-61r2.pdf) ## Appendix B: Contact Templates ### Reporter Follow-up ``` Subject: Re: [RSK-SEC-YYYY-NNN] Status Update Thank you for your patience. Here's an update on your report: Status: [In Progress/Testing Fix/Ready for Release] Severity: [Confirmed as X] Timeline: [Expected resolution date] [Any questions for reporter] We'll notify you before public disclosure. ``` ### Maintainer Escalation ``` Subject: [URGENT] Critical Security Issue - Immediate Action Required A critical vulnerability has been reported: Tracking: RSK-SEC-YYYY-NNN Type: [Vulnerability type] Impact: [Brief impact description] Status: [Confirmed/Under Investigation] Required Actions: 1. [Immediate actions needed] 2. [Review assignments] Details in private issue: [Link] ``` ### Release Notification ``` Subject: Security Release Scheduled - [DATE] Security release details: Version: X.Y.Z Release Date: [DATE TIME UTC] Severity: [Level] CVE: [If assigned] Pre-release checklist: - [ ] Code reviewed and tested - [ ] Advisory prepared - [ ] Reporter notified - [ ] Release notes ready Please confirm readiness by [DATE]. ``` ## Revision History - v1.0.0 - Initial playbook creation - Updates logged in commit history --- _This playbook is a living document. Update it based on lessons learned from each incident._ ================================================ FILE: docs/security/policy-template.md ================================================ # Security Policy & Incident Response Plan ## Our Security Commitment The [PROJECT_NAME] team takes security seriously. We appreciate responsible disclosure of vulnerabilities and are committed to working with security researchers to keep our project secure. This document outlines our security policy, incident response procedures, and how to report vulnerabilities. ## Scope This security policy applies to vulnerabilities discovered within the `[REPOSITORY_NAME]` repository. The scope includes: - [List specific components, modules, or features] - [Example: Core application code and configurations] - [Example: API endpoints and authentication systems] - [Example: Database schemas and data handling] - [Example: Build and deployment processes] ### Out of Scope The following are considered **out of scope** for this policy: - Vulnerabilities in third-party dependencies already publicly disclosed - Issues requiring physical access or compromised credentials - Social engineering attacks - [Add project-specific exclusions] ## Supported Versions We provide security updates for the following versions: | Version | Supported | | ------- | ------------------ | | [X.Y.Z] | :white_check_mark: | | [X.Y-1] | :x: | ## Incident Response - **Report Security Issues**: `[SECURITY_EMAIL]` - **Initial Response**: Within [RESPONSE_TIME] - **Critical Issues**: Escalated immediately to maintainers ## Reporting a Vulnerability **⚠️ DO NOT report security vulnerabilities through public GitHub issues.** Report to: **`[SECURITY_EMAIL]`** ### Include in Your Report 1. **Description**: Clear explanation of the vulnerability and impact 2. **Steps to Reproduce**: Minimal steps to demonstrate the issue 3. **Proof of Concept**: Code or screenshots if applicable 4. **Affected Version**: Branch or commit hash 5. **Suggested Fix**: Optional recommendations ## Incident Response Process ### Severity Classification | Level | Description | Examples | | ----------------- | ----------------------------- | --------------------------------------------------------- | | **Critical (P0)** | Immediate threat to all users | Remote code execution, authentication bypass, data breach | | **High (P1)** | Significant security impact | Privilege escalation, data exposure, XSS in auth flows | | **Medium (P2)** | Limited security impact | XSS in non-critical areas, CSRF vulnerabilities | | **Low (P3)** | Minor security issues | Information disclosure, security misconfigurations | ### Response Timeline | Severity | Initial Response | Fix Target | Disclosure | | -------- | ---------------- | ----------- | ------------ | | Critical | 2 days | 14 days | Upon patch | | High | 3 days | 30 days | Upon patch | | Medium | 5 days | 60 days | Upon patch | | Low | 7 days | Best effort | With release | ### Incident Response Phases #### Phase 1: Detection & Analysis - Acknowledge receipt and assign tracking ID - Validate and reproduce vulnerability - Assess severity and impact - Notify team if critical #### Phase 2: Containment - Implement temporary mitigations - Document affected components - Begin fix development - Prepare communication plan #### Phase 3: Remediation - Develop and test permanent fix - Prepare security patch - Request CVE if appropriate - Coordinate disclosure timeline #### Phase 4: Recovery & Disclosure - Release patched version - Publish security advisory - Update documentation - Credit reporter #### Phase 5: Post-Incident Review - Document lessons learned - Update security practices - Improve detection - Update policies ## Communication Expectations - All communications via email - Regular updates throughout process - Clear explanation if not a vulnerability - Confidentiality until patched ## Safe Harbor We consider security research conducted in good faith to be: - Authorized under applicable laws - Exempt from Terms of Service restrictions - Protected from legal action Requirements for safe harbor: - Follow this policy - Report responsibly - Avoid privacy violations - No exploitation beyond demonstration ## Recognition We value security researchers' contributions: - Public credit in advisories (unless anonymous) - Security acknowledgments - Letter of appreciation upon request ## Security Best Practices for Users ### Essential Security Measures 1. **Secret Management** - Never commit secrets to version control - Use environment variables for sensitive data - Implement secret rotation - Enable secret scanning 2. **Authentication & Authorization** - Implement proper session management - Use strong password policies - Enable multi-factor authentication - Regular auth system updates 3. **Dependencies** - Regular security audits - Keep dependencies updated - Review licenses and advisories - Use dependency scanning tools 4. **Deployment** - HTTPS everywhere - Proper CORS policies - Security headers (CSP, HSTS, etc.) - Regular security assessments 5. **Code Security** - Input validation - Output encoding - Parameterized queries - Principle of least privilege ## Additional Resources - [Security Advisories]([GITHUB_SECURITY_URL]) - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - [Project Documentation]([DOCS_URL]) - [Security Checklist]([CHECKLIST_URL]) --- --- _Template Instructions: Replace all [BRACKETS] with project-specific information and adjust timelines to match your team's capacity._ Thank you for helping us keep [PROJECT_NAME] secure! ================================================ FILE: docs/specs/auth-form.md ================================================ # Auth Flow UX Specification Target UX inspired by Linear's authentication flow. ## Design Principles 1. **Progressive disclosure** – Show only what's needed at each step 2. **Method selection first** – Let users choose their auth method before showing inputs 3. **Minimal friction** – Reduce cognitive load with focused, single-purpose views 4. **Clear navigation** – Easy to go back and switch methods ## Flow Structure ### Login (`/login`) ```text Step 1: Method Selection ┌─────────────────────────────┐ │ [Logo] │ │ │ │ Log in to [App Name] │ │ │ │ ┌───────────────────────┐ │ │ │ Continue with Google │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ Continue with email │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ Log in with passkey │ │ │ └───────────────────────┘ │ │ │ │ Don't have an account? │ │ Sign up │ └─────────────────────────────┘ Step 2: Email Input (after clicking "Continue with email") ┌─────────────────────────────┐ │ [Logo] │ │ │ │ What's your email address? │ │ │ │ ┌───────────────────────┐ │ │ │ Enter your email... │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ Continue with email │ │ │ └───────────────────────┘ │ │ │ │ ← Back to login │ └─────────────────────────────┘ Step 3: OTP Verification ┌─────────────────────────────┐ │ [Logo] │ │ │ │ Check your email │ │ We sent a code to │ │ user@example.com │ │ │ │ ┌─┬─┬─┬─┬─┬─┐ │ │ │ │ │ │ │ │ │ (6 digits) │ │ └─┴─┴─┴─┴─┴─┘ │ │ │ │ Resend code │ │ ← Back │ └─────────────────────────────┘ ``` ### Signup (`/signup`) ```text Step 1: Method Selection ┌─────────────────────────────┐ │ [Logo] │ │ │ │ Create your account │ │ │ │ ┌───────────────────────┐ │ │ │ Continue with Google │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ Continue with email │ │ │ └───────────────────────┘ │ │ │ │ By signing up, you agree │ │ to our Terms and Privacy │ │ Policy. │ │ │ │ Already have an account? │ │ Log in │ └─────────────────────────────┘ Step 2: Email Input (after clicking "Continue with email") ┌─────────────────────────────┐ │ [Logo] │ │ │ │ What's your email address? │ │ │ │ ┌───────────────────────┐ │ │ │ Enter your email... │ │ │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ Continue with email │ │ │ └───────────────────────┘ │ │ │ │ By signing up, you agree │ │ to our Terms and Privacy │ │ Policy. │ │ │ │ ← Back to sign up │ └─────────────────────────────┘ Step 3: OTP Verification ┌─────────────────────────────┐ │ [Logo] │ │ │ │ Check your email │ │ We sent a code to │ │ user@example.com │ │ │ │ ┌─┬─┬─┬─┬─┬─┐ │ │ │ │ │ │ │ │ │ (6 digits) │ │ └─┴─┴─┴─┴─┴─┘ │ │ │ │ Resend code │ │ ← Back to email │ └─────────────────────────────┘ ``` Note: No passkey option on signup (passkeys require existing account). ## Third-Party Auth Behavior - **Google**: On failure or user cancel, return to method selection with inline error. - **Passkey**: On failure (not supported, no credential, user cancel), return to method selection with inline error and a short hint to use email instead. - **Network/system errors**: Show a non-blocking toast and keep the user on the current step. ## Key Differences from Current Implementation | Aspect | Current | Target | | ------------ | --------------------------------- | ----------------------------------------- | | Initial view | All methods + email input visible | Method selection buttons only | | Email input | Always visible with divider | Separate step after clicking email button | | Layout | Card with optional right panel | Centered content, no card | | Headings | "Welcome" / "Welcome back" | "Create your account" / "Log in to [App]" | | Navigation | None | "Back to login" link between steps | | Terms | Footer on both pages | Inline on signup only | ## Copy & Labels | Screen | Heading | CTA | Helper | | ------------- | -------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Login method | Log in to [App Name] | Continue with Google / Continue with email / Log in with passkey | Don't have an account? Sign up | | Login email | What's your email address? | Continue with email | ← Back to login | | Login OTP | Check your email | Verify code | Resend code / ← Back to email | | Signup method | Create your account | Continue with Google / Continue with email | By signing up, you agree to our Terms and Privacy Policy. Already have an account? Log in | | Signup email | What's your email address? | Continue with email | By signing up, you agree to our Terms and Privacy Policy. ← Back to sign up | | Signup OTP | Check your email | Verify code | Resend code / ← Back to email | ## Component Architecture ### State Machine ```text ┌─────────────┐ click email ┌───────────┐ submit email ┌──────────────┐ │ METHOD │ ──────────────────→ │ EMAIL │ ────────────────→ │ OTP │ │ SELECTION │ │ INPUT │ │ VERIFICATION │ └─────────────┘ ←─────────────── └───────────┘ ←─────────────── └──────────────┘ back back/cancel ``` ### Suggested Step Type ```ts type AuthStep = "method" | "email" | "otp"; ``` ### Props ```ts interface AuthFormProps { mode: "login" | "signup"; onSuccess?: () => void; } ``` ## Visual Design - **Layout**: Centered, max-width ~400px, no card wrapper - **Logo**: Centered above heading - **Buttons**: Full-width, stacked vertically with consistent spacing - **Typography**: Clear hierarchy – heading (h1), body text, links - **Back link**: Left-aligned, subtle styling, positioned below form ## Transitions - Smooth fade/slide between steps (optional enhancement) - Maintain scroll position when navigating back ## Error Handling - Inline error messages below relevant input - Clear error state when user modifies input - Specific messages for common errors (invalid email, expired OTP, rate limit) - Third-party auth error surfaced on method selection with a one-line explanation ## Loading & Empty States - Method selection: disable buttons and show spinner during third-party auth initiation - Email input: disable CTA while sending code; show spinner inside button - OTP: disable inputs while verifying; show progress indicator - Resend: disabled until cooldown expires; show countdown ## OTP Constraints - 6 digits, numeric only - Expires after 10 minutes - Resend cooldown: 30 seconds - Rate limit: 5 attempts per hour per email ## Accessibility - Focus management: auto-focus first input when entering email/OTP steps - Keyboard navigation: Enter to submit, Escape to go back (optional) - Screen reader announcements for step changes ## Open Questions - [ ] Should the logo link to home or be static? - [ ] Add "Remember me" checkbox? - [ ] Show password option as alternative to OTP? - [ ] Magic link option in addition to OTP? - [ ] Should login email step include a short notice about email delivery/usage? ================================================ FILE: docs/specs/billing.md ================================================ # Stripe Billing Integration ## Overview Stripe billing via `@better-auth/stripe` plugin. Billing is tightly coupled with auth – customer lifecycle, subscription state, and webhook handling are managed by the same system that manages sessions and organizations. **Non-goals:** Usage-based billing, metered pricing, one-time payments, invoicing, Stripe Elements/embedded checkout, tax calculation, multi-currency. These can be added incrementally. ## Decision Rationale **Better Auth plugin over raw Stripe SDK:** RSK already uses Better Auth for auth, organizations, and sessions. The plugin handles customer sync, subscription lifecycle, webhook verification, and org-level billing – eliminating significant glue code. **Hosted Checkout over embedded Elements:** Stripe Checkout is PCI-compliant out of the box, requires no `@stripe/stripe-js` client dependency, and handles payment method selection, 3D Secure, and receipts. The upgrade path to embedded Elements exists but isn't needed initially. **`createCustomerOnSignUp: true`:** Creates Stripe customer records eagerly on signup. Simplifies the upgrade flow and enables Stripe-side analytics. Trade-off: creates unused records for users who never upgrade. ## Architecture ```text ┌─────────────┐ POST /api/auth/subscription/upgrade ┌───────────────┐ │ Browser │ ──────────────────────────────────────────→ │ API Worker │ │ (app) │ │ (Hono) │ │ │ ←── 302 redirect │ │ │ │──→ Stripe Checkout (hosted) │ Better Auth │ │ │ │ + stripe() │ │ │ POST /api/auth/stripe/webhook │ plugin │ │ │ │ │ │ │ Stripe ────────→│ webhook ──→ │ │ │ │ update DB │ │ │ GET /api/trpc/billing.subscription │ │ │ │ ──────────────────────────────────────────→ │ tRPC router │ └─────────────┘ ←── subscription data (TanStack Query) └───────────────┘ ``` **Data flow:** 1. User clicks "Upgrade" – Better Auth client calls `auth.subscription.upgrade()` 2. Plugin creates Stripe Checkout session – redirects browser to Stripe 3. User completes payment – Stripe sends webhook to `/api/auth/stripe/webhook` 4. Plugin verifies signature, updates `subscription` table – client refetches via tRPC **Why tRPC for reads, Better Auth client for mutations:** Subscription queries benefit from TanStack Query caching, batching, and stale-while-revalidate. Mutations (upgrade, cancel, portal) go through the auth client because the plugin handles Stripe API calls, session validation, and org authorization internally. ## Billing Reference Billing is tied to `session.activeOrganizationId` when present; otherwise falls back to `user.id` for personal use. The plugin enforces one active subscription per reference ID. - **Organization context:** `referenceId = activeOrganizationId` – only org owner/admin can manage billing - **No organization:** `referenceId = user.id` – user manages their own subscription - The server derives `referenceId` from the session – no client-side param needed - The billing query key includes `activeOrgId`, so switching organizations automatically fetches fresh billing data via TanStack Query ## Database Schema The plugin uses `stripeCustomerId` on the `user` and `organization` tables, and a `subscription` table. The plugin manages the subscription table – no manual inserts/updates needed. Schema must match plugin expectations. After auth config changes, update the schema in `db/schema/` and run `bun db:generate` to create migrations. ## Plan Configuration Plan limits defined in `apps/api/lib/plans.ts` (single source of truth), referenced by both auth plugin config and tRPC router. Price IDs come from environment variables (`STRIPE_*_PRICE_ID`). Config-as-code is the simplest correct approach – plans rarely change and this makes them testable and version-controlled. **Escape hatch:** The plugin accepts `plans: () => StripePlan[]` for dynamic plans. Switch to this only when a real use case requires runtime plan management (e.g., admin dashboard for plan CRUD). **Limits enforcement:** The `limits` object is returned by the `billing.subscription` tRPC query. Enforce limits in application logic (tRPC middleware, UI guards), not in the plugin itself. ## Environment Variables | Variable | Prefix | | ---------------------------- | -------- | | `STRIPE_SECRET_KEY` | `sk_` | | `STRIPE_WEBHOOK_SECRET` | `whsec_` | | `STRIPE_STARTER_PRICE_ID` | `price_` | | `STRIPE_PRO_PRICE_ID` | `price_` | | `STRIPE_PRO_ANNUAL_PRICE_ID` | `price_` | Set in `.env.local` (local dev), Cloudflare secrets (staging/prod). ## Webhook Setup The plugin registers `POST /api/auth/stripe/webhook` automatically. It handles: - `checkout.session.completed` – activates subscription - `customer.subscription.created` – records new subscription - `customer.subscription.updated` – syncs status, cancellation scheduling - `customer.subscription.deleted` – marks subscription canceled ### Stripe Dashboard Configuration ``` Endpoint URL: https:///api/auth/stripe/webhook Events: - checkout.session.completed - customer.subscription.created - customer.subscription.updated - customer.subscription.deleted ``` ### Local Development ```bash stripe listen --forward-to localhost:5173/api/auth/stripe/webhook # Copy the whsec_... signing secret to .env.local ``` ### Raw Body Requirement Stripe webhook verification requires the raw request body. The plugin handles this via `request.text()` – no special Hono middleware needed. ## Testing The plugin tests its own internals (webhooks, checkout, subscription lifecycle, authorization). App tests cover the seams we own: - **Router** (`apps/api/routers/billing.test.ts`) – free plan fallback, plan limits mapping, unknown plan rejection, response shape - **Query** (`apps/app/lib/queries/billing.test.ts`) – cache key includes org ID, null normalization, distinct keys per org, prefix for bulk invalidation Checkout and webhook flows are not retested at app level – verified via `stripe listen` during development. ## File Map | Layer | Files | | ------ | ------------------------------------------------------------------------------------------------------ | | Schema | `db/schema/subscription.ts`, `stripeCustomerId` in `db/schema/user.ts` and `db/schema/organization.ts` | | Server | `apps/api/lib/plans.ts`, `apps/api/lib/stripe.ts`, stripe plugin in `apps/api/lib/auth.ts` | | Router | `apps/api/routers/billing.ts`, registered in `apps/api/lib/app.ts` | | Client | `stripeClient` in `apps/app/lib/auth.ts`, `apps/app/lib/queries/billing.ts` | | UI | Billing card in `apps/app/routes/(app)/settings.tsx` | | Tests | `apps/api/routers/billing.test.ts`, `apps/app/lib/queries/billing.test.ts` | ================================================ FILE: docs/specs/infra-terraform.md ================================================ # Infrastructure Terraform Specification ## Overview Two deployment stacks with clear separation of concerns. **Non-goals:** Multi-region orchestration, blue-green deployments, auto-scaling policies. These belong in CI/CD or dedicated tooling. | Stack | Components | Use Case | | ------------------- | ------------------------------------------- | ----------------------- | | **edge** (default) | Hyperdrive, DNS (Workers via Wrangler) | Most SaaS apps | | **hybrid** (opt-in) | Cloud Run, Cloud SQL, GCS + optional CF DNS | GCP services, Vertex AI | ## Directory Structure ```bash infra/ modules/ # Atomic resources (no credentials) cloudflare/ hyperdrive/ # Database connection pooling r2-bucket/ # Object storage dns/ # Proxied DNS records gcp/ cloud-run/ # Container deployment cloud-sql/ # Managed PostgreSQL gcs/ # Object storage stacks/ # Architectural compositions edge/ # Hyperdrive + DNS (Workers via Wrangler) hybrid/ # GCP + optional CF DNS envs/ # Terraform roots (providers + backend + state) dev/edge/ preview/edge/ staging/edge/ prod/edge/ templates/ env-roots/hybrid/ # Copy to enable hybrid backend-r2.example.hcl # Remote state for edge backend-gcs.example.hcl # Remote state for hybrid ``` ## Module Contract Modules must NOT define `provider` blocks. Non-HashiCorp providers require `required_providers` to specify the source: ```hcl # Cloudflare modules declare source only (no version): terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" } } } ``` Version constraints live exclusively in env roots. This keeps modules reusable while centralizing version management. ## Provider Versions Canonical versions (single source of truth): | Provider | Version | | ---------- | -------------- | | terraform | `>= 1.12, < 2` | | cloudflare | `~> 5.0` | | google | `~> 7.0` | ## Design Decisions ### Explicit Roots Over Dispatcher Each `(environment, stack)` pair gets its own Terraform root with isolated state. ```bash terraform -chdir=infra/envs/prod/edge apply ``` **Why not a dispatcher?** A `variable "stack"` that switches configs: - Destroys one stack when switching to another - Requires separate backends anyway - Creates awkward `module.edge[0].x` references ### No Backend by Default Terraform uses local state when no backend is configured. Remote backends require pre-existing buckets and credentials. **Rationale:** Zero-friction onboarding. Add remote backend when ready for team collaboration. ### Providers in Env Roots Only Only env roots define `provider` blocks with credentials. Modules declare `required_providers` for source resolution only (no versions, no credentials). **Rationale:** Keeps modules reusable. Version constraints and credentials stay in one place per environment. ### Preview Uses Edge Only PR previews need fast spin-up and low cost. Cloudflare Workers: no cold starts, instant deploys, minimal cost. ## Secrets ```bash # Via environment variables (CI/CD) export TF_VAR_cloudflare_api_token="..." terraform -chdir=infra/envs/prod/edge apply # Or local terraform.tfvars (gitignored) ``` Mark sensitive variables: ```hcl variable "cloudflare_api_token" { type = string sensitive = true } ``` ## Switching to Remote Backend ### Edge Stack (R2) ```bash cp infra/templates/backend-r2.example.hcl infra/envs/prod/edge/backend.hcl terraform -chdir=infra/envs/prod/edge init -backend-config=backend.hcl -migrate-state ``` ### Hybrid Stack (GCS) ```bash cp infra/templates/backend-gcs.example.hcl infra/envs/prod/hybrid/backend.hcl terraform -chdir=infra/envs/prod/hybrid init -backend-config=backend.hcl -migrate-state ``` ## Multi-Region Use separate roots: `envs/prod-eu/edge`, `envs/prod-us/edge`. Each manages its own state. ## Naming Conventions ### Resource values Cloud resources use `{project_slug}-{environment}`; lowercase alphanumeric and hyphens only: `^[a-z0-9-]+$`. ### Resource identifiers One simple set of rules: 1. Name the thing being created (provider-native noun, singular). ```hcl resource "cloudflare_hyperdrive_config" "hyperdrive" {} resource "cloudflare_r2_bucket" "bucket" {} resource "cloudflare_dns_record" "record" {} resource "google_cloud_run_v2_service" "service" {} resource "google_sql_database_instance" "instance" {} ``` 2. If you have multiples, suffix with the role. ```hcl resource "cloudflare_r2_bucket" "uploads" {} resource "cloudflare_r2_bucket" "backups" {} ``` 3. Module names describe architectural role; resource names describe the concrete thing. ```hcl module "hyperdrive" { # contains: cloudflare_hyperdrive_config.hyperdrive } # → module.hyperdrive.id ``` ## Known Limitations ### Hyperdrive Database URL Parsing The hyperdrive module parses `database_url` via regex to extract individual connection parameters. This works reliably with Neon URLs (which use URL-safe generated credentials) but has limitations: - Port must be explicitly specified (e.g., `:5432`) - Credentials must not contain unencoded `@` or `:` characters - Validation fails fast with a descriptive error message For non-Neon databases with special characters in credentials, consider modifying the module to accept individual connection parameters instead. ================================================ FILE: docs/specs/prefixed-ids.md ================================================ # Prefixed CUID2 Database IDs All database primary keys use application-generated, prefixed [CUID2](https://github.com/paralleldrive/cuid2) identifiers: `usr_ght4k2jxm7pqbv01`. The prefix encodes entity type, improving debuggability across logs, URLs, and support conversations. Same pattern as Stripe (`cus_`, `sub_`), Clerk (`user_`, `org_`). IDs are opaque strings – clients must not parse or decode them. ## Format ```text {prefix}_{body} Example: usr_ght4k2jxm7pqbv01 └──3──┘ └─16─┘ 20 chars total ``` - **Prefix:** 3-char lowercase entity type - **Body:** 16-char CUID2 (alphanumeric, starts with letter) ## Prefix Map Defined in `db/schema/id.ts`. Keys are Better Auth model names (not table names). | Model | Prefix | Notes | | -------------- | ------ | ------------------------------------------------------- | | `user` | `usr` | | | `session` | `ses` | | | `account` | `idn` | Maps to `identity` table via `account.modelName` config | | `verification` | `vfy` | | | `organization` | `org` | | | `member` | `mem` | | | `invitation` | `inv` | | | `passkey` | `pky` | | | `subscription` | `sub` | | ## API ```ts import { generateAuthId, generateId } from "@repo/db"; // Auth tables – type-checked against the prefix map generateAuthId("user"); // "usr_ght4k2jxm7pqbv01" // Non-auth tables – any 3-letter prefix generateId("upl"); // "upl_m8xk3jvqp2wnba09" ``` Throws on unknown auth models or invalid prefixes. The CUID2 generator is lazy-initialized (no module-level side effects – safe for Workers isolates). ## Integration Points **Better Auth** – `apps/api/lib/auth.ts`: ```ts advanced: { database: { generateId: ({ model }) => generateAuthId(model as AuthModel), }, }, ``` **Drizzle schema** – `db/schema/*.ts` use `.$defaultFn()` instead of `gen_random_uuid()`: ```ts id: text().primaryKey().$defaultFn(() => generateAuthId("user")), ``` ## Adding a New Model 1. Add the prefix to `AUTH_PREFIX` in `db/schema/id.ts` 2. Use `.$defaultFn(() => generateAuthId("modelName"))` in the schema 3. Re-generate migrations: `bun db:generate` ================================================ FILE: docs/testing.md ================================================ # Testing The project uses [Vitest](https://vitest.dev/) for both API and frontend tests. Two test projects run from a single root config – API tests in Node, frontend tests in [Happy DOM](https://github.com/capricorn86/happy-dom). ## Configuration The root config defines both projects: ```ts // vitest.config.ts export default defineConfig({ test: { projects: ["apps/api", "apps/app"], }, }); ``` `apps/api` has its own `vitest.config.ts`; `apps/app` uses an inline `test` block in `vite.config.ts`: | Project | Environment | Setup file | | ---------- | -------------- | ----------------- | | `apps/api` | Node (default) | – | | `apps/app` | `happy-dom` | `vitest.setup.ts` | The app setup file registers [jest-dom](https://github.com/testing-library/jest-dom) matchers like `toBeInTheDocument()`: ```ts // apps/app/vitest.setup.ts import "@testing-library/jest-dom/vitest"; ``` ## Running Tests ```bash bun test # All projects, watch mode bun test --run # Single run (no watch) bun test --project @repo/api # API tests only bun test --project @repo/app # Frontend tests only bun test billing # Filter by filename ``` ## File Conventions - Test files live next to the code they test – `billing.ts` → `billing.test.ts` - Import everything from `vitest`, not globals: ```ts import { describe, expect, it, vi } from "vitest"; ``` ## Testing tRPC Procedures Use `createCallerFactory` to invoke procedures directly without HTTP. Build a minimal context mock with only the fields the procedure accesses: ```ts // apps/api/routers/billing.test.ts import { describe, expect, it, vi } from "vitest"; import type { TRPCContext } from "../lib/context"; import { createCallerFactory } from "../lib/trpc"; import { billingRouter } from "./billing"; const createCaller = createCallerFactory(billingRouter); function testCtx({ userId = "user-1", activeOrgId = undefined as string | undefined, subscription = undefined as Record | undefined, } = {}) { const ctx: TRPCContext = { req: new Request("http://localhost"), info: {} as TRPCContext["info"], session: { id: "s-1", createdAt: new Date(), updatedAt: new Date(), userId, expiresAt: new Date(Date.now() + 60_000), token: "token", activeOrganizationId: activeOrgId, }, user: { id: userId, createdAt: new Date(), updatedAt: new Date(), email: "test@example.com", emailVerified: true, name: "Test User", }, db: { query: { subscription: { findFirst: vi.fn().mockResolvedValue(subscription), }, }, } as unknown as TRPCContext["db"], dbDirect: {} as TRPCContext["dbDirect"], cache: new Map(), env: {} as TRPCContext["env"], }; return ctx; } describe("billing.subscription", () => { it("returns free plan defaults when no subscription exists", async () => { const result = await createCaller(testCtx()).subscription(); expect(result).toEqual({ plan: "free", status: null, periodEnd: null, cancelAtPeriodEnd: false, limits: { members: 1 }, }); }); it("throws on unknown plan name", async () => { await expect( createCaller( testCtx({ subscription: { plan: "enterprise", status: "active" } }), ).subscription(), ).rejects.toThrow('Unknown plan "enterprise"'); }); }); ``` Key points: - `createCallerFactory(router)` from `@trpc/server` – calls procedures in-process, no network layer - Cast partial DB mocks with `as unknown as TRPCContext["db"]` – only stub the methods your procedure actually calls - Use `vi.fn().mockResolvedValue()` for async Drizzle query methods ## Testing Utility Functions Pure functions need no mocking – just import and assert: ```ts // apps/app/lib/errors.test.ts import { describe, expect, it } from "vitest"; import { getErrorMessage, isUnauthenticatedError } from "./errors"; describe("getErrorMessage", () => { it("extracts message from Error instances", () => { expect(getErrorMessage(new Error("Something broke"))).toBe( "Something broke", ); }); it("returns fallback for unknown shapes", () => { expect(getErrorMessage(null)).toBe("An unexpected error occurred"); }); }); ``` ## Testing Query Options Test TanStack Query option factories by inspecting query keys. Use a real `QueryClient` with retries disabled to test cache helpers: ```ts // apps/app/lib/queries/session.test.ts import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import { getCachedSession, isAuthenticated, sessionQueryKey } from "./session"; function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { retry: false } }, }); } describe("isAuthenticated", () => { it("returns true when both user and session exist", () => { const queryClient = createQueryClient(); queryClient.setQueryData(sessionQueryKey, { user: { id: "user-1", email: "test@example.com" }, session: { id: "session-1", expiresAt: new Date() }, }); expect(isAuthenticated(queryClient)).toBe(true); }); it("returns false when no session data cached", () => { expect(isAuthenticated(createQueryClient())).toBe(false); }); }); ``` ## Testing React Components The app project includes [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) with Happy DOM. Components render in a simulated DOM: ```ts // apps/app/components/example.test.tsx import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { MyComponent } from "./my-component"; describe("MyComponent", () => { it("renders the label", () => { render(); expect(screen.getByText("Hello")).toBeInTheDocument(); }); it("calls onClick when button is pressed", async () => { const user = userEvent.setup(); const onClick = vi.fn(); render(); await user.click(screen.getByRole("button")); expect(onClick).toHaveBeenCalledOnce(); }); }); ``` ::: tip Use `userEvent` over `fireEvent` for user interactions – it simulates real browser behavior (focus, keyboard events, pointer events) rather than dispatching synthetic events. ::: ## Mocking ### Function mocks ```ts const fn = vi.fn(); fn.mockReturnValue(42); fn.mockResolvedValue({ data: "ok" }); // async fn.mockImplementation((x) => x + 1); ``` ### Partial object mocks Cast partial mocks when you only need a subset of a typed interface: ```ts const db = { query: { user: { findFirst: vi.fn().mockResolvedValue({ id: "user-1" }) }, }, } as unknown as TRPCContext["db"]; ``` ### Module mocks ```ts vi.mock(import("./some-module.js"), () => ({ myFunction: vi.fn().mockReturnValue("mocked"), })); ``` For partial module mocks that keep the original implementation: ```ts vi.mock(import("./some-module.js"), async (importOriginal) => { const mod = await importOriginal(); return { ...mod, myFunction: vi.fn() }; }); ``` ::: warning Module mocks are hoisted – they run before imports regardless of where you write them. See [Vitest mocking docs](https://vitest.dev/guide/mocking) for details. ::: ## Where Tests Live ``` apps/ ├── api/ │ └── routers/ │ └── billing.test.ts # tRPC procedure tests └── app/ └── lib/ ├── errors.test.ts # utility function tests └── queries/ ├── billing.test.ts # query option tests └── session.test.ts # cache helper tests ``` Place test files next to the source they test. No separate `__tests__` directories. ================================================ FILE: eslint.config.ts ================================================ import react from "@eslint-react/eslint-plugin"; import js from "@eslint/js"; import * as tsParser from "@typescript-eslint/parser"; import prettierConfig from "eslint-config-prettier"; import { defineConfig } from "eslint/config"; import globals from "globals"; import ts from "typescript-eslint"; /** * ESLint configuration. * @see https://eslint.org/docs/latest/use/configure/ */ export default defineConfig( // Global ignores { ignores: [ ".cache", ".venv", "**/.astro", "**/.react-email", "**/dist", "**/node_modules", "docs/.vitepress/cache", "docs/.vitepress/dist", ], }, // Base configs for all files js.configs.recommended, ...ts.configs.recommended, // TypeScript parser for all .ts/.tsx files { files: ["**/*.{ts,tsx}"], languageOptions: { parser: tsParser, }, }, // Node.js environment (servers, scripts, config files) { files: [ "**/*.config.{js,ts,mjs}", "**/scripts/**/*", "apps/api/**/*", "apps/email/**/*", "db/**/*", "infra/**/*", "packages/core/**/*", "packages/ws-protocol/**/*", ], languageOptions: { globals: { ...globals.node }, }, }, // React environment (frontend apps, email templates) { ...react.configs["recommended-typescript"], files: [ "apps/app/**/*.{ts,tsx}", "apps/email/**/*.tsx", "apps/web/**/*.{ts,tsx}", "packages/ui/**/*.tsx", ], rules: { ...react.configs["recommended-typescript"].rules, "@eslint-react/dom/no-missing-iframe-sandbox": "off", }, languageOptions: { parser: tsParser, parserOptions: { ecmaVersion: "latest", sourceType: "module", jsxImportSource: "react", ecmaFeatures: { jsx: true }, }, globals: { ...globals.browser, ...globals.es2021, }, }, }, // Email templates: add Node globals (server-side rendering) { files: ["apps/email/**/*.tsx"], languageOptions: { globals: { ...globals.node }, }, }, // UI package specific overrides { files: ["packages/ui/**/*.tsx"], rules: { "@eslint-react/no-forward-ref": "off", }, }, // Prettier must be last to override any formatting rules prettierConfig, ); ================================================ FILE: infra/.gitignore ================================================ .terraform/ *.tfstate *.tfstate.* *.tfvars *.tfvars.json backend.hcl override.tf override.tf.json *_override.tf *_override.tf.json .terraform.tfstate.lock.info crash.log crash.*.log ================================================ FILE: infra/README.md ================================================ # Infrastructure Terraform configuration for deploying to Cloudflare (edge) or GCP (hybrid). [Documentation](https://reactstarter.com/deployment/cloudflare) | [CI/CD](https://reactstarter.com/deployment/ci-cd) ## Design Goals - **One obvious default:** Edge stack handles most SaaS apps with zero GCP overhead. - **Stacks are composable:** Modules have no credentials; stacks wire them together. - **State isolation:** Each `envs//` directory = one Terraform root = one state file. - **Instant code deployments:** Terraform provisions infrastructure; Wrangler deploys code. ## Which Stack? | Choose **edge** (default) | Choose **hybrid** | | ------------------------- | ---------------------------------- | | Most SaaS apps | Need GCP services (Vertex AI, etc) | | Fastest cold starts | Require Cloud Run containers | | Minimal cost | Need Cloud SQL (managed Postgres) | | Neon for database | Already on GCP | ## Structure ```bash infra/ modules/ # Atomic resources (no credentials) cloudflare/ worker/ # Worker resource (Beta API) - created without code hyperdrive/ # Database connection pooling dns/ # Proxied DNS records stacks/ # Composable architectures edge/ # Workers + Hyperdrive + DNS (routes via Wrangler) hybrid/ # Cloud Run + Cloud SQL + GCS (+ optional CF DNS) envs/ # Terraform roots (providers + backend + state) dev/edge/ preview/edge/ staging/edge/ prod/edge/ templates/ # Copy-paste templates for hybrid envs and remote state ``` ## Quickstart (Edge Stack) ```bash # Configure variables cp infra/envs/dev/edge/terraform.tfvars.example infra/envs/dev/edge/terraform.tfvars # Edit terraform.tfvars with your values # 1. Provision infrastructure (workers, hyperdrive, DNS) terraform -chdir=infra/envs/dev/edge init terraform -chdir=infra/envs/dev/edge apply # 2. Copy Hyperdrive ID to apps/api/wrangler.jsonc terraform -chdir=infra/envs/dev/edge output hyperdrive_id # 3. Deploy code + routes via Wrangler bun api:deploy # or: cd apps/api && bun wrangler deploy bun app:deploy # or: cd apps/app && bun wrangler deploy bun web:deploy # or: cd apps/web && bun wrangler deploy (includes routes) ``` Required variables: `cloudflare_api_token`, `cloudflare_account_id`, `project_slug`, `environment`, `neon_database_url` Optional: `cloudflare_zone_id`, `hostname` (for custom domains) Pass secrets via environment variables (recommended for CI/CD): ```bash export TF_VAR_cloudflare_api_token="..." export TF_VAR_neon_database_url="$DATABASE_URL" terraform -chdir=infra/envs/dev/edge apply ``` ## Worker Secrets (Wrangler) Terraform provisions infrastructure only — worker secrets are deployed via Wrangler. **Required** (all environments): ```bash wrangler secret put BETTER_AUTH_SECRET --env ``` **Optional** (only if billing is enabled): ```bash wrangler secret put STRIPE_SECRET_KEY --env wrangler secret put STRIPE_WEBHOOK_SECRET --env wrangler secret put STRIPE_STARTER_PRICE_ID --env wrangler secret put STRIPE_PRO_PRICE_ID --env wrangler secret put STRIPE_PRO_ANNUAL_PRICE_ID --env ``` After adding Stripe secrets, register the webhook URL in the Stripe Dashboard: ```bash terraform -chdir=infra/envs//edge output stripe_webhook_url ``` ## Hybrid Stack (GCP) Copy the hybrid template and configure: ```bash cp -r infra/templates/env-roots/hybrid infra/envs/prod/hybrid # Edit terraform.tfvars with GCP credentials and settings terraform -chdir=infra/envs/prod/hybrid init terraform -chdir=infra/envs/prod/hybrid apply ``` ## Remote State By default, Terraform uses local state. For team collaboration, configure a remote backend: ```bash # Edge stack (R2) cp infra/templates/backend-r2.example.hcl infra/envs/prod/edge/backend.hcl terraform -chdir=infra/envs/prod/edge init -backend-config=backend.hcl -migrate-state # Hybrid stack (GCS) cp infra/templates/backend-gcs.example.hcl infra/envs/prod/hybrid/backend.hcl terraform -chdir=infra/envs/prod/hybrid init -backend-config=backend.hcl -migrate-state ``` ## API Token Permissions (Cloudflare) Terraform token (infrastructure): - Zone:DNS:Edit - Zone:Zone:Read - Account:Workers Scripts:Edit - Account:Cloudflare Hyperdrive:Edit Wrangler token (code + routes deployment): - Zone:Workers Routes:Edit - Account:Workers Scripts:Edit ## Requirements - Terraform >= 1.12 - Cloudflare provider >= 5.15.0 (for `cloudflare_worker` Beta resource) - Cloudflare account (edge stack) - GCP project (hybrid stack) See `docs/specs/infra-terraform.md` for design details. ================================================ FILE: infra/envs/dev/edge/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { version = "5.15.0" constraints = "~> 5.0, >= 5.15.0" hashes = [ "h1:EY8mWQO1NTqXuxnZxwVppF7AiLRdx7vRLYk6O7yzgPs=", "zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c", "zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47", "zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f", "zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c", "zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b", "zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549", "zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d", "zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } ================================================ FILE: infra/envs/dev/edge/main.tf ================================================ module "stack" { source = "../../../stacks/edge" cloudflare_account_id = var.cloudflare_account_id cloudflare_zone_id = var.cloudflare_zone_id hostname = var.hostname project_slug = var.project_slug environment = var.environment neon_database_url = var.neon_database_url } output "worker_api_name" { value = module.stack.worker_api_name } output "worker_app_name" { value = module.stack.worker_app_name } output "worker_web_name" { value = module.stack.worker_web_name } output "hyperdrive_id" { value = module.stack.hyperdrive_id } output "hyperdrive_name" { value = module.stack.hyperdrive_name } ================================================ FILE: infra/envs/dev/edge/providers.tf ================================================ terraform { required_version = ">= 1.12, < 2.0" required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5.0, >= 5.15.0" } } } provider "cloudflare" { api_token = var.cloudflare_api_token } ================================================ FILE: infra/envs/dev/edge/terraform.tfvars.example ================================================ cloudflare_api_token = "" cloudflare_account_id = "" cloudflare_zone_id = "" hostname = "" project_slug = "myapp" environment = "dev" neon_database_url = "" ================================================ FILE: infra/envs/dev/edge/variables.tf ================================================ variable "cloudflare_api_token" { type = string sensitive = true } variable "cloudflare_account_id" { type = string } variable "cloudflare_zone_id" { type = string default = "" } variable "hostname" { type = string default = "" } variable "project_slug" { type = string } variable "environment" { type = string } variable "neon_database_url" { type = string sensitive = true } ================================================ FILE: infra/envs/preview/edge/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { version = "5.15.0" constraints = "~> 5.0, >= 5.15.0" hashes = [ "h1:EY8mWQO1NTqXuxnZxwVppF7AiLRdx7vRLYk6O7yzgPs=", "zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c", "zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47", "zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f", "zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c", "zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b", "zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549", "zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d", "zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } ================================================ FILE: infra/envs/preview/edge/main.tf ================================================ module "stack" { source = "../../../stacks/edge" cloudflare_account_id = var.cloudflare_account_id cloudflare_zone_id = var.cloudflare_zone_id hostname = var.hostname project_slug = var.project_slug environment = var.environment neon_database_url = var.neon_database_url } output "worker_api_name" { value = module.stack.worker_api_name } output "worker_app_name" { value = module.stack.worker_app_name } output "worker_web_name" { value = module.stack.worker_web_name } output "hyperdrive_id" { value = module.stack.hyperdrive_id } output "hyperdrive_name" { value = module.stack.hyperdrive_name } ================================================ FILE: infra/envs/preview/edge/providers.tf ================================================ terraform { required_version = ">= 1.12, < 2.0" required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5.0, >= 5.15.0" } } } provider "cloudflare" { api_token = var.cloudflare_api_token } ================================================ FILE: infra/envs/preview/edge/terraform.tfvars.example ================================================ cloudflare_api_token = "" cloudflare_account_id = "" cloudflare_zone_id = "" hostname = "" project_slug = "myapp" environment = "preview" neon_database_url = "" ================================================ FILE: infra/envs/preview/edge/variables.tf ================================================ variable "cloudflare_api_token" { type = string sensitive = true } variable "cloudflare_account_id" { type = string } variable "cloudflare_zone_id" { type = string default = "" } variable "hostname" { type = string default = "" } variable "project_slug" { type = string } variable "environment" { type = string } variable "neon_database_url" { type = string sensitive = true } ================================================ FILE: infra/envs/prod/edge/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { version = "5.15.0" constraints = "~> 5.0, >= 5.15.0" hashes = [ "h1:EY8mWQO1NTqXuxnZxwVppF7AiLRdx7vRLYk6O7yzgPs=", "zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c", "zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47", "zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f", "zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c", "zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b", "zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549", "zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d", "zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } ================================================ FILE: infra/envs/prod/edge/main.tf ================================================ module "stack" { source = "../../../stacks/edge" cloudflare_account_id = var.cloudflare_account_id cloudflare_zone_id = var.cloudflare_zone_id hostname = var.hostname project_slug = var.project_slug environment = var.environment neon_database_url = var.neon_database_url } output "worker_api_name" { value = module.stack.worker_api_name } output "worker_app_name" { value = module.stack.worker_app_name } output "worker_web_name" { value = module.stack.worker_web_name } output "hyperdrive_id" { value = module.stack.hyperdrive_id } output "hyperdrive_name" { value = module.stack.hyperdrive_name } ================================================ FILE: infra/envs/prod/edge/providers.tf ================================================ terraform { required_version = ">= 1.12, < 2.0" required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5.0, >= 5.15.0" } } } provider "cloudflare" { api_token = var.cloudflare_api_token } ================================================ FILE: infra/envs/prod/edge/terraform.tfvars.example ================================================ cloudflare_api_token = "" cloudflare_account_id = "" cloudflare_zone_id = "" hostname = "" project_slug = "myapp" environment = "prod" neon_database_url = "" ================================================ FILE: infra/envs/prod/edge/variables.tf ================================================ variable "cloudflare_api_token" { type = string sensitive = true } variable "cloudflare_account_id" { type = string } variable "cloudflare_zone_id" { type = string default = "" } variable "hostname" { type = string default = "" } variable "project_slug" { type = string } variable "environment" { type = string } variable "neon_database_url" { type = string sensitive = true } ================================================ FILE: infra/envs/staging/edge/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { version = "5.15.0" constraints = "~> 5.0, >= 5.15.0" hashes = [ "h1:EY8mWQO1NTqXuxnZxwVppF7AiLRdx7vRLYk6O7yzgPs=", "zh:20a72bdbb28435f11d165b367732369e8f8163100a214e89ad720dae03fafa0c", "zh:2eabd7a51fd7aafcab9861631d85c895914857e4fcd6fe2dd80bac22e74a1f47", "zh:62828afbc1ba0e0a64bbb7d5d42ae3c2fbbaabb793010b07eba770ba91bae94f", "zh:6693f1021e52c34a629300fbcd91f8bd4ca386fda3b45aec746b9c200c28a42c", "zh:6873a15454b289e5baecc1d36ce8997266438761386a320753c63f13407f4a6b", "zh:afbf4e56b3a5e5950b35b02b553313e4a2008415920b23f536682269c64ca549", "zh:db367612900bc2e5a01c6a325e4cff9b1b04960ce9de3dd41671dda5a627ca1d", "zh:eb7365eafc6160c3b304a9ce6a598e5400a2e779e9e2bd27976df244f79f774f", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } ================================================ FILE: infra/envs/staging/edge/main.tf ================================================ module "stack" { source = "../../../stacks/edge" cloudflare_account_id = var.cloudflare_account_id cloudflare_zone_id = var.cloudflare_zone_id hostname = var.hostname project_slug = var.project_slug environment = var.environment neon_database_url = var.neon_database_url } output "worker_api_name" { value = module.stack.worker_api_name } output "worker_app_name" { value = module.stack.worker_app_name } output "worker_web_name" { value = module.stack.worker_web_name } output "hyperdrive_id" { value = module.stack.hyperdrive_id } output "hyperdrive_name" { value = module.stack.hyperdrive_name } ================================================ FILE: infra/envs/staging/edge/providers.tf ================================================ terraform { required_version = ">= 1.12, < 2.0" required_providers { cloudflare = { source = "cloudflare/cloudflare" version = "~> 5.0, >= 5.15.0" } } } provider "cloudflare" { api_token = var.cloudflare_api_token } ================================================ FILE: infra/envs/staging/edge/terraform.tfvars.example ================================================ cloudflare_api_token = "" cloudflare_account_id = "" cloudflare_zone_id = "" hostname = "" project_slug = "myapp" environment = "staging" neon_database_url = "" ================================================ FILE: infra/envs/staging/edge/variables.tf ================================================ variable "cloudflare_api_token" { type = string sensitive = true } variable "cloudflare_account_id" { type = string } variable "cloudflare_zone_id" { type = string default = "" } variable "hostname" { type = string default = "" } variable "project_slug" { type = string } variable "environment" { type = string } variable "neon_database_url" { type = string sensitive = true } ================================================ FILE: infra/modules/cloudflare/dns/main.tf ================================================ # Proxied DNS record for Cloudflare Workers routing. # Workers and routes are managed via Wrangler, not Terraform. terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" } } } resource "cloudflare_dns_record" "record" { zone_id = var.zone_id name = var.hostname type = "AAAA" content = "100::" ttl = 1 # Auto (required for proxied records) proxied = true comment = "Managed by Terraform" } ================================================ FILE: infra/modules/cloudflare/dns/outputs.tf ================================================ output "hostname" { description = "The configured hostname" value = var.hostname } ================================================ FILE: infra/modules/cloudflare/dns/variables.tf ================================================ variable "zone_id" { description = "Cloudflare zone ID" type = string } variable "hostname" { description = "DNS hostname (e.g., 'example.com' or 'staging.example.com')" type = string } ================================================ FILE: infra/modules/cloudflare/hyperdrive/main.tf ================================================ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" } } } locals { # Normalize postgresql:// to postgres:// for regex parsing. # Limitation: credentials must not contain unencoded @ or : characters. # This works reliably with Neon URLs which use URL-safe generated credentials. db_url = replace(var.database_url, "postgresql://", "postgres://") } resource "cloudflare_hyperdrive_config" "hyperdrive" { account_id = var.account_id name = var.name mtls = {} origin = { database = regex("^postgres://[^:]+:[^@]+@[^:/]+:[0-9]+/([^?]+)", local.db_url)[0] password = regex("^postgres://[^:]+:([^@]+)@", local.db_url)[0] host = regex("^postgres://[^:]+:[^@]+@([^:/]+):", local.db_url)[0] port = tonumber(regex("^postgres://[^:]+:[^@]+@[^:/]+:([0-9]+)/", local.db_url)[0]) scheme = "postgres" user = regex("^postgres://([^:]+):", local.db_url)[0] } origin_connection_limit = 60 caching = { disabled = true } } ================================================ FILE: infra/modules/cloudflare/hyperdrive/outputs.tf ================================================ output "id" { description = "Hyperdrive configuration ID" value = cloudflare_hyperdrive_config.hyperdrive.id } output "name" { description = "Hyperdrive configuration name" value = cloudflare_hyperdrive_config.hyperdrive.name } ================================================ FILE: infra/modules/cloudflare/hyperdrive/variables.tf ================================================ variable "account_id" { description = "Cloudflare account ID" type = string } variable "name" { description = "Hyperdrive configuration name" type = string } variable "database_url" { description = "PostgreSQL connection URL in format postgres://user:pass@host:port/db (port required, no special chars in credentials)" type = string sensitive = true validation { condition = can(regex("^postgres(ql)?://[^:]+:[^@]+@[^:/]+:[0-9]+/[^?]+", var.database_url)) error_message = "Invalid database_url format. Expected: postgres://user:pass@host:port/db (port required, credentials must not contain unencoded @ or :)" } } ================================================ FILE: infra/modules/cloudflare/r2-bucket/main.tf ================================================ terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" } } } resource "cloudflare_r2_bucket" "bucket" { account_id = var.account_id name = var.name location = var.location } ================================================ FILE: infra/modules/cloudflare/r2-bucket/outputs.tf ================================================ output "name" { description = "R2 bucket name" value = cloudflare_r2_bucket.bucket.name } output "id" { description = "R2 bucket ID" value = cloudflare_r2_bucket.bucket.id } ================================================ FILE: infra/modules/cloudflare/r2-bucket/variables.tf ================================================ variable "account_id" { description = "Cloudflare account ID" type = string } variable "name" { description = "R2 bucket name" type = string } variable "location" { description = "R2 bucket location" type = string default = "enam" } ================================================ FILE: infra/modules/cloudflare/worker/main.tf ================================================ # Worker resource without code. Deploy via Wrangler. # Routes managed via wrangler.jsonc (routes live with code, not infra). terraform { required_providers { cloudflare = { source = "cloudflare/cloudflare" version = ">= 5.15.0" } } } resource "cloudflare_worker" "worker" { account_id = var.account_id name = var.name observability = var.observability_enabled ? { enabled = true head_sampling_rate = var.head_sampling_rate } : null subdomain = var.subdomain_enabled ? { enabled = true previews_enabled = var.previews_enabled } : null tags = var.tags } ================================================ FILE: infra/modules/cloudflare/worker/outputs.tf ================================================ output "id" { value = cloudflare_worker.worker.id description = "Worker UUID" } output "name" { value = cloudflare_worker.worker.name description = "Worker name" } output "subdomain_url" { value = var.subdomain_enabled ? "https://${cloudflare_worker.worker.name}.workers.dev" : null description = "Workers.dev URL (null if subdomain disabled)" } ================================================ FILE: infra/modules/cloudflare/worker/variables.tf ================================================ variable "account_id" { type = string description = "Cloudflare account ID" } variable "name" { type = string description = "Worker name (used in URLs and route configuration)" } variable "observability_enabled" { type = bool description = "Enable observability (logs and metrics)" default = true } variable "head_sampling_rate" { type = number description = "Sampling rate for head-based sampling (0.0 to 1.0)" default = 1 } variable "subdomain_enabled" { type = bool description = "Enable workers.dev subdomain" default = false } variable "previews_enabled" { type = bool description = "Enable preview deployments on workers.dev subdomain" default = false } variable "tags" { type = list(string) description = "Tags for organizing workers" default = [] } ================================================ FILE: infra/modules/gcp/cloud-run/main.tf ================================================ resource "google_cloud_run_v2_service" "service" { name = var.service_name location = var.region deletion_protection = false ingress = "INGRESS_TRAFFIC_ALL" invoker_iam_disabled = true # Allow public access without IAM checks template { scaling { min_instance_count = 0 max_instance_count = 10 } dynamic "volumes" { for_each = var.cloud_sql_connection != null ? [1] : [] content { name = "cloudsql" cloud_sql_instance { instances = [var.cloud_sql_connection] } } } containers { image = var.image ports { container_port = 8080 } resources { limits = { cpu = "1" memory = "512Mi" } cpu_idle = true } dynamic "volume_mounts" { for_each = var.cloud_sql_connection != null ? [1] : [] content { name = "cloudsql" mount_path = "/cloudsql" } } dynamic "env" { for_each = var.env_vars content { name = env.key value = env.value } } } } lifecycle { ignore_changes = [ template[0].containers[0].image, # Allow gcloud to deploy new images template[0].revision, template[0].labels, ] } } ================================================ FILE: infra/modules/gcp/cloud-run/outputs.tf ================================================ output "url" { description = "URL of the Cloud Run service" value = google_cloud_run_v2_service.service.uri } output "service_name" { description = "Name of the Cloud Run service" value = google_cloud_run_v2_service.service.name } ================================================ FILE: infra/modules/gcp/cloud-run/variables.tf ================================================ variable "project_id" { description = "GCP project ID" type = string } variable "region" { description = "GCP region for Cloud Run service" type = string } variable "service_name" { description = "Name of the Cloud Run service" type = string } variable "image" { description = "Container image to deploy" type = string } variable "env_vars" { description = "Environment variables for the service" type = map(string) default = {} } variable "cloud_sql_connection" { description = "Cloud SQL connection name (project:region:instance)" type = string default = null } ================================================ FILE: infra/modules/gcp/cloud-sql/main.tf ================================================ resource "google_sql_database_instance" "instance" { name = var.instance_name database_version = "POSTGRES_18" settings { edition = "ENTERPRISE" tier = var.tier disk_size = 10 disk_type = "PD_SSD" ip_configuration { ipv4_enabled = var.private_network_id == null private_network = var.private_network_id } backup_configuration { enabled = false } } } resource "google_sql_database" "database" { name = var.database_name instance = google_sql_database_instance.instance.name } resource "random_password" "password" { length = 32 special = true override_special = "-_" } resource "google_sql_user" "user" { name = var.database_name instance = google_sql_database_instance.instance.name password = random_password.password.result } ================================================ FILE: infra/modules/gcp/cloud-sql/outputs.tf ================================================ output "connection_name" { description = "Cloud SQL connection name (project:region:instance)" value = google_sql_database_instance.instance.connection_name } output "connection_string" { description = "PostgreSQL connection string" value = "postgresql://${google_sql_user.user.name}:${random_password.password.result}@localhost/${var.database_name}?host=/cloudsql/${google_sql_database_instance.instance.connection_name}" sensitive = true } output "instance_ip" { description = "Private IP address of the instance" value = google_sql_database_instance.instance.private_ip_address } ================================================ FILE: infra/modules/gcp/cloud-sql/variables.tf ================================================ variable "project_id" { description = "GCP project ID" type = string } variable "region" { description = "GCP region for Cloud SQL instance" type = string } variable "instance_name" { description = "Name of the Cloud SQL instance" type = string } variable "tier" { description = "Machine tier for Cloud SQL instance" type = string default = "db-f1-micro" } variable "database_name" { description = "Name of the database to create" type = string } variable "private_network_id" { description = "VPC network ID for private IP (optional, enables public IP if not set)" type = string default = null } ================================================ FILE: infra/modules/gcp/gcs/main.tf ================================================ resource "google_storage_bucket" "bucket" { name = var.bucket_name location = var.location force_destroy = false uniform_bucket_level_access = true dynamic "cors" { for_each = length(var.cors_origins) > 0 ? [1] : [] content { origin = var.cors_origins method = ["GET", "PUT", "POST", "OPTIONS"] response_header = [ "Content-Type", "Access-Control-Allow-Origin", "x-goog-resumable" ] max_age_seconds = 3600 } } } ================================================ FILE: infra/modules/gcp/gcs/outputs.tf ================================================ output "bucket_name" { description = "Name of the GCS bucket" value = google_storage_bucket.bucket.name } output "url" { description = "URL of the GCS bucket" value = google_storage_bucket.bucket.url } ================================================ FILE: infra/modules/gcp/gcs/variables.tf ================================================ variable "project_id" { description = "GCP project ID" type = string } variable "location" { description = "GCS bucket location" type = string } variable "bucket_name" { description = "Name of the GCS bucket" type = string } variable "cors_origins" { description = "List of CORS origins" type = list(string) default = [] } ================================================ FILE: infra/stacks/edge/main.tf ================================================ # Edge stack: Cloudflare infrastructure for Workers deployment. # Worker metadata created here; code + routes deployed via Wrangler. locals { worker_suffix = var.environment == "prod" ? "" : "-${var.environment}" has_custom_domain = var.cloudflare_zone_id != "" && var.hostname != "" } # API Worker (tRPC, auth endpoints) module "worker_api" { source = "../../modules/cloudflare/worker" account_id = var.cloudflare_account_id name = "${var.project_slug}-api${local.worker_suffix}" observability_enabled = true subdomain_enabled = !local.has_custom_domain tags = [var.project_slug, var.environment] } # App Worker (SPA with static assets) module "worker_app" { source = "../../modules/cloudflare/worker" account_id = var.cloudflare_account_id name = "${var.project_slug}-app${local.worker_suffix}" observability_enabled = true subdomain_enabled = !local.has_custom_domain tags = [var.project_slug, var.environment] } # Web Worker (marketing site, edge router) module "worker_web" { source = "../../modules/cloudflare/worker" account_id = var.cloudflare_account_id name = "${var.project_slug}-web${local.worker_suffix}" observability_enabled = true subdomain_enabled = !local.has_custom_domain tags = [var.project_slug, var.environment] } module "hyperdrive" { source = "../../modules/cloudflare/hyperdrive" account_id = var.cloudflare_account_id name = "${var.project_slug}-${var.environment}" database_url = var.neon_database_url } module "dns" { count = local.has_custom_domain ? 1 : 0 source = "../../modules/cloudflare/dns" zone_id = var.cloudflare_zone_id hostname = var.hostname } ================================================ FILE: infra/stacks/edge/outputs.tf ================================================ # Worker names for wrangler deploy output "worker_api_name" { value = module.worker_api.name description = "API worker name for wrangler deploy" } output "worker_app_name" { value = module.worker_app.name description = "App worker name for wrangler deploy" } output "worker_web_name" { value = module.worker_web.name description = "Web worker name for wrangler deploy" } # Hyperdrive ID for wrangler.jsonc output "hyperdrive_id" { value = module.hyperdrive.id description = "Hyperdrive configuration ID for wrangler.jsonc" } output "hyperdrive_name" { value = module.hyperdrive.name description = "Hyperdrive configuration name" } output "hostname" { value = var.hostname != "" ? var.hostname : null description = "Configured hostname (null if using workers.dev)" } # Stripe webhook URL for dashboard configuration output "stripe_webhook_url" { value = "https://${var.hostname != "" ? var.hostname : "${module.worker_api.name}.workers.dev"}/api/auth/stripe/webhook" description = "Register in Stripe Dashboard → Webhooks (only needed if billing is enabled)" } ================================================ FILE: infra/stacks/edge/variables.tf ================================================ variable "cloudflare_account_id" { type = string description = "Cloudflare account ID" } variable "cloudflare_zone_id" { type = string description = "Cloudflare zone ID (required when hostname is set)" default = "" } variable "hostname" { type = string description = "Public hostname (e.g., example.com). If empty, uses workers.dev URLs." default = "" } variable "project_slug" { type = string description = "Short identifier for resource naming (e.g., myapp)" } variable "environment" { type = string description = "Environment name (e.g., dev, staging, prod)" } variable "neon_database_url" { type = string description = "Neon PostgreSQL connection string" sensitive = true } ================================================ FILE: infra/stacks/hybrid/main.tf ================================================ # Hybrid stack: GCP backend with optional Cloudflare edge. # Workers are deployed separately via Wrangler. # Cloud SQL PostgreSQL module "database" { source = "../../modules/gcp/cloud-sql" project_id = var.gcp_project_id region = var.gcp_region instance_name = "${var.project_slug}-${var.environment}" database_name = var.project_slug tier = var.cloud_sql_tier } # GCS bucket for uploads module "storage" { source = "../../modules/gcp/gcs" project_id = var.gcp_project_id location = var.gcp_region bucket_name = "${var.project_slug}-${var.environment}-uploads" } # Cloud Run API service module "api" { source = "../../modules/gcp/cloud-run" project_id = var.gcp_project_id region = var.gcp_region service_name = "${var.project_slug}-api-${var.environment}" image = var.api_image cloud_sql_connection = module.database.connection_name env_vars = { DATABASE_URL = module.database.connection_string GCS_BUCKET = module.storage.bucket_name } } # Optional: Cloudflare DNS for edge routing. # Deploy the edge proxy Worker separately via Wrangler. module "dns" { count = var.enable_edge_routing && var.hostname != "" ? 1 : 0 source = "../../modules/cloudflare/dns" zone_id = var.cloudflare_zone_id hostname = var.hostname } ================================================ FILE: infra/stacks/hybrid/outputs.tf ================================================ output "api_url" { value = module.api.url description = "Cloud Run service URL" } output "edge_api_url" { value = var.enable_edge_routing && var.hostname != "" ? "https://${var.hostname}/api" : null description = "Public API URL via Cloudflare edge (null if edge routing disabled or no hostname)" } ================================================ FILE: infra/stacks/hybrid/variables.tf ================================================ variable "gcp_project_id" { type = string description = "GCP project ID" } variable "gcp_region" { type = string description = "GCP region (e.g., us-central1)" } variable "project_slug" { type = string description = "Short identifier for resource naming (e.g., myapp)" } variable "environment" { type = string description = "Environment name (e.g., dev, staging, prod)" } variable "api_image" { type = string description = "Container image URL for Cloud Run API service" } variable "cloud_sql_tier" { type = string description = "Cloud SQL instance tier (e.g., db-f1-micro)" default = "db-f1-micro" } variable "enable_edge_routing" { type = bool description = "Enable Cloudflare edge routing in front of Cloud Run" default = false } variable "cloudflare_zone_id" { type = string description = "Cloudflare zone ID (required when enable_edge_routing = true and hostname is set)" default = "" } variable "hostname" { type = string description = "Public hostname for edge routing (e.g., api-gcp.example.com)" default = "" } ================================================ FILE: infra/templates/backend-gcs.example.hcl ================================================ bucket = "tf-state" prefix = "prod/hybrid" ================================================ FILE: infra/templates/backend-r2.example.hcl ================================================ bucket = "tf-state" key = "prod/edge/terraform.tfstate" endpoints = { s3 = "https://.r2.cloudflarestorage.com" } # Do not hard-code secrets here. Set credentials via environment variables: # access_key = "..." → AWS_ACCESS_KEY_ID # secret_key = "..." → AWS_SECRET_ACCESS_KEY skip_credentials_validation = true skip_metadata_api_check = true skip_region_validation = true skip_requesting_account_id = true skip_s3_checksum = true region = "auto" ================================================ FILE: infra/templates/env-roots/hybrid/.terraform.lock.hcl ================================================ # This file is maintained automatically by "terraform init". # Manual edits may be lost in future updates. provider "registry.terraform.io/cloudflare/cloudflare" { version = "5.14.0" hashes = [ "h1:5rwgZxUA7qCU4HWcE7VUE5hPqrAH1Bk9Rr13qEeR1KY=", "zh:0556cb6f38067c95e320f2d5680cf9851991d28448da903dd50b6c4b54de1c0b", "zh:7cdd70418aa6571c27de4102e3cc3228f6edbe4a1eaa7927f772234ee09fb519", "zh:84feef6c19993da06139e05dd6e1fceb7beb086f041cb9bc4edcae6081fe4812", "zh:8b7dfcececcced324a8a8344fcb48b746f218174ffc691e36a54d09f2fb5e797", "zh:99ba55ced06327bd8f16077f26d95593d3d77107e9faf204bf1a2485653eb02e", "zh:9e93df24a28ffe7551458035bae8ea1f4fdab74a9a47ce61f1008bc18025ecd0", "zh:a03e65a78c70578f3ebb3f2d84df516a33e2c3e9b62c0e3a2d2cea4f411c5e83", "zh:e57ab18a3275dee18d69910a1103a52c8071d77e449e50b1a8b9d80779068145", "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } provider "registry.terraform.io/hashicorp/google" { version = "7.12.0" constraints = "~> 7.0" hashes = [ "h1:kBKvDUp6GLwHAsoM6CIj9ZTxVBzSnQjyxaVSP8SfqHQ=", "zh:38722ec7777543c23e22e02695e53dd5c94644022647c3c79e11e587063d4d2b", "zh:417b12b69c91c12e3fcefee38744b7a37bae73b706e3071c714151a623a6b0e9", "zh:4902cea92c78b462beaf053de03d0d55fb2241d41ca3379b4568ba247f667fa9", "zh:50ccce39d403ba477943e6652ccb6913092d9dcce1d55533b00b66062888db3d", "zh:56dccfe5df28cfe368d93c37ad6c46a16e76da61482fd0bfc83676b1423cecf5", "zh:7265fca2921e5e300da5d8de7e28b658c0863fdda9da696c5b97dbd3122c17c2", "zh:8317467e828178a6db9ddabe431bb13935c00bfb5e4b4d9760bd56f7ae596eca", "zh:84cc9d9277422a0d6c80d2bd204642d8776ddbba23feb94cf2760bb5f15410bc", "zh:8f79d72e7ed4e36d01560ce5fc944dc7e0387fa0f8272a4345fc6ae896e8f575", "zh:98c3d756beca036f84e7840e2099ff7359e9a246cd9a35386e03ce65032b3f5f", "zh:a07e3ca19673d28da9289ca28dfb83204fa6636f642b8cf46de8caaf526b7dde", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } provider "registry.terraform.io/hashicorp/random" { version = "3.7.2" hashes = [ "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=", "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", ] } ================================================ FILE: infra/templates/env-roots/hybrid/README.md ================================================ # Hybrid Stack Root Template Copy this directory to enable hybrid stack for an environment: ```bash cp -r infra/templates/env-roots/hybrid infra/envs//hybrid ``` After copying: 1. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in values 2. Never commit secrets—use `TF_VAR_*` environment variables in CI ## Edge Routing (optional) To add Cloudflare in front of Cloud Run, uncomment edge routing blocks in all three files: - `providers.tf` — Cloudflare provider - `variables.tf` — Cloudflare variables - `main.tf` — `enable_edge_routing = true` and related inputs > **Note:** Don't enable edge routing if you're also using the edge stack for the same hostname—they would conflict. ## When to use hybrid Only if you need Cloud Run compute, Vertex AI, or other GCP services. The edge stack handles most SaaS workloads. ================================================ FILE: infra/templates/env-roots/hybrid/main.tf ================================================ module "stack" { source = "../../../stacks/hybrid" gcp_project_id = var.gcp_project_id gcp_region = var.gcp_region project_slug = var.project_slug environment = var.environment api_image = var.api_image cloud_sql_tier = var.cloud_sql_tier # --- Edge routing (optional) --- # enable_edge_routing = true # cloudflare_zone_id = var.cloudflare_zone_id # hostname = var.hostname } output "api_url" { value = module.stack.api_url } ================================================ FILE: infra/templates/env-roots/hybrid/providers.tf ================================================ terraform { required_version = ">= 1.12, < 2.0" required_providers { google = { source = "hashicorp/google" version = "~> 7.0" } } } # See "Provider Versions" in docs/specs/infra-terraform.md provider "google" { project = var.gcp_project_id region = var.gcp_region } # --- Edge routing (optional) --- # Add these blocks to enable Cloudflare in front of Cloud Run. # Terraform initializes providers before planning, so credentials are required # even if no resources use them. # # cloudflare = { # source = "cloudflare/cloudflare" # version = "~> 5.0" # } # # provider "cloudflare" { # api_token = var.cloudflare_api_token # } ================================================ FILE: infra/templates/env-roots/hybrid/terraform.tfvars.example ================================================ # GCP Configuration gcp_project_id = "my-gcp-project" gcp_region = "us-central1" # Project Configuration project_slug = "myapp" environment = "prod" # API Configuration api_image = "gcr.io/my-gcp-project/api:latest" cloud_sql_tier = "db-f1-micro" # --- Edge routing (optional) --- # cloudflare_account_id = "your-account-id" # cloudflare_zone_id = "your-zone-id" # hostname = "api.example.com" # TF_VAR_cloudflare_api_token: set via environment variable in CI ================================================ FILE: infra/templates/env-roots/hybrid/variables.tf ================================================ variable "gcp_project_id" { type = string } variable "gcp_region" { type = string } variable "project_slug" { type = string description = "Short identifier for resource naming (e.g., myapp)" } variable "environment" { type = string description = "Environment name (e.g., dev, staging, prod)" } variable "api_image" { type = string } variable "cloud_sql_tier" { type = string description = "Cloud SQL instance tier (e.g., db-f1-micro)" default = "db-f1-micro" } # --- Edge routing (optional) --- # Uncomment to add Cloudflare edge layer in front of Cloud Run. # Also uncomment the Cloudflare provider in providers.tf and module inputs in main.tf. # # variable "cloudflare_api_token" { # type = string # sensitive = true # } # # variable "cloudflare_zone_id" { # type = string # default = "" # } # # variable "hostname" { # type = string # } ================================================ FILE: package.json ================================================ { "name": "@repo/root", "version": "0.0.0", "packageManager": "bun@1.3.9", "private": true, "type": "module", "engines": { "bun": ">=1.3.0" }, "workspaces": [ "apps/*", "db", "packages/*", "scripts" ], "scripts": { "dev": "bun --filter @repo/web --filter @repo/api --filter @repo/app dev", "lint": "eslint --cache --report-unused-disable-directives .", "test": "vitest", "typecheck": "tsc --build", "build:types": "tsc --build", "build": "bun --filter @repo/email --filter @repo/web --filter @repo/api --filter @repo/app build", "generate:types": "bun wrangler types --config apps/api/wrangler.jsonc --include-runtime=false apps/api/types/cloudflare-env.d.ts && bun prettier --write apps/api/types/*-env.d.ts", "prepare": "husky", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", "docs:deploy": "gh-pages --dist docs/.vitepress/dist", "app:dev": "bun --cwd apps/app dev", "app:build": "bun --cwd apps/app build", "app:test": "bun --cwd apps/app test", "app:deploy": "bun --cwd apps/app deploy", "web:dev": "bun --cwd apps/web dev", "web:build": "bun --cwd apps/web build", "web:test": "bun --cwd apps/web test", "web:deploy": "bun --cwd apps/web deploy", "api:dev": "bun --cwd apps/api dev", "api:build": "bun --cwd apps/api build", "api:build:docker": "docker build --tag api:latest -f ./apps/api/Dockerfile .", "api:test": "bun --cwd apps/api test", "api:deploy": "bun --cwd apps/api deploy", "ui:add": "bun --cwd packages/ui add", "ui:list": "bun --cwd packages/ui list", "ui:update": "bun --cwd packages/ui update", "ui:essentials": "bun --cwd packages/ui essentials", "email:dev": "bun --cwd apps/email dev", "email:build": "bun --cwd apps/email build", "email:export": "bun --cwd apps/email export", "db:generate": "bun --cwd db generate", "db:generate:staging": "bun --cwd db generate:staging", "db:generate:prod": "bun --cwd db generate:prod", "db:migrate": "bun --cwd db migrate", "db:migrate:staging": "bun --cwd db migrate:staging", "db:migrate:prod": "bun --cwd db migrate:prod", "db:push": "bun --cwd db push", "db:push:staging": "bun --cwd db push:staging", "db:push:prod": "bun --cwd db push:prod", "db:studio": "bun --cwd db studio", "db:studio:staging": "bun --cwd db studio:staging", "db:studio:prod": "bun --cwd db studio:prod", "db:seed": "bun --cwd db seed", "db:seed:staging": "bun --cwd db seed:staging", "db:seed:prod": "bun --cwd db seed:prod", "db:export": "bun --cwd db export", "db:export:staging": "bun --cwd db export:staging", "db:export:prod": "bun --cwd db export:prod", "db:check": "bun --cwd db check", "db:typecheck": "bun --cwd db typecheck", "infra:dev": "bun run infra:dev:edge:apply", "infra:dev:edge:plan": "terraform -chdir=infra/envs/dev/edge plan", "infra:dev:edge:apply": "terraform -chdir=infra/envs/dev/edge apply -auto-approve", "infra:dev:edge:destroy": "terraform -chdir=infra/envs/dev/edge destroy -auto-approve", "infra:preview": "bun run infra:preview:edge:apply", "infra:preview:edge:plan": "terraform -chdir=infra/envs/preview/edge plan", "infra:preview:edge:apply": "terraform -chdir=infra/envs/preview/edge apply -auto-approve", "infra:preview:edge:destroy": "terraform -chdir=infra/envs/preview/edge destroy -auto-approve", "infra:staging": "bun run infra:staging:edge:apply", "infra:staging:edge:plan": "terraform -chdir=infra/envs/staging/edge plan", "infra:staging:edge:apply": "terraform -chdir=infra/envs/staging/edge apply", "infra:staging:edge:destroy": "terraform -chdir=infra/envs/staging/edge destroy", "infra:prod": "bun run infra:prod:edge:apply", "infra:prod:edge:plan": "terraform -chdir=infra/envs/prod/edge plan", "infra:prod:edge:apply": "terraform -chdir=infra/envs/prod/edge apply", "infra:prod:edge:destroy": "terraform -chdir=infra/envs/prod/edge destroy" }, "devDependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/eslint-plugin": "^11.12.0", "@eslint-react/eslint-plugin": "^2.12.4", "@eslint/js": "^9.0.0", "@types/eslint": "^9.6.1", "@typescript-eslint/eslint-plugin": "^8.55.0", "@typescript-eslint/parser": "^8.55.0", "@ws-kit/zod": "^0.10.2", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "gh-pages": "^6.3.0", "globals": "^17.3.0", "graphql": "^16.12.0", "happy-dom": "^20.6.2", "husky": "^9.1.7", "jiti": "^2.6.1", "lint-staged": "^16.2.7", "mermaid": "^11.12.3", "npm-check": "^6.0.1", "prettier": "^3.8.1", "react": "^19.2.4", "relay-config": "^12.0.1", "srcpack": "^0.1.15", "typescript": "~5.9.3", "typescript-eslint": "^8.56.0", "typescript-language-server": "^5.1.3", "vite": "~7.3.1", "vitepress": "2.0.0-alpha.16", "vitepress-plugin-llms": "^1.11.0", "vitest": "~4.0.18", "wrangler": "^4.66.0" }, "prettier": { "printWidth": 80, "tabWidth": 2, "useTabs": false, "semi": true, "singleQuote": false, "quoteProps": "as-needed", "jsxSingleQuote": false, "trailingComma": "all", "bracketSpacing": true, "bracketSameLine": false, "arrowParens": "always", "endOfLine": "lf", "overrides": [ { "files": "*.jsonc", "options": { "trailingComma": "none" } } ] }, "lint-staged": { "*.{js,jsx,ts,tsx,mjs,cjs}": [ "bunx eslint --max-warnings=0 --report-unused-disable-directives" ], "*": [ "bunx prettier --check --ignore-unknown" ] } } ================================================ FILE: packages/core/README.md ================================================ # Core Package Shared utilities and helpers used across the monorepo. ================================================ FILE: packages/core/index.ts ================================================ /** * @file Core package entrypoint. * * Placeholder for shared utilities and WebSocket functionality. */ export default {}; ================================================ FILE: packages/core/package.json ================================================ { "name": "@repo/core", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts", "./package.json": "./package.json" }, "scripts": { "typecheck": "tsc --noEmit" }, "devDependencies": { "@repo/typescript-config": "workspace:*", "@types/bun": "^1.3.9", "typescript": "~5.9.3" } } ================================================ FILE: packages/core/tsconfig.json ================================================ { "extends": "../typescript-config/node.jsonc", "compilerOptions": { "composite": true, "declaration": true, "emitDeclarationOnly": true, "outDir": "./dist", "rootDir": "./" }, "include": ["**/*.ts"], "exclude": ["**/node_modules/**/*", "dist"] } ================================================ FILE: packages/typescript-config/README.md ================================================ # TypeScript Configuration Shared TypeScript configuration for the monorepo. ## Usage Extend from the appropriate configuration in your `tsconfig.json`: ```jsonc // React applications { "extends": "@repo/typescript-config/react.jsonc" } // Node.js/Bun applications { "extends": "@repo/typescript-config/node.jsonc" } // Cloudflare Workers { "extends": "@repo/typescript-config/cloudflare.jsonc" } ``` ## Available Configurations - `base.jsonc` -- Core strict-mode configuration shared by all targets - `react.jsonc` -- React applications with DOM types and JSX support - `node.jsonc` -- Node.js/Bun backend services - `cloudflare.jsonc` -- Cloudflare Workers edge functions ================================================ FILE: packages/typescript-config/base.jsonc ================================================ { /* Visit https://aka.ms/tsconfig to read more about this file */ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { /* Type Checking */ "strict": true, "noImplicitAny": true, "noImplicitOverride": true, "noImplicitThis": true, "strictNullChecks": true, "strictPropertyInitialization": true, /* Modules */ "module": "ESNext", "moduleResolution": "Bundler", "baseUrl": "../../", "paths": { "@repo/api": ["apps/api"], "@repo/api/*": ["apps/api/*"], "@repo/core": ["packages/core"], "@repo/core/*": ["packages/core/*"], "@repo/db": ["db"], "@repo/db/*": ["db/*"], "@repo/ws-protocol": ["packages/ws-protocol"], "@repo/ws-protocol/*": ["packages/ws-protocol/*"], "@/*": ["apps/web/*"] }, "resolveJsonModule": true, "allowImportingTsExtensions": false, /* Emit */ // noEmit is set per-package based on needs /* JavaScript Support */ "allowJs": true, /* Interop Constraints */ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "verbatimModuleSyntax": true, /* Language and Environment */ "target": "ESNext", "lib": ["ESNext"], "useDefineForClassFields": true, /* Completeness */ "skipLibCheck": true } } ================================================ FILE: packages/typescript-config/cloudflare.jsonc ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.jsonc", "compilerOptions": { "lib": ["ESNext"], "types": ["@cloudflare/workers-types"] } } ================================================ FILE: packages/typescript-config/node.jsonc ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.jsonc", "compilerOptions": { "lib": ["ESNext"], "types": ["bun"] } } ================================================ FILE: packages/typescript-config/package.json ================================================ { "name": "@repo/typescript-config", "version": "0.0.0", "private": true, "exports": { "./base": "./base.jsonc", "./base.jsonc": "./base.jsonc", "./cloudflare": "./cloudflare.jsonc", "./cloudflare.jsonc": "./cloudflare.jsonc", "./node": "./node.jsonc", "./node.jsonc": "./node.jsonc", "./react": "./react.jsonc", "./react.jsonc": "./react.jsonc", "./package.json": "./package.json" }, "files": [ "*.jsonc", "package.json" ] } ================================================ FILE: packages/typescript-config/react.jsonc ================================================ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.jsonc", "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ESNext"], "jsx": "react-jsx", "jsxImportSource": "react", "types": ["vite/client"] } } ================================================ FILE: packages/ui/README.md ================================================ # UI Components Shared UI component library built on shadcn/ui (new-york style), Radix UI, and Tailwind CSS v4. [Documentation](https://reactstarter.com/frontend/ui) ## Usage ```typescript import { Button, Card, Input, cn } from "@repo/ui"; ``` ## Commands ```bash bun ui:add # Add a shadcn/ui component bun ui:list # List installed components bun ui:essentials # Install curated essential set ``` ## Structure ```bash components/ # shadcn/ui components hooks/ # Custom React hooks lib/ # Utilities (cn function) scripts/ # Component management tools ``` Consuming apps must include `@source "../../packages/ui/components/**/*.{ts,tsx}"` in their Tailwind config. ================================================ FILE: packages/ui/components/avatar.tsx ================================================ import * as React from "react"; import * as AvatarPrimitive from "@radix-ui/react-avatar"; import { cn } from "@/lib/utils"; const Avatar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); Avatar.displayName = AvatarPrimitive.Root.displayName; const AvatarImage = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AvatarImage.displayName = AvatarPrimitive.Image.displayName; const AvatarFallback = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; export { Avatar, AvatarImage, AvatarFallback }; ================================================ FILE: packages/ui/components/button.tsx ================================================ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, }, ); export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; } const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( ); }, ); Button.displayName = "Button"; export { Button, buttonVariants }; ================================================ FILE: packages/ui/components/card.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); Card.displayName = "Card"; const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardHeader.displayName = "CardHeader"; const CardTitle = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardTitle.displayName = "CardTitle"; const CardDescription = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardDescription.displayName = "CardDescription"; const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardContent.displayName = "CardContent"; const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => (
)); CardFooter.displayName = "CardFooter"; export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, }; ================================================ FILE: packages/ui/components/checkbox.tsx ================================================ import * as React from "react"; import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; const Checkbox = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; export { Checkbox }; ================================================ FILE: packages/ui/components/dialog.tsx ================================================ import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} Close )); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, }; ================================================ FILE: packages/ui/components/input.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; const Input = React.forwardRef>( ({ className, type, ...props }, ref) => { return ( ); }, ); Input.displayName = "Input"; export { Input }; ================================================ FILE: packages/ui/components/label.tsx ================================================ import * as React from "react"; import * as LabelPrimitive from "@radix-ui/react-label"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const labelVariants = cva( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", ); const Label = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & VariantProps >(({ className, ...props }, ref) => ( )); Label.displayName = LabelPrimitive.Root.displayName; export { Label }; ================================================ FILE: packages/ui/components/radio-group.tsx ================================================ import * as React from "react"; import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; import { Circle } from "lucide-react"; import { cn } from "@/lib/utils"; const RadioGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { return ( ); }); RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; const RadioGroupItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => { return ( ); }); RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; export { RadioGroup, RadioGroupItem }; ================================================ FILE: packages/ui/components/scroll-area.tsx ================================================ import * as React from "react"; import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; import { cn } from "@/lib/utils"; const ScrollArea = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} )); ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; const ScrollBar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, orientation = "vertical", ...props }, ref) => ( )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; export { ScrollArea, ScrollBar }; ================================================ FILE: packages/ui/components/select.tsx ================================================ import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { Check, ChevronDown, ChevronUp } from "lucide-react"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( span]:line-clamp-1", className, )} {...props} > {children} )); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; const SelectScrollUpButton = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; const SelectScrollDownButton = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; const SelectContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, position = "popper", ...props }, ref) => ( {children} )); SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => ( {children} )); SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, }; ================================================ FILE: packages/ui/components/separator.tsx ================================================ import * as React from "react"; import * as SeparatorPrimitive from "@radix-ui/react-separator"; import { cn } from "@/lib/utils"; const Separator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >( ( { className, orientation = "horizontal", decorative = true, ...props }, ref, ) => ( ), ); Separator.displayName = SeparatorPrimitive.Root.displayName; export { Separator }; ================================================ FILE: packages/ui/components/skeleton.tsx ================================================ import { cn } from "@/lib/utils"; function Skeleton({ className, ...props }: React.HTMLAttributes) { return (
); } export { Skeleton }; ================================================ FILE: packages/ui/components/switch.tsx ================================================ import * as React from "react"; import * as SwitchPrimitives from "@radix-ui/react-switch"; import { cn } from "@/lib/utils"; const Switch = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); Switch.displayName = SwitchPrimitives.Root.displayName; export { Switch }; ================================================ FILE: packages/ui/components/textarea.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; const Textarea = React.forwardRef< HTMLTextAreaElement, React.ComponentProps<"textarea"> >(({ className, ...props }, ref) => { return (