9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref,
13 | ) => (
14 |
25 | ),
26 | );
27 | Separator.displayName = SeparatorPrimitive.Root.displayName;
28 |
29 | export { Separator };
30 |
--------------------------------------------------------------------------------
/infra/stacks/edge/variables.tf:
--------------------------------------------------------------------------------
1 | variable "cloudflare_account_id" {
2 | type = string
3 | description = "Cloudflare account ID"
4 | }
5 |
6 | variable "cloudflare_zone_id" {
7 | type = string
8 | description = "Cloudflare zone ID (required when hostname is set)"
9 | default = ""
10 | }
11 |
12 | variable "hostname" {
13 | type = string
14 | description = "Public hostname (e.g., api.example.com). If empty, uses workers.dev URL."
15 | default = ""
16 | }
17 |
18 | variable "project_slug" {
19 | type = string
20 | description = "Short identifier for resource naming (e.g., myapp)"
21 | }
22 |
23 | variable "environment" {
24 | type = string
25 | description = "Environment name (e.g., dev, staging, prod)"
26 | }
27 |
28 | variable "neon_database_url" {
29 | type = string
30 | description = "Neon PostgreSQL connection string"
31 | sensitive = true
32 | }
33 |
--------------------------------------------------------------------------------
/apps/app/components/error.tsx:
--------------------------------------------------------------------------------
1 | import { useRouterState } from "@tanstack/react-router";
2 |
3 | export function RootError() {
4 | const routerState = useRouterState();
5 | const err = routerState.matches.find((match) => match.error)
6 | ?.error as RouteError | null;
7 |
8 | return (
9 |
17 |
25 | Error {err?.status || 500}:{" "}
26 | {err?.statusText ?? err?.message ?? "Unknown error"}
27 |
28 |
29 | );
30 | }
31 |
32 | type RouteError = Error & { status?: number; statusText?: string };
33 |
--------------------------------------------------------------------------------
/apps/app/lib/auth.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Better Auth client instance.
3 | *
4 | * Do not use auth.useSession() directly - use TanStack Query wrappers
5 | * from lib/queries/session.ts to ensure proper caching and consistency.
6 | */
7 |
8 | import { passkeyClient } from "@better-auth/passkey/client";
9 | import {
10 | anonymousClient,
11 | emailOTPClient,
12 | organizationClient,
13 | } from "better-auth/client/plugins";
14 | import { createAuthClient } from "better-auth/react";
15 | import { authConfig } from "./auth-config";
16 |
17 | const baseURL =
18 | typeof window !== "undefined"
19 | ? window.location.origin
20 | : "http://localhost:5173";
21 |
22 | export const auth = createAuthClient({
23 | baseURL: baseURL + authConfig.api.basePath,
24 | plugins: [
25 | anonymousClient(),
26 | emailOTPClient(),
27 | organizationClient(),
28 | passkeyClient(),
29 | ],
30 | });
31 |
32 | export type AuthClient = typeof auth;
33 |
--------------------------------------------------------------------------------
/apps/api/lib/ai.ts:
--------------------------------------------------------------------------------
1 | import type { OpenAIProvider } from "@ai-sdk/openai";
2 | import { createOpenAI } from "@ai-sdk/openai";
3 | import type { Env } from "./env";
4 |
5 | // Request-scoped cache key
6 | const OPENAI_CACHE_KEY = Symbol("openai");
7 |
8 | /**
9 | * Returns an OpenAI provider instance with request-scoped caching.
10 | * Uses the tRPC context cache to avoid recreating the provider multiple times
11 | * within the same request while ensuring environment isolation.
12 | */
13 | export function getOpenAI(env: Env, cache?: Map) {
14 | // Use request-scoped cache if available (from tRPC context)
15 | if (cache?.has(OPENAI_CACHE_KEY)) {
16 | return cache.get(OPENAI_CACHE_KEY) as OpenAIProvider;
17 | }
18 |
19 | const provider = createOpenAI({
20 | apiKey: env.OPENAI_API_KEY,
21 | });
22 |
23 | // Cache for this request only
24 | cache?.set(OPENAI_CACHE_KEY, provider);
25 |
26 | return provider;
27 | }
28 |
--------------------------------------------------------------------------------
/apps/api/lib/trpc.ts:
--------------------------------------------------------------------------------
1 | import { initTRPC, TRPCError } from "@trpc/server";
2 | import { flattenError, ZodError } from "zod";
3 | import type { TRPCContext } from "./context.js";
4 |
5 | const t = initTRPC.context().create({
6 | errorFormatter({ shape, error }) {
7 | return {
8 | ...shape,
9 | data: {
10 | ...shape.data,
11 | zodError:
12 | error.cause instanceof ZodError ? flattenError(error.cause) : null,
13 | },
14 | };
15 | },
16 | });
17 |
18 | export const router = t.router;
19 | export const publicProcedure = t.procedure;
20 |
21 | export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
22 | if (!ctx.session || !ctx.user) {
23 | throw new TRPCError({
24 | code: "UNAUTHORIZED",
25 | message: "Authentication required",
26 | });
27 | }
28 | return next({
29 | ctx: {
30 | ...ctx,
31 | session: ctx.session,
32 | user: ctx.user,
33 | },
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/apps/app/components/layout/sidebar-nav.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 | import type { LucideIcon } from "lucide-react";
3 |
4 | interface SidebarNavItem {
5 | icon: LucideIcon;
6 | label: string;
7 | to: string;
8 | }
9 |
10 | interface SidebarNavProps {
11 | items: SidebarNavItem[];
12 | }
13 |
14 | export function SidebarNav({ items }: SidebarNavProps) {
15 | return (
16 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/infra/templates/env-roots/hybrid/README.md:
--------------------------------------------------------------------------------
1 | # Hybrid Stack Root Template
2 |
3 | Copy this directory to enable hybrid stack for an environment:
4 |
5 | ```bash
6 | cp -r infra/templates/env-roots/hybrid infra/envs//hybrid
7 | ```
8 |
9 | After copying:
10 |
11 | 1. Copy `terraform.tfvars.example` to `terraform.tfvars` and fill in values
12 | 2. Never commit secrets—use `TF_VAR_*` environment variables in CI
13 |
14 | ## Edge Routing (optional)
15 |
16 | To add Cloudflare in front of Cloud Run, uncomment edge routing blocks in all three files:
17 |
18 | - `providers.tf` — Cloudflare provider
19 | - `variables.tf` — Cloudflare variables
20 | - `main.tf` — `enable_edge_routing = true` and related inputs
21 |
22 | > **Note:** Don't enable edge routing if you're also using the edge stack for the same hostname—they would conflict.
23 |
24 | ## When to use hybrid
25 |
26 | Only if you need Cloud Run compute, Vertex AI, or other GCP services. The edge stack handles most SaaS workloads.
27 |
--------------------------------------------------------------------------------
/apps/email/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/email",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": {
8 | "types": "./dist/index.d.ts",
9 | "import": "./dist/index.js"
10 | },
11 | "./templates/*": {
12 | "types": "./dist/templates/*.d.ts",
13 | "import": "./dist/templates/*.js"
14 | },
15 | "./package.json": "./package.json"
16 | },
17 | "scripts": {
18 | "dev": "bunx react-email dev --port 3001",
19 | "build": "tsc",
20 | "export": "bunx react-email export",
21 | "typecheck": "tsc --noEmit"
22 | },
23 | "dependencies": {
24 | "@react-email/components": "^1.0.1",
25 | "@react-email/render": "^2.0.0",
26 | "react": "19.2.1"
27 | },
28 | "devDependencies": {
29 | "react-email": "^5.0.5",
30 | "@react-email/preview-server": "^5.0.5",
31 | "@repo/typescript-config": "workspace:*",
32 | "@types/react": "^19.2.7",
33 | "typescript": "~5.9.3"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/apps/api/lib/db.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Database client using Neon PostgreSQL via Cloudflare Hyperdrive.
3 | *
4 | * Two bindings available: HYPERDRIVE_CACHED (60s cache) and HYPERDRIVE_DIRECT (no cache).
5 | */
6 |
7 | import { schema } from "@repo/db";
8 | import { drizzle } from "drizzle-orm/postgres-js";
9 | import postgres from "postgres";
10 |
11 | /**
12 | * Creates a database client using Drizzle ORM and Cloudflare Hyperdrive.
13 | *
14 | * @param db - Cloudflare Hyperdrive binding providing connection string
15 | */
16 | export function createDb(db: Hyperdrive) {
17 | const client = postgres(db.connectionString, {
18 | max: 1,
19 | connect_timeout: 10,
20 | prepare: false, // Avoids prepared statement caching issues in Workers
21 | idle_timeout: 20,
22 | max_lifetime: 60 * 30,
23 | transform: {
24 | undefined: null,
25 | },
26 | onnotice: () => {},
27 | });
28 |
29 | return drizzle(client, { schema, casing: "snake_case" });
30 | }
31 |
32 | export { schema as Db };
33 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/web",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "astro dev",
8 | "build": "astro build",
9 | "preview": "astro preview",
10 | "check": "astro check",
11 | "typecheck": "tsc --noEmit",
12 | "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local",
13 | "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
14 | },
15 | "dependencies": {
16 | "@astrojs/react": "^4.4.2",
17 | "@repo/ui": "workspace:*",
18 | "astro": "^5.16.4",
19 | "react": "^19.2.1",
20 | "react-dom": "^19.2.1"
21 | },
22 | "devDependencies": {
23 | "@repo/typescript-config": "workspace:*",
24 | "@tailwindcss/postcss": "^4.1.17",
25 | "@types/react": "^19.2.7",
26 | "@types/react-dom": "^19.2.3",
27 | "autoprefixer": "^10.4.22",
28 | "postcss": "^8.5.6",
29 | "tailwindcss": "^4.1.17",
30 | "typescript": "~5.9.3",
31 | "wrangler": "^4.53.0"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/infra/modules/gcp/cloud-sql/main.tf:
--------------------------------------------------------------------------------
1 | resource "google_sql_database_instance" "instance" {
2 | name = var.instance_name
3 | database_version = "POSTGRES_18"
4 |
5 | settings {
6 | edition = "ENTERPRISE"
7 | tier = var.tier
8 |
9 | disk_size = 10
10 | disk_type = "PD_SSD"
11 |
12 | ip_configuration {
13 | ipv4_enabled = var.private_network_id == null
14 | private_network = var.private_network_id
15 | }
16 |
17 | backup_configuration {
18 | enabled = false
19 | }
20 | }
21 | }
22 |
23 | resource "google_sql_database" "database" {
24 | name = var.database_name
25 | instance = google_sql_database_instance.instance.name
26 | }
27 |
28 | resource "random_password" "password" {
29 | length = 32
30 | special = true
31 | override_special = "-_"
32 | }
33 |
34 | resource "google_sql_user" "user" {
35 | name = var.database_name
36 | instance = google_sql_database_instance.instance.name
37 | password = random_password.password.result
38 | }
39 |
--------------------------------------------------------------------------------
/infra/envs/dev/edge/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/cloudflare/cloudflare" {
5 | version = "5.14.0"
6 | constraints = "~> 5.0"
7 | hashes = [
8 | "h1:5rwgZxUA7qCU4HWcE7VUE5hPqrAH1Bk9Rr13qEeR1KY=",
9 | "zh:0556cb6f38067c95e320f2d5680cf9851991d28448da903dd50b6c4b54de1c0b",
10 | "zh:7cdd70418aa6571c27de4102e3cc3228f6edbe4a1eaa7927f772234ee09fb519",
11 | "zh:84feef6c19993da06139e05dd6e1fceb7beb086f041cb9bc4edcae6081fe4812",
12 | "zh:8b7dfcececcced324a8a8344fcb48b746f218174ffc691e36a54d09f2fb5e797",
13 | "zh:99ba55ced06327bd8f16077f26d95593d3d77107e9faf204bf1a2485653eb02e",
14 | "zh:9e93df24a28ffe7551458035bae8ea1f4fdab74a9a47ce61f1008bc18025ecd0",
15 | "zh:a03e65a78c70578f3ebb3f2d84df516a33e2c3e9b62c0e3a2d2cea4f411c5e83",
16 | "zh:e57ab18a3275dee18d69910a1103a52c8071d77e449e50b1a8b9d80779068145",
17 | "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/infra/envs/preview/edge/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/cloudflare/cloudflare" {
5 | version = "5.14.0"
6 | constraints = "~> 5.0"
7 | hashes = [
8 | "h1:5rwgZxUA7qCU4HWcE7VUE5hPqrAH1Bk9Rr13qEeR1KY=",
9 | "zh:0556cb6f38067c95e320f2d5680cf9851991d28448da903dd50b6c4b54de1c0b",
10 | "zh:7cdd70418aa6571c27de4102e3cc3228f6edbe4a1eaa7927f772234ee09fb519",
11 | "zh:84feef6c19993da06139e05dd6e1fceb7beb086f041cb9bc4edcae6081fe4812",
12 | "zh:8b7dfcececcced324a8a8344fcb48b746f218174ffc691e36a54d09f2fb5e797",
13 | "zh:99ba55ced06327bd8f16077f26d95593d3d77107e9faf204bf1a2485653eb02e",
14 | "zh:9e93df24a28ffe7551458035bae8ea1f4fdab74a9a47ce61f1008bc18025ecd0",
15 | "zh:a03e65a78c70578f3ebb3f2d84df516a33e2c3e9b62c0e3a2d2cea4f411c5e83",
16 | "zh:e57ab18a3275dee18d69910a1103a52c8071d77e449e50b1a8b9d80779068145",
17 | "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/infra/envs/prod/edge/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/cloudflare/cloudflare" {
5 | version = "5.14.0"
6 | constraints = "~> 5.0"
7 | hashes = [
8 | "h1:5rwgZxUA7qCU4HWcE7VUE5hPqrAH1Bk9Rr13qEeR1KY=",
9 | "zh:0556cb6f38067c95e320f2d5680cf9851991d28448da903dd50b6c4b54de1c0b",
10 | "zh:7cdd70418aa6571c27de4102e3cc3228f6edbe4a1eaa7927f772234ee09fb519",
11 | "zh:84feef6c19993da06139e05dd6e1fceb7beb086f041cb9bc4edcae6081fe4812",
12 | "zh:8b7dfcececcced324a8a8344fcb48b746f218174ffc691e36a54d09f2fb5e797",
13 | "zh:99ba55ced06327bd8f16077f26d95593d3d77107e9faf204bf1a2485653eb02e",
14 | "zh:9e93df24a28ffe7551458035bae8ea1f4fdab74a9a47ce61f1008bc18025ecd0",
15 | "zh:a03e65a78c70578f3ebb3f2d84df516a33e2c3e9b62c0e3a2d2cea4f411c5e83",
16 | "zh:e57ab18a3275dee18d69910a1103a52c8071d77e449e50b1a8b9d80779068145",
17 | "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/infra/envs/staging/edge/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/cloudflare/cloudflare" {
5 | version = "5.14.0"
6 | constraints = "~> 5.0"
7 | hashes = [
8 | "h1:5rwgZxUA7qCU4HWcE7VUE5hPqrAH1Bk9Rr13qEeR1KY=",
9 | "zh:0556cb6f38067c95e320f2d5680cf9851991d28448da903dd50b6c4b54de1c0b",
10 | "zh:7cdd70418aa6571c27de4102e3cc3228f6edbe4a1eaa7927f772234ee09fb519",
11 | "zh:84feef6c19993da06139e05dd6e1fceb7beb086f041cb9bc4edcae6081fe4812",
12 | "zh:8b7dfcececcced324a8a8344fcb48b746f218174ffc691e36a54d09f2fb5e797",
13 | "zh:99ba55ced06327bd8f16077f26d95593d3d77107e9faf204bf1a2485653eb02e",
14 | "zh:9e93df24a28ffe7551458035bae8ea1f4fdab74a9a47ce61f1008bc18025ecd0",
15 | "zh:a03e65a78c70578f3ebb3f2d84df516a33e2c3e9b62c0e3a2d2cea4f411c5e83",
16 | "zh:e57ab18a3275dee18d69910a1103a52c8071d77e449e50b1a8b9d80779068145",
17 | "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32",
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/apps/app/components/layout/header.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@repo/ui";
2 | import { Menu, Settings, X } from "lucide-react";
3 |
4 | interface HeaderProps {
5 | isSidebarOpen: boolean;
6 | onMenuToggle: () => void;
7 | }
8 |
9 | export function Header({ isSidebarOpen, onMenuToggle }: HeaderProps) {
10 | return (
11 |
12 |
24 |
25 |
26 |
Application
27 |
28 |
29 |
30 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/apps/app/routes/(app)/route.tsx:
--------------------------------------------------------------------------------
1 | import { Layout } from "@/components/layout";
2 | import { getCachedSession, sessionQueryOptions } from "@/lib/queries/session";
3 | import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
4 |
5 | export const Route = createFileRoute("/(app)")({
6 | // Route-level authentication guard using cache-first strategy.
7 | // Checks cache before fetching to make navigation instant.
8 | beforeLoad: async ({ context, location }) => {
9 | let session = getCachedSession(context.queryClient);
10 |
11 | if (session === undefined) {
12 | session = await context.queryClient.fetchQuery(sessionQueryOptions());
13 | }
14 |
15 | // Both user and session must exist for valid auth state
16 | if (!session?.user || !session?.session) {
17 | throw redirect({
18 | to: "/login",
19 | search: { returnTo: location.href },
20 | });
21 | }
22 |
23 | return { user: session.user, session };
24 | },
25 | component: AppLayout,
26 | });
27 |
28 | function AppLayout() {
29 | return (
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/infra/templates/env-roots/hybrid/variables.tf:
--------------------------------------------------------------------------------
1 | variable "gcp_project_id" {
2 | type = string
3 | }
4 |
5 | variable "gcp_region" {
6 | type = string
7 | }
8 |
9 | variable "project_slug" {
10 | type = string
11 | description = "Short identifier for resource naming (e.g., myapp)"
12 | }
13 |
14 | variable "environment" {
15 | type = string
16 | description = "Environment name (e.g., dev, staging, prod)"
17 | }
18 |
19 | variable "api_image" {
20 | type = string
21 | }
22 |
23 | variable "cloud_sql_tier" {
24 | type = string
25 | description = "Cloud SQL instance tier (e.g., db-f1-micro)"
26 | default = "db-f1-micro"
27 | }
28 |
29 | # --- Edge routing (optional) ---
30 | # Uncomment to add Cloudflare edge layer in front of Cloud Run.
31 | # Also uncomment the Cloudflare provider in providers.tf and module inputs in main.tf.
32 | #
33 | # variable "cloudflare_api_token" {
34 | # type = string
35 | # sensitive = true
36 | # }
37 | #
38 | # variable "cloudflare_zone_id" {
39 | # type = string
40 | # default = ""
41 | # }
42 | #
43 | # variable "hostname" {
44 | # type = string
45 | # }
46 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2014-present Konstantin Tarkus, Kriasoft (hello@kriasoft.com)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/.claude/commands/review-better-auth.md:
--------------------------------------------------------------------------------
1 | Verify Better Auth integration to ensure that it is properly configured, up-to-date and functioning as expected. This includes:
2 |
3 | - [ ] 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.
4 |
5 | - [ ] 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.
6 |
7 | - [ ] Verify db indexes, relations, constraints, default values, and other Better Auth specific database schema features in @db/schema/.
8 |
9 | - [ ] 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.
10 |
11 | - [ ] Verify betterAuth initialization logic in both client and server codebases.
12 |
--------------------------------------------------------------------------------
/packages/ui/components/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3 | import { Check } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ));
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
27 |
28 | export { Checkbox };
29 |
--------------------------------------------------------------------------------
/infra/modules/cloudflare/hyperdrive/main.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | cloudflare = {
4 | source = "cloudflare/cloudflare"
5 | }
6 | }
7 | }
8 |
9 | locals {
10 | # Normalize postgresql:// to postgres:// for regex parsing.
11 | # Limitation: credentials must not contain unencoded @ or : characters.
12 | # This works reliably with Neon URLs which use URL-safe generated credentials.
13 | db_url = replace(var.database_url, "postgresql://", "postgres://")
14 | }
15 |
16 | resource "cloudflare_hyperdrive_config" "hyperdrive" {
17 | account_id = var.account_id
18 | name = var.name
19 |
20 | mtls = {}
21 |
22 | origin = {
23 | database = regex("^postgres://[^:]+:[^@]+@[^:/]+:[0-9]+/([^?]+)", local.db_url)[0]
24 | password = regex("^postgres://[^:]+:([^@]+)@", local.db_url)[0]
25 | host = regex("^postgres://[^:]+:[^@]+@([^:/]+):", local.db_url)[0]
26 | port = tonumber(regex("^postgres://[^:]+:[^@]+@[^:/]+:([0-9]+)/", local.db_url)[0])
27 | scheme = "postgres"
28 | user = regex("^postgres://([^:]+):", local.db_url)[0]
29 | }
30 |
31 | origin_connection_limit = 60
32 |
33 | caching = {
34 | disabled = true
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/apps/api/routers/user.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { protectedProcedure, router } from "../lib/trpc.js";
3 |
4 | export const userRouter = router({
5 | me: protectedProcedure.query(async ({ ctx }) => {
6 | // User is now directly available in context from Better Auth
7 | return {
8 | id: ctx.user.id,
9 | email: ctx.user.email,
10 | name: ctx.user.name,
11 | };
12 | }),
13 |
14 | updateProfile: protectedProcedure
15 | .input(
16 | z.object({
17 | name: z.string().min(1).optional(),
18 | email: z.email({ message: "Invalid email address" }).optional(),
19 | }),
20 | )
21 | .mutation(({ input, ctx }) => {
22 | // TODO: Implement user profile update logic
23 | return {
24 | id: ctx.user.id,
25 | ...input,
26 | };
27 | }),
28 |
29 | list: protectedProcedure
30 | .input(
31 | z.object({
32 | limit: z.number().min(1).max(100).default(10),
33 | cursor: z.string().optional(),
34 | }),
35 | )
36 | .query(() => {
37 | // TODO: Implement user listing logic
38 | return {
39 | users: [],
40 | nextCursor: null,
41 | };
42 | }),
43 | });
44 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Preview (Reusable)
2 |
3 | on:
4 | workflow_call:
5 | inputs:
6 | name:
7 | description: "Name of the deployment"
8 | required: true
9 | type: string
10 | environment:
11 | required: true
12 | type: string
13 | url:
14 | description: "URL of the deployment"
15 | required: true
16 | type: string
17 |
18 | jobs:
19 | environment:
20 | name: ${{ inputs.name }}
21 | runs-on: ubuntu-latest
22 | permissions:
23 | deployments: write
24 | pull-requests: read
25 | environment:
26 | name: ${{ inputs.environment }}
27 | url: ${{ steps.pr.outputs.formatted }}
28 | steps:
29 | - uses: kriasoft/pr-codename@v1
30 | id: pr
31 | with:
32 | template: ${{ inputs.url }}
33 | token: ${{ github.token }}
34 | # TODO: Add deployment steps
35 | # - run: bun wrangler deploy --config apps/api/wrangler.jsonc --env=${{ inputs.environment }}
36 | # - run: bun wrangler deploy --config apps/app/wrangler.jsonc --env=${{ inputs.environment }}
37 | # - run: bun wrangler deploy --config apps/web/wrangler.jsonc --env=${{ inputs.environment }}
38 |
--------------------------------------------------------------------------------
/apps/web/_headers:
--------------------------------------------------------------------------------
1 | /*
2 | Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
3 | X-Content-Type-Options: nosniff
4 | Referrer-Policy: strict-origin-when-cross-origin
5 | Permissions-Policy: camera=(), microphone=(), geolocation=()
6 | Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'
7 | X-Frame-Options: DENY
8 |
9 | /*.html
10 | Cache-Control: public, max-age=0, must-revalidate
11 |
12 | /*.css
13 | Cache-Control: public, max-age=31536000, immutable
14 |
15 | /*.js
16 | Cache-Control: public, max-age=31536000, immutable
17 |
18 | /*.woff2
19 | Cache-Control: public, max-age=31536000, immutable
20 |
21 | /*.woff
22 | Cache-Control: public, max-age=31536000, immutable
23 |
24 | /*.ttf
25 | Cache-Control: public, max-age=31536000, immutable
26 |
27 | /*.svg
28 | Cache-Control: public, max-age=86400
29 |
30 | /*.jpg
31 | Cache-Control: public, max-age=86400
32 |
33 | /*.jpeg
34 | Cache-Control: public, max-age=86400
35 |
36 | /*.png
37 | Cache-Control: public, max-age=86400
38 |
39 | /*.webp
40 | Cache-Control: public, max-age=86400
41 |
42 | /*.ico
43 | Cache-Control: public, max-age=86400
44 |
--------------------------------------------------------------------------------
/packages/ui/components/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/apps/app/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | %VITE_APP_NAME%
6 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/apps/api/worker.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Cloudflare Workers entrypoint.
3 | *
4 | * Initializes database and auth context, then mounts the core Hono app.
5 | */
6 |
7 | import { Hono } from "hono";
8 | import app from "./lib/app.js";
9 | import { createAuth } from "./lib/auth.js";
10 | import type { AppContext } from "./lib/context.js";
11 | import { createDb } from "./lib/db.js";
12 | import type { Env } from "./lib/env.js";
13 |
14 | type CloudflareEnv = {
15 | HYPERDRIVE_CACHED: Hyperdrive;
16 | HYPERDRIVE_DIRECT: Hyperdrive;
17 | } & Env;
18 |
19 | // Create a Hono app with Cloudflare Workers context
20 | const worker = new Hono<{
21 | Bindings: CloudflareEnv;
22 | Variables: AppContext["Variables"];
23 | }>();
24 |
25 | // Initialize shared context for all requests
26 | worker.use("*", async (c, next) => {
27 | // Initialize database using Neon via Hyperdrive
28 | const db = createDb(c.env.HYPERDRIVE_CACHED);
29 | const dbDirect = createDb(c.env.HYPERDRIVE_DIRECT);
30 |
31 | // Initialize auth
32 | const auth = createAuth(db, c.env);
33 |
34 | // Set context variables
35 | c.set("db", db);
36 | c.set("dbDirect", dbDirect);
37 | c.set("auth", auth);
38 |
39 | await next();
40 | });
41 |
42 | // Mount the core API app
43 | worker.route("/", app);
44 |
45 | export default worker;
46 |
--------------------------------------------------------------------------------
/apps/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClientProvider } from "@tanstack/react-query";
2 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
3 | import { createRouter, RouterProvider } from "@tanstack/react-router";
4 | import { StrictMode } from "react";
5 | import { createRoot } from "react-dom/client";
6 | import { auth } from "./lib/auth";
7 | import { queryClient } from "./lib/query";
8 | import { routeTree } from "./lib/routeTree.gen";
9 | import "./styles/globals.css";
10 |
11 | const router = createRouter({
12 | routeTree,
13 | context: {
14 | auth,
15 | queryClient,
16 | },
17 | });
18 |
19 | const container = document.getElementById("root");
20 | const root = createRoot(container!);
21 |
22 | root.render(
23 |
24 |
25 |
26 | {import.meta.env.DEV && (
27 |
31 | )}
32 |
33 | ,
34 | );
35 |
36 | if (import.meta.hot) {
37 | import.meta.hot.dispose(() => root.unmount());
38 | }
39 |
40 | declare module "@tanstack/react-router" {
41 | interface Register {
42 | router: typeof router;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/scripts/mcp.ts:
--------------------------------------------------------------------------------
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import { fileURLToPath } from "bun";
4 | import { execa } from "execa";
5 | import { z } from "zod";
6 |
7 | const rootDir = fileURLToPath(new URL("..", import.meta.url));
8 | const $ = execa({ cwd: rootDir });
9 |
10 | /**
11 | * Model Context Protocol (MCP) server.
12 | *
13 | * @see https://modelcontextprotocol.org
14 | * @see https://code.visualstudio.com/docs/copilot/chat/mcp-servers
15 | */
16 | const server = new McpServer({
17 | name: "React Starter Kit",
18 | version: "0.0.0",
19 | });
20 |
21 | // This is just an example of a custom command that can be executed
22 | // from the MCP client (e.g., VS Code, GitHub Copilot, etc.).
23 | server.tool(
24 | "eslint",
25 | "Lint JavaScript and TypeScript files with ESLint",
26 | {
27 | filename: z.string(),
28 | },
29 | async function lint({ filename }) {
30 | const cmd = await $`bun run eslint ${filename}`;
31 | return {
32 | content: [{ type: "text", text: cmd.stdout }],
33 | };
34 | },
35 | );
36 |
37 | // Start receiving messages on stdin and sending messages on stdout
38 | const transport = new StdioServerTransport();
39 | await server.connect(transport);
40 |
--------------------------------------------------------------------------------
/infra/stacks/hybrid/variables.tf:
--------------------------------------------------------------------------------
1 | variable "gcp_project_id" {
2 | type = string
3 | description = "GCP project ID"
4 | }
5 |
6 | variable "gcp_region" {
7 | type = string
8 | description = "GCP region (e.g., us-central1)"
9 | }
10 |
11 | variable "project_slug" {
12 | type = string
13 | description = "Short identifier for resource naming (e.g., myapp)"
14 | }
15 |
16 | variable "environment" {
17 | type = string
18 | description = "Environment name (e.g., dev, staging, prod)"
19 | }
20 |
21 | variable "api_image" {
22 | type = string
23 | description = "Container image URL for Cloud Run API service"
24 | }
25 |
26 | variable "cloud_sql_tier" {
27 | type = string
28 | description = "Cloud SQL instance tier (e.g., db-f1-micro)"
29 | default = "db-f1-micro"
30 | }
31 |
32 | variable "enable_edge_routing" {
33 | type = bool
34 | description = "Enable Cloudflare edge routing in front of Cloud Run"
35 | default = false
36 | }
37 |
38 | variable "cloudflare_zone_id" {
39 | type = string
40 | description = "Cloudflare zone ID (required when enable_edge_routing = true and hostname is set)"
41 | default = ""
42 | }
43 |
44 | variable "hostname" {
45 | type = string
46 | description = "Public hostname for edge routing (e.g., api-gcp.example.com)"
47 | default = ""
48 | }
49 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Include your project-specific ignores in this file
2 | # Read about how to use .gitignore: https://help.github.com/articles/ignoring-files
3 |
4 | # Compiled output
5 | **/dist/
6 | **/*.tsbuildinfo
7 |
8 | # Bun package manager
9 | # https://bun.sh/docs/install/lockfile
10 | node_modules/
11 |
12 | # Logs
13 | *.log
14 |
15 | # Cache
16 | /.cache
17 | /*/.swc/
18 | .eslintcache
19 |
20 | # Testing
21 | /coverage
22 | *.lcov
23 |
24 | # Environment variables
25 | .env.*.local
26 | .env.local
27 |
28 | # Visual Studio Code
29 | # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
30 | .vscode/*
31 | !.vscode/extensions.json
32 | !.vscode/launch.json
33 | !.vscode/mcp.json
34 | !.vscode/settings.json
35 | !.vscode/tasks.json
36 |
37 | # WebStorm
38 | .idea
39 |
40 | # Wrangler CLI
41 | # https://developers.cloudflare.com/workers/wrangler/
42 | .wrangler/
43 |
44 | # Astro
45 | .astro/
46 |
47 | # TanStack Router
48 | # Generated route tree files should not be committed
49 | */lib/routeTree.gen.ts
50 | */.tanstack/
51 |
52 | # VitePress
53 | docs/.vitepress/cache
54 | docs/.vitepress/dist
55 |
56 | # React Email
57 | # Generated preview build and runtime files
58 | .react-email/
59 |
60 | # Local development files
61 | *.local.md
62 | *.local.json
63 | *.local.jsonc
64 | *.local.ts
65 |
66 | # macOS
67 | # https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
68 | .DS_Store
69 |
70 |
--------------------------------------------------------------------------------
/apps/app/lib/trpc.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouter } from "@repo/api";
2 | import {
3 | createTRPCClient,
4 | httpBatchLink,
5 | type TRPCLink,
6 | loggerLink,
7 | } from "@trpc/client";
8 | import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
9 | import { queryClient } from "./query";
10 |
11 | // Build links array conditionally based on environment
12 | const links: TRPCLink[] = [];
13 |
14 | // Add logger link in development for debugging
15 | if (import.meta.env.DEV) {
16 | links.push(
17 | loggerLink({
18 | enabled: (opts) =>
19 | (import.meta.env.DEV && typeof window !== "undefined") ||
20 | (opts.direction === "down" && opts.result instanceof Error),
21 | }),
22 | );
23 | }
24 |
25 | // Add HTTP batch link for actual requests
26 | links.push(
27 | httpBatchLink({
28 | url: `${import.meta.env.VITE_API_URL || "/api"}/trpc`,
29 | // Custom headers for request tracking
30 | headers() {
31 | return {
32 | "x-trpc-source": "react-app",
33 | };
34 | },
35 | // Include credentials for authentication
36 | fetch(url, options) {
37 | return fetch(url, {
38 | ...options,
39 | credentials: "include",
40 | });
41 | },
42 | }),
43 | );
44 |
45 | export const trpcClient = createTRPCClient({ links });
46 |
47 | export const api = createTRPCOptionsProxy({
48 | client: trpcClient,
49 | queryClient,
50 | });
51 |
--------------------------------------------------------------------------------
/packages/ui/scripts/format-utils.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 | import { join } from "node:path";
3 | import { Glob } from "bun";
4 |
5 | /**
6 | * Execute a command with inherited stdio
7 | */
8 | export async function execCommand(
9 | command: string,
10 | args: string[],
11 | ): Promise {
12 | const proc = Bun.spawn([command, ...args], {
13 | stdio: ["inherit", "inherit", "inherit"],
14 | });
15 |
16 | const exitCode = await proc.exited;
17 | if (exitCode !== 0) {
18 | throw new Error(`Command failed: ${command} ${args.join(" ")}`);
19 | }
20 | }
21 |
22 | /**
23 | * Format generated UI component files with Prettier
24 | */
25 | export async function formatGeneratedFiles(): Promise {
26 | try {
27 | const componentsDir = join(import.meta.dirname, "../components");
28 |
29 | const glob = new Glob("**/*.{ts,tsx}");
30 | const componentFiles: string[] = [];
31 |
32 | for await (const file of glob.scan({
33 | cwd: componentsDir,
34 | absolute: true,
35 | })) {
36 | componentFiles.push(file);
37 | }
38 |
39 | if (componentFiles.length === 0) {
40 | return;
41 | }
42 |
43 | console.log("🎨 Formatting generated files with Prettier...");
44 |
45 | await execCommand("bunx", ["prettier", "--write", ...componentFiles]);
46 |
47 | console.log("✨ Files formatted successfully");
48 | } catch (error) {
49 | console.warn("⚠️ Failed to format files with Prettier:", error);
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/db/seeds/users.ts:
--------------------------------------------------------------------------------
1 | import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2 | import * as schema from "../schema";
3 | import { type NewUser, user } from "../schema";
4 |
5 | /**
6 | * Seeds the database with test user accounts.
7 | */
8 | export async function seedUsers(db: PostgresJsDatabase) {
9 | console.log("Seeding users...");
10 |
11 | // Test user data with realistic names and email addresses
12 | const users: NewUser[] = [
13 | { name: "Alice Johnson", email: "alice@example.com", emailVerified: true },
14 | { name: "Bob Smith", email: "bob@example.com", emailVerified: true },
15 | {
16 | name: "Charlie Brown",
17 | email: "charlie@example.com",
18 | emailVerified: false,
19 | },
20 | { name: "Diana Prince", email: "diana@example.com", emailVerified: true },
21 | { name: "Eve Davis", email: "eve@example.com", emailVerified: true },
22 | { name: "Frank Miller", email: "frank@example.com", emailVerified: false },
23 | { name: "Grace Lee", email: "grace@example.com", emailVerified: true },
24 | { name: "Henry Wilson", email: "henry@example.com", emailVerified: true },
25 | { name: "Ivy Chen", email: "ivy@example.com", emailVerified: false },
26 | { name: "Jack Thompson", email: "jack@example.com", emailVerified: true },
27 | ];
28 |
29 | for (const u of users) {
30 | await db.insert(user).values(u).onConflictDoNothing();
31 | }
32 |
33 | console.log(`✅ Seeded ${users.length} test users`);
34 | }
35 |
--------------------------------------------------------------------------------
/apps/api/lib/env.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | /**
4 | * Zod schema for validating environment variables.
5 | * Ensures all required configuration values are present and correctly formatted.
6 | *
7 | * @throws {ZodError} When environment variables don't match the schema
8 | */
9 | export const envSchema = z.object({
10 | ENVIRONMENT: z.enum(["production", "staging", "preview", "development"]),
11 | APP_NAME: z.string().default("Example"),
12 | APP_ORIGIN: z.url(),
13 | DATABASE_URL: z.url(),
14 | BETTER_AUTH_SECRET: z.string().min(32),
15 | GOOGLE_CLIENT_ID: z.string(),
16 | GOOGLE_CLIENT_SECRET: z.string(),
17 | OPENAI_API_KEY: z.string(),
18 | RESEND_API_KEY: z.string(),
19 | RESEND_EMAIL_FROM: z.email(),
20 | });
21 |
22 | /**
23 | * Runtime environment variables accessor.
24 | *
25 | * @remarks
26 | * - In Bun runtime: Variables are accessed via `Bun.env`
27 | * - In Cloudflare Workers: Variables must be accessed via request context
28 | * - Falls back to empty object when Bun global is unavailable
29 | *
30 | * @example
31 | * // In Bun runtime
32 | * const dbUrl = env.DATABASE_URL;
33 | *
34 | * // In Cloudflare Workers (must use context)
35 | * const dbUrl = context.env.DATABASE_URL;
36 | */
37 | export const env =
38 | typeof Bun === "undefined" ? ({} as Env) : envSchema.parse(Bun.env);
39 |
40 | /**
41 | * Type-safe environment variables interface.
42 | * Inferred from the Zod schema to ensure type safety.
43 | */
44 | export type Env = z.infer;
45 |
--------------------------------------------------------------------------------
/infra/stacks/hybrid/main.tf:
--------------------------------------------------------------------------------
1 | # Hybrid stack: GCP backend with optional Cloudflare edge.
2 | # Workers are deployed separately via Wrangler.
3 |
4 | # Cloud SQL PostgreSQL
5 | module "database" {
6 | source = "../../modules/gcp/cloud-sql"
7 |
8 | project_id = var.gcp_project_id
9 | region = var.gcp_region
10 | instance_name = "${var.project_slug}-${var.environment}"
11 | database_name = var.project_slug
12 | tier = var.cloud_sql_tier
13 | }
14 |
15 | # GCS bucket for uploads
16 | module "storage" {
17 | source = "../../modules/gcp/gcs"
18 |
19 | project_id = var.gcp_project_id
20 | location = var.gcp_region
21 | bucket_name = "${var.project_slug}-${var.environment}-uploads"
22 | }
23 |
24 | # Cloud Run API service
25 | module "api" {
26 | source = "../../modules/gcp/cloud-run"
27 |
28 | project_id = var.gcp_project_id
29 | region = var.gcp_region
30 | service_name = "${var.project_slug}-api-${var.environment}"
31 | image = var.api_image
32 |
33 | cloud_sql_connection = module.database.connection_name
34 |
35 | env_vars = {
36 | DATABASE_URL = module.database.connection_string
37 | GCS_BUCKET = module.storage.bucket_name
38 | }
39 | }
40 |
41 | # Optional: Cloudflare DNS for edge routing.
42 | # Deploy the edge proxy Worker separately via Wrangler.
43 | module "dns" {
44 | count = var.enable_edge_routing && var.hostname != "" ? 1 : 0
45 | source = "../../modules/cloudflare/dns"
46 |
47 | zone_id = var.cloudflare_zone_id
48 | hostname = var.hostname
49 | }
50 |
--------------------------------------------------------------------------------
/db/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { configDotenv } from "dotenv";
2 | import { defineConfig } from "drizzle-kit";
3 | import { resolve } from "node:path";
4 |
5 | // Environment detection: ENVIRONMENT var takes priority, then NODE_ENV mapping
6 | const envName = (() => {
7 | if (process.env.ENVIRONMENT) return process.env.ENVIRONMENT;
8 | if (process.env.NODE_ENV === "production") return "prod";
9 | if (process.env.NODE_ENV === "staging") return "staging";
10 | if (process.env.NODE_ENV === "test") return "test";
11 | return "dev";
12 | })();
13 |
14 | // Load .env files in priority order: environment-specific → local → base
15 | for (const file of [`.env.${envName}.local`, ".env.local", ".env"]) {
16 | configDotenv({ path: resolve(__dirname, "..", file), quiet: true });
17 | }
18 |
19 | if (!process.env.DATABASE_URL) {
20 | throw new Error("DATABASE_URL environment variable is required");
21 | }
22 |
23 | // Validate DATABASE_URL format (accepts both postgres:// and postgresql://)
24 | if (!/^postgre(s|sql):\/\/.+/.test(process.env.DATABASE_URL)) {
25 | throw new Error("DATABASE_URL must be a valid PostgreSQL connection string");
26 | }
27 |
28 | /**
29 | * Drizzle ORM configuration for Neon PostgreSQL database
30 | *
31 | * @see https://orm.drizzle.team/docs/drizzle-config-file
32 | * @see https://orm.drizzle.team/llms.txt
33 | */
34 | export default defineConfig({
35 | out: "./migrations",
36 | schema: "./schema",
37 | dialect: "postgresql",
38 | casing: "snake_case",
39 | dbCredentials: {
40 | url: process.env.DATABASE_URL,
41 | },
42 | });
43 |
--------------------------------------------------------------------------------
/apps/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/api",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": "./index.ts",
8 | "./auth": "./lib/auth.ts",
9 | "./package.json": "./package.json"
10 | },
11 | "scripts": {
12 | "predev": "bun --filter @repo/email build",
13 | "dev": "bun run --watch --env-file ../../.env --env-file ../../.env.local ./dev.ts",
14 | "build": "bun build index.ts --outdir dist --target bun",
15 | "test": "vitest",
16 | "typecheck": "tsc --noEmit",
17 | "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local",
18 | "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
19 | },
20 | "dependencies": {
21 | "@ai-sdk/openai": "^2.0.77",
22 | "@better-auth/passkey": "^1.4.5",
23 | "@repo/core": "workspace:*",
24 | "@repo/db": "workspace:*",
25 | "@repo/email": "workspace:*",
26 | "@trpc/server": "^11.7.2",
27 | "ai": "^5.0.107",
28 | "better-auth": "^1.4.5",
29 | "dataloader": "^2.2.3",
30 | "drizzle-orm": "^0.45.0",
31 | "postgres": "^3.4.7",
32 | "resend": "^6.5.2"
33 | },
34 | "peerDependencies": {
35 | "hono": "^4.10.7",
36 | "zod": "^4.1.13"
37 | },
38 | "devDependencies": {
39 | "@cloudflare/workers-types": "^4.20251205.0",
40 | "@repo/typescript-config": "workspace:*",
41 | "@types/bun": "^1.3.3",
42 | "hono": "^4.10.7",
43 | "typescript": "~5.9.3",
44 | "vitest": "~4.0.15",
45 | "wrangler": "^4.53.0",
46 | "zod": "^4.1.13"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/ui/scripts/add.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 | import { execCommand, formatGeneratedFiles } from "./format-utils.js";
3 |
4 | async function addComponent(): Promise {
5 | const args = process.argv.slice(2);
6 |
7 | if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
8 | console.log("📋 shadcn/ui Component Installer");
9 | console.log("=================================\n");
10 | console.log("Usage:");
11 | console.log(" bun run ui:add Add a single component");
12 | console.log(" bun run ui:add Add multiple components");
13 | console.log(
14 | " bun run ui:add --all Add all available components",
15 | );
16 | console.log("\nExamples:");
17 | console.log(" bun run ui:add button");
18 | console.log(" bun run ui:add button card input");
19 | console.log(" bun run ui:add dialog alert-dialog toast");
20 |
21 | if (args.length === 0) {
22 | process.exit(1);
23 | } else {
24 | process.exit(0);
25 | }
26 | }
27 |
28 | console.log("🚀 Adding shadcn/ui components...");
29 |
30 | try {
31 | const shadcnArgs = ["shadcn@latest", "add", ...args, "--yes"];
32 |
33 | console.log(`Running: bunx ${shadcnArgs.join(" ")}`);
34 | await execCommand("bunx", shadcnArgs);
35 |
36 | await formatGeneratedFiles();
37 |
38 | console.log("✅ Components added successfully!");
39 | } catch (error) {
40 | console.error("❌ Failed to add components:", error);
41 | process.exit(1);
42 | }
43 | }
44 |
45 | addComponent().catch(console.error);
46 |
--------------------------------------------------------------------------------
/packages/ui/components/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
3 | import { Circle } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return (
12 |
17 | );
18 | });
19 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
20 |
21 | const RadioGroupItem = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => {
25 | return (
26 |
34 |
35 |
36 |
37 |
38 | );
39 | });
40 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
41 |
42 | export { RadioGroup, RadioGroupItem };
43 |
--------------------------------------------------------------------------------
/apps/email/README.md:
--------------------------------------------------------------------------------
1 | # @repo/email
2 |
3 | A collection of transactional email templates built with React Email.
4 |
5 | ## Templates
6 |
7 | - **EmailVerification** - Email verification with verification link
8 | - **PasswordReset** - Password reset with secure reset link
9 | - **OTPEmail** - One-time password codes for sign-in, verification, or password reset
10 |
11 | ## Development
12 |
13 | ```bash
14 | # Start email preview development server
15 | bun email:dev
16 |
17 | # Build email templates
18 | bun email:build
19 |
20 | # Export static email templates
21 | bun email:export
22 | ```
23 |
24 | The development server will be available at
25 |
26 | ## Usage
27 |
28 | ```typescript
29 | import { EmailVerification, renderEmailToHtml } from "@repo/email";
30 |
31 | const component = EmailVerification({
32 | userName: "John Doe",
33 | verificationUrl: "https://example.com/verify?token=abc123",
34 | appName: "My App",
35 | appUrl: "https://example.com",
36 | });
37 |
38 | const html = await renderEmailToHtml(component);
39 | ```
40 |
41 | ## Template Structure
42 |
43 | - `templates/` - React Email component templates
44 | - `components/` - Shared components (BaseTemplate)
45 | - `utils/` - Rendering utilities
46 | - `emails/` - Preview files for development server
47 |
48 | ## References
49 |
50 | - [React Email Documentation](https://react.email/docs/introduction) - Official React Email guide
51 | - [React Email Components](https://react.email/components) - Available email components
52 | - [Better Auth Email Integration](https://better-auth.com/docs/concepts/email) - Authentication email setup
53 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | /* Visit https://aka.ms/tsconfig to read more about this file */
3 | "$schema": "https://json.schemastore.org/tsconfig",
4 | "compilerOptions": {
5 | /* Type Checking */
6 | "strict": true,
7 | "noImplicitAny": true,
8 | "noImplicitOverride": true,
9 | "noImplicitThis": true,
10 | "strictNullChecks": true,
11 | "strictPropertyInitialization": true,
12 |
13 | /* Modules */
14 | "module": "ESNext",
15 | "moduleResolution": "Bundler",
16 | "baseUrl": "../../",
17 | "paths": {
18 | "@repo/api": ["apps/api"],
19 | "@repo/api/*": ["apps/api/*"],
20 | "@repo/core": ["packages/core"],
21 | "@repo/core/*": ["packages/core/*"],
22 | "@repo/db": ["db"],
23 | "@repo/db/*": ["db/*"],
24 | "@repo/ws-protocol": ["packages/ws-protocol"],
25 | "@repo/ws-protocol/*": ["packages/ws-protocol/*"],
26 | "@/*": ["apps/web/*"]
27 | },
28 | "resolveJsonModule": true,
29 | "allowImportingTsExtensions": false,
30 |
31 | /* Emit */
32 | // noEmit is set per-package based on needs
33 |
34 | /* JavaScript Support */
35 | "allowJs": true,
36 |
37 | /* Interop Constraints */
38 | "allowSyntheticDefaultImports": true,
39 | "esModuleInterop": true,
40 | "forceConsistentCasingInFileNames": true,
41 | "isolatedModules": true,
42 | "verbatimModuleSyntax": true,
43 |
44 | /* Language and Environment */
45 | "target": "ESNext",
46 | "lib": ["ESNext"],
47 | "useDefineForClassFields": true,
48 |
49 | /* Completeness */
50 | "skipLibCheck": true
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/packages/ui/components/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/packages/typescript-config/README.md:
--------------------------------------------------------------------------------
1 | # TypeScript Configuration
2 |
3 | Shared TypeScript configuration for the monorepo.
4 |
5 | ## Usage
6 |
7 | Add this package as a development dependency:
8 |
9 | ```json
10 | {
11 | "devDependencies": {
12 | "@repo/typescript-config": "workspace:*"
13 | }
14 | }
15 | ```
16 |
17 | Then extend from the appropriate configuration in your `tsconfig.json`:
18 |
19 | ### For React applications
20 |
21 | ```json
22 | {
23 | "extends": "@repo/typescript-config/react.json",
24 | "include": ["src"],
25 | "exclude": ["node_modules"]
26 | }
27 | ```
28 |
29 | ### For Node.js/Bun applications
30 |
31 | ```json
32 | {
33 | "extends": "@repo/typescript-config/node.json",
34 | "include": ["src"],
35 | "exclude": ["node_modules"]
36 | }
37 | ```
38 |
39 | ### For Cloudflare Workers
40 |
41 | ```json
42 | {
43 | "extends": "@repo/typescript-config/cloudflare.json",
44 | "include": ["src"],
45 | "exclude": ["node_modules"]
46 | }
47 | ```
48 |
49 | ## Available Configurations
50 |
51 | - `base.json` - Core configuration with strict mode and modern defaults
52 | - `react.json` - React applications with DOM types and JSX support
53 | - `node.json` - Node.js/Bun backend services
54 | - `cloudflare.json` - Cloudflare Workers edge functions
55 |
56 | ## Features
57 |
58 | - **Strict mode** enabled by default for maximum type safety
59 | - **Modern module resolution** optimized for bundlers
60 | - **Project references** support for better monorepo performance
61 | - **Centralized path aliases** for consistent imports across the monorepo
62 | - **Environment-specific** configurations for different runtime targets
63 |
--------------------------------------------------------------------------------
/infra/modules/gcp/cloud-run/main.tf:
--------------------------------------------------------------------------------
1 | resource "google_cloud_run_v2_service" "service" {
2 | name = var.service_name
3 | location = var.region
4 | deletion_protection = false
5 | ingress = "INGRESS_TRAFFIC_ALL"
6 | invoker_iam_disabled = true # Allow public access without IAM checks
7 |
8 | template {
9 | scaling {
10 | min_instance_count = 0
11 | max_instance_count = 10
12 | }
13 |
14 | dynamic "volumes" {
15 | for_each = var.cloud_sql_connection != null ? [1] : []
16 | content {
17 | name = "cloudsql"
18 | cloud_sql_instance {
19 | instances = [var.cloud_sql_connection]
20 | }
21 | }
22 | }
23 |
24 | containers {
25 | image = var.image
26 |
27 | ports {
28 | container_port = 8080
29 | }
30 |
31 | resources {
32 | limits = {
33 | cpu = "1"
34 | memory = "512Mi"
35 | }
36 | cpu_idle = true
37 | }
38 |
39 | dynamic "volume_mounts" {
40 | for_each = var.cloud_sql_connection != null ? [1] : []
41 | content {
42 | name = "cloudsql"
43 | mount_path = "/cloudsql"
44 | }
45 | }
46 |
47 | dynamic "env" {
48 | for_each = var.env_vars
49 | content {
50 | name = env.key
51 | value = env.value
52 | }
53 | }
54 | }
55 | }
56 |
57 | lifecycle {
58 | ignore_changes = [
59 | template[0].containers[0].image, # Allow gcloud to deploy new images
60 | template[0].revision,
61 | template[0].labels,
62 | ]
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | hero:
6 | name: "React Starter Kit"
7 | # text: "Production-ready monorepo for building fast web apps"
8 | tagline: Skip months of setup and ship your AI-powered SaaS fast. Authentication, database migrations, edge deployment, and cutting-edge React patterns all configured with industry best practices.
9 | actions:
10 | - theme: brand
11 | text: Getting Started
12 | link: /getting-started
13 | - theme: alt
14 | text: View on GitHub
15 | link: https://github.com/kriasoft/react-starter-kit
16 |
17 | features:
18 | - icon: 🤖
19 | title: AI-First Development
20 | details: Code with AI from day one - LLM instructions, tool configurations, and project context pre-built for Claude Code, Cursor, and Gemini CLI
21 | - icon: 🚀
22 | title: Edge-First Architecture
23 | details: Built for Cloudflare Workers with optimized performance, global distribution, and instant deployment
24 | - icon: ⚛️
25 | title: Modern React Stack
26 | details: React 19 with Vite & Astro, TanStack Router, Jotai state management, and shadcn/ui components with Tailwind CSS v4
27 | - icon: 🔐
28 | title: Complete Authentication
29 | details: Pre-configured Better Auth with social providers, session management, and secure user flows
30 | - icon: 🏢
31 | title: Multi-Tenant Database
32 | details: Neon PostgreSQL with Drizzle ORM, pre-built multi-tenant schema with UUIDv7, migrations, and type-safe queries
33 | - icon: ⚡
34 | title: Ship Faster
35 | details: Bun runtime for instant builds, hot reload, unified tooling, and comprehensive testing setup
36 | ---
37 |
--------------------------------------------------------------------------------
/docs/.vitepress/config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitepress";
2 | import { withMermaid } from "vitepress-plugin-mermaid";
3 |
4 | /**
5 | * VitePress configuration.
6 | *
7 | * @link {https://vitepress.dev/reference/site-config}
8 | * @link {https://emersonbottero.github.io/vitepress-plugin-mermaid/}
9 | */
10 | export default withMermaid(
11 | defineConfig({
12 | title: "React Starter Kit",
13 | description: "Production-ready monorepo for building fast web apps",
14 | themeConfig: {
15 | // https://vitepress.dev/reference/default-theme-config
16 | nav: [
17 | { text: "Home", link: "/" },
18 | { text: "Docs", link: "/getting-started" },
19 | ],
20 |
21 | sidebar: [
22 | {
23 | text: "Documentation",
24 | items: [
25 | { text: "Getting Started", link: "/getting-started" },
26 | { text: "Database Schema", link: "/database-schema" },
27 | { text: "Deployment", link: "/deployment" },
28 | ],
29 | },
30 | {
31 | text: "Security",
32 | items: [
33 | { text: "Security Checklist", link: "/security/checklist" },
34 | { text: "Incident Playbook", link: "/security/incident-playbook" },
35 | {
36 | text: "Security Policy Template",
37 | link: "/security/SECURITY.template",
38 | },
39 | ],
40 | },
41 | ],
42 |
43 | socialLinks: [
44 | {
45 | icon: "discord",
46 | link: "https://discord.gg/2nKEnKq",
47 | },
48 | {
49 | icon: "github",
50 | link: "https://github.com/kriasoft/react-starter-kit",
51 | },
52 | ],
53 | },
54 | }),
55 | );
56 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/ui",
3 | "version": "0.1.0",
4 | "description": "Reusable UI components for React Starter Kit (monorepo)",
5 | "type": "module",
6 | "main": "./index.ts",
7 | "types": "./index.ts",
8 | "exports": {
9 | ".": "./index.ts",
10 | "./lib/utils": "./lib/utils.ts",
11 | "./components/*": "./components/*.tsx",
12 | "./hooks/*": "./hooks/*.ts",
13 | "./lib/*": "./lib/*.ts"
14 | },
15 | "files": [
16 | "components",
17 | "hooks",
18 | "lib",
19 | "index.ts",
20 | "components.json"
21 | ],
22 | "scripts": {
23 | "build": "tsc",
24 | "dev": "tsc --watch",
25 | "lint": "eslint . --max-warnings 0",
26 | "type-check": "tsc --noEmit",
27 | "add": "bun scripts/add.ts",
28 | "list": "bun scripts/list.ts",
29 | "update": "bun scripts/update.ts",
30 | "essentials": "bun scripts/essentials.ts"
31 | },
32 | "peerDependencies": {
33 | "react": ">=19.2.1",
34 | "react-dom": ">=19.2.1"
35 | },
36 | "dependencies": {
37 | "@radix-ui/react-avatar": "^1.1.11",
38 | "@radix-ui/react-checkbox": "^1.3.3",
39 | "@radix-ui/react-dialog": "^1.1.15",
40 | "@radix-ui/react-label": "^2.1.8",
41 | "@radix-ui/react-radio-group": "^1.3.8",
42 | "@radix-ui/react-scroll-area": "^1.2.10",
43 | "@radix-ui/react-select": "^2.2.6",
44 | "@radix-ui/react-separator": "^1.1.8",
45 | "@radix-ui/react-slot": "^1.2.4",
46 | "@radix-ui/react-switch": "^1.2.6",
47 | "class-variance-authority": "^0.7.1",
48 | "clsx": "^2.1.1",
49 | "lucide-react": "^0.556.0",
50 | "tailwind-merge": "^3.4.0"
51 | },
52 | "devDependencies": {
53 | "@types/react": "^19.2.7",
54 | "@types/react-dom": "^19.2.3",
55 | "tailwindcss": "^4.1.17",
56 | "typescript": "~5.9.3"
57 | },
58 | "license": "MIT"
59 | }
60 |
--------------------------------------------------------------------------------
/.claude/commands/validate-auth-schema.md:
--------------------------------------------------------------------------------
1 | # Validate Auth Schema
2 |
3 | Validate that the Drizzle ORM schema in `db/schema/` matches the Better Auth requirements.
4 |
5 | ## Steps
6 |
7 | 1. **Generate Better Auth schema reference**:
8 |
9 | ```bash
10 | bun run db/scripts/generate-auth-schema.ts
11 | ```
12 |
13 | 2. **Compare with current Drizzle schema**:
14 | - Review all files in `db/schema/` (user.ts, organization.ts, etc.)
15 | - Check that each Better Auth table has corresponding Drizzle table
16 | - Verify field types, constraints, and relationships match
17 | - Ensure table names and field names align with Better Auth expectations
18 |
19 | 3. **Key validation points**:
20 | - **Table mapping**: Better Auth `account` → Drizzle `identity`
21 | - **Required fields**: All Better Auth required fields are present and correctly typed
22 | - **Relationships**: Foreign key references match (userId, organizationId, etc.)
23 | - **Constraints**: Unique fields, required fields, default values
24 | - **Field types**: string/text, boolean, date/timestamp, number types
25 |
26 | 4. **Report findings**:
27 | - List any missing tables or fields
28 | - Identify type mismatches
29 | - Note incorrect constraints or relationships
30 | - Suggest specific fixes needed
31 |
32 | ## Context
33 |
34 | 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.
35 |
36 | ## Success Criteria
37 |
38 | - All Better Auth required tables exist in Drizzle schema
39 | - Field types and constraints match Better Auth requirements
40 | - Foreign key relationships are correctly implemented
41 | - Custom schema additions (like organizations) don't conflict with Better Auth expectations
42 |
--------------------------------------------------------------------------------
/packages/ui/components/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ));
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ));
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
45 |
46 | export { ScrollArea, ScrollBar };
47 |
--------------------------------------------------------------------------------
/db/schema/passkey.ts:
--------------------------------------------------------------------------------
1 | // WebAuthn passkey credentials for Better Auth
2 | // @see https://www.better-auth.com/docs/plugins/passkey
3 |
4 | import { sql } from "drizzle-orm";
5 | import {
6 | boolean,
7 | index,
8 | integer,
9 | pgTable,
10 | text,
11 | timestamp,
12 | } from "drizzle-orm/pg-core";
13 | import { user } from "./user";
14 |
15 | /**
16 | * Passkey credential store.
17 | *
18 | * Extended fields beyond Better Auth defaults:
19 | * - lastUsedAt: Tracks last authentication for security audits
20 | * - deviceName: User-friendly name (e.g., "MacBook Pro", "iPhone 15")
21 | * - platform: Authenticator platform ("platform" | "cross-platform")
22 | */
23 | export const passkey = pgTable(
24 | "passkey",
25 | {
26 | id: text()
27 | .primaryKey()
28 | .default(sql`gen_random_uuid()`),
29 | name: text(),
30 | publicKey: text().notNull(),
31 | userId: text()
32 | .notNull()
33 | .references(() => user.id, { onDelete: "cascade" }),
34 | credentialID: text().notNull().unique(),
35 | counter: integer().default(0).notNull(),
36 | deviceType: text().notNull(),
37 | backedUp: boolean().notNull(),
38 | transports: text(),
39 | aaguid: text(),
40 | // Extended operational fields
41 | lastUsedAt: timestamp({ withTimezone: true, mode: "date" }),
42 | deviceName: text(),
43 | platform: text(), // "platform" | "cross-platform"
44 | createdAt: timestamp({ withTimezone: true, mode: "date" })
45 | .defaultNow()
46 | .notNull(),
47 | updatedAt: timestamp({ withTimezone: true, mode: "date" })
48 | .defaultNow()
49 | .$onUpdate(() => new Date())
50 | .notNull(),
51 | },
52 | (table) => [index("passkey_user_id_idx").on(table.userId)],
53 | );
54 |
55 | export type Passkey = typeof passkey.$inferSelect;
56 | export type NewPasskey = typeof passkey.$inferInsert;
57 |
--------------------------------------------------------------------------------
/apps/web/wrangler.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../../node_modules/wrangler/config-schema.json",
3 |
4 | // [METADATA]
5 | // Application identity and Cloudflare runtime compatibility settings.
6 | "name": "example-web",
7 | "compatibility_date": "2025-08-15",
8 | "compatibility_flags": [],
9 | "workers_dev": false,
10 |
11 | // [ASSETS]
12 | // Serves bundled JavaScript, CSS, images, and other static assets.
13 | "assets": {
14 | "directory": "./dist"
15 | },
16 |
17 | // [ENVIRONMENTS]
18 | // Environment-specific configurations for different deployment stages.
19 | // Top-level config = production, nested env objects = other environments.
20 |
21 | // [ENV:PRODUCTION]
22 | // Default configuration used when no --env flag is specified.
23 | // Command: bun wrangler deploy
24 |
25 | // WARNING: Route modifications must be synchronized across all environments
26 | // prettier-ignore
27 | "routes": [
28 | { "pattern": "example.com/*", "zone_name": "example.com" }
29 | ],
30 | "vars": {
31 | "ENVIRONMENT": "production"
32 | },
33 |
34 | "env": {
35 | // [ENV:DEVELOPMENT]
36 | // Command: bun wrangler dev
37 | "dev": {
38 | "vars": {
39 | "ENVIRONMENT": "development"
40 | }
41 | },
42 |
43 | // [ENV:STAGING]
44 | // Pre-production testing environment that mirrors production setup.
45 | // Command: bun wrangler deploy --env staging
46 | "staging": {
47 | // prettier-ignore
48 | "routes": [
49 | { "pattern": "staging.example.com/*", "zone_name": "example.com" }
50 | ],
51 | "vars": {
52 | "ENVIRONMENT": "staging"
53 | }
54 | },
55 |
56 | // [ENV:PREVIEW]
57 | // Command: bun wrangler deploy --env preview
58 | "preview": {
59 | // prettier-ignore
60 | "routes": [
61 | { "pattern": "preview.example.com/*", "zone_name": "example.com" }
62 | ],
63 | "vars": {
64 | "ENVIRONMENT": "preview"
65 | }
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/apps/app/lib/query.ts:
--------------------------------------------------------------------------------
1 | import type { AppRouter } from "@repo/api";
2 | import { QueryClient } from "@tanstack/react-query";
3 | import { TRPCClientError } from "@trpc/client";
4 |
5 | export const queryClient = new QueryClient({
6 | defaultOptions: {
7 | queries: {
8 | // Data remains fresh for 2 minutes - prevents redundant API calls during
9 | // typical user sessions while ensuring data updates within reasonable time
10 | staleTime: 2 * 60 * 1000,
11 | // Garbage collection after 5 minutes - balances memory usage with instant
12 | // data availability when navigating back to recently viewed pages
13 | gcTime: 5 * 60 * 1000,
14 | // Retry strategy: 3 attempts with exponential backoff (1s, 2s, 4s) capped at 30s
15 | // Handles transient network issues without overwhelming the server
16 | retry: 3,
17 | retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
18 | // Auto-refetch when user returns to tab - ensures displayed data is current
19 | // after context switches (critical for collaborative features)
20 | refetchOnWindowFocus: true,
21 | // Always refetch after network reconnection - prevents stale data after
22 | // connectivity issues (overrides staleTime check)
23 | refetchOnReconnect: "always",
24 | },
25 | mutations: {
26 | // Single retry for mutations - prevents duplicate operations while handling
27 | // momentary network blips (user can manually retry for persistent failures)
28 | retry: 1,
29 | retryDelay: 1000,
30 | onError: (error) => {
31 | // Redirect to login on auth errors
32 | if (error instanceof TRPCClientError) {
33 | const trpcError = error as TRPCClientError;
34 | if (trpcError.data?.code === "UNAUTHORIZED") {
35 | window.location.href = "/login";
36 | return;
37 | }
38 | }
39 | console.error("Mutation error:", error);
40 | },
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/db",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "exports": {
7 | ".": "./index.ts",
8 | "./schema": "./schema/index.ts",
9 | "./schema/*": "./schema/*"
10 | },
11 | "scripts": {
12 | "generate": "bun --bun drizzle-kit generate",
13 | "generate:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit generate",
14 | "generate:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit generate",
15 | "migrate": "bun --bun drizzle-kit migrate",
16 | "migrate:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit migrate",
17 | "migrate:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit migrate",
18 | "push": "bun --bun drizzle-kit push",
19 | "push:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit push",
20 | "push:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit push",
21 | "studio": "bun --bun drizzle-kit studio",
22 | "studio:staging": "bun --bun --env ENVIRONMENT=staging drizzle-kit studio",
23 | "studio:prod": "bun --bun --env ENVIRONMENT=prod drizzle-kit studio",
24 | "seed": "bun scripts/seed.ts",
25 | "seed:staging": "bun --env ENVIRONMENT=staging scripts/seed.ts",
26 | "seed:prod": "bun --env ENVIRONMENT=prod scripts/seed.ts",
27 | "export": "bun scripts/export.ts",
28 | "export:staging": "bun --env ENVIRONMENT=staging scripts/export.ts",
29 | "export:prod": "bun --env ENVIRONMENT=prod scripts/export.ts",
30 | "introspect": "bun --bun drizzle-kit introspect",
31 | "up": "bun --bun drizzle-kit up",
32 | "check": "bun --bun drizzle-kit check",
33 | "drop": "bun --bun drizzle-kit drop",
34 | "typecheck": "tsc --noEmit"
35 | },
36 | "peerDependencies": {
37 | "drizzle-orm": "^0.45.0"
38 | },
39 | "devDependencies": {
40 | "@repo/typescript-config": "workspace:*",
41 | "@types/bun": "^1.3.3",
42 | "dotenv": "^17.2.3",
43 | "drizzle-kit": "^0.31.8",
44 | "drizzle-orm": "^0.45.0",
45 | "typescript": "~5.9.3"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/apps/app/routes/(auth)/login.tsx:
--------------------------------------------------------------------------------
1 | import { LoginForm } from "@/components/auth/login-form";
2 | import { getSafeRedirectUrl } from "@/lib/auth-config";
3 | import { invalidateSession, sessionQueryOptions } from "@/lib/queries/session";
4 | import { useQueryClient } from "@tanstack/react-query";
5 | import {
6 | createFileRoute,
7 | isRedirect,
8 | redirect,
9 | useRouter,
10 | } from "@tanstack/react-router";
11 | import { z } from "zod";
12 |
13 | // Sanitize returnTo at parse time - consumers get a safe value or undefined
14 | const searchSchema = z.object({
15 | returnTo: z
16 | .string()
17 | .optional()
18 | .transform((val) => {
19 | const safe = getSafeRedirectUrl(val);
20 | return safe === "/" ? undefined : safe;
21 | })
22 | .catch(undefined),
23 | });
24 |
25 | export const Route = createFileRoute("/(auth)/login")({
26 | validateSearch: searchSchema,
27 | beforeLoad: async ({ context, search }) => {
28 | try {
29 | const session = await context.queryClient.fetchQuery(
30 | sessionQueryOptions(),
31 | );
32 |
33 | // Redirect authenticated users to their destination
34 | if (session?.user && session?.session) {
35 | throw redirect({ to: search.returnTo ?? "/" });
36 | }
37 | } catch (error) {
38 | // Re-throw redirects, show login form for fetch errors
39 | if (isRedirect(error)) throw error;
40 | }
41 | },
42 | component: LoginPage,
43 | });
44 |
45 | function LoginPage() {
46 | const router = useRouter();
47 | const queryClient = useQueryClient();
48 | const search = Route.useSearch();
49 |
50 | async function handleSuccess() {
51 | await invalidateSession(queryClient);
52 | await router.invalidate();
53 | await router.navigate({ to: search.returnTo ?? "/" });
54 | }
55 |
56 | return (
57 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # Environment variables
2 | # https://vitejs.dev/guide/env-and-mode.html#env-files
3 | #
4 | # TIP: Feel free to personalize these settings in your `.env.local` file that
5 | # is not tracked by source control, giving you the liberty to tweak
6 | # settings in your local environment worry-free! Happy coding! 🚀
7 |
8 | # Web application settings
9 | APP_NAME=Acme Co.
10 | APP_ORIGIN=http://localhost:5173
11 | API_ORIGIN=http://localhost:8787
12 | ENVIRONMENT=development
13 | PORT=8787
14 |
15 | # Google Cloud
16 | # https://console.cloud.google.com/
17 | GOOGLE_CLOUD_PROJECT=kriasoft
18 | GOOGLE_CLOUD_REGION=us-central1
19 |
20 | # Database
21 | # https://console.neon.tech/
22 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/example
23 |
24 | # Cloudflare Hyperdrive for local development
25 | # https://developers.cloudflare.com/hyperdrive/
26 | CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_CACHED=postgres://postgres:postgres@localhost:5432/example
27 | CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_DIRECT=postgres://postgres:postgres@localhost:5432/example
28 |
29 | # Better Auth
30 | # bunx @better-auth/cli@latest secret
31 | BETTER_AUTH_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
32 |
33 | # Google OAuth 2.0
34 | # https://console.cloud.google.com/apis/credentials
35 | GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
36 | GOOGLE_CLIENT_SECRET=xxxxx
37 |
38 | # OpenAI
39 | # https://platform.openai.com/
40 | OPENAI_ORGANIZATION=xxxxx
41 | OPENAI_API_KEY=xxxxx
42 |
43 | # Cloudflare
44 | # https://dash.cloudflare.com/
45 | # https://developers.cloudflare.com/api/tokens/create
46 | CLOUDFLARE_ACCOUNT_ID=
47 | CLOUDFLARE_ZONE_ID=
48 | CLOUDFLARE_API_TOKEN=
49 |
50 | # Google Analytics (v4)
51 | # https://console.firebase.google.com/
52 | # https://firebase.google.com/docs/analytics/get-started?platform=web
53 | GA_MEASUREMENT_ID=G-XXXXXXXX
54 |
55 | # Resend
56 | # https://resend.com/api-keys
57 | RESEND_API_KEY=xxxxx
58 | RESEND_EMAIL_FROM=onboarding@resend.dev
59 |
60 | # Algolia Search
61 | # https://dashboard.algolia.com/account/api-keys/all
62 | ALGOLIA_APP_ID=xxxxx
63 | ALGOLIA_ADMIN_API_KEY=xxxxx
64 |
--------------------------------------------------------------------------------
/apps/api/routers/organization.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { protectedProcedure, router } from "../lib/trpc.js";
3 |
4 | export const organizationRouter = router({
5 | list: protectedProcedure.query(() => {
6 | // TODO: Implement organization listing logic
7 | return {
8 | organizations: [],
9 | };
10 | }),
11 |
12 | create: protectedProcedure
13 | .input(
14 | z.object({
15 | name: z.string().min(1),
16 | description: z.string().optional(),
17 | }),
18 | )
19 | .mutation(({ input, ctx }) => {
20 | // TODO: Implement organization creation logic
21 | return {
22 | id: "org_" + Date.now(),
23 | name: input.name,
24 | description: input.description,
25 | ownerId: ctx.user.id,
26 | };
27 | }),
28 |
29 | update: protectedProcedure
30 | .input(
31 | z.object({
32 | id: z.string(),
33 | name: z.string().min(1).optional(),
34 | description: z.string().optional(),
35 | }),
36 | )
37 | .mutation(({ input }) => {
38 | // TODO: Implement organization update logic
39 | return {
40 | ...input,
41 | };
42 | }),
43 |
44 | delete: protectedProcedure
45 | .input(z.object({ id: z.string() }))
46 | .mutation(({ input }) => {
47 | // TODO: Implement organization deletion logic
48 | return { success: true, id: input.id };
49 | }),
50 |
51 | members: protectedProcedure
52 | .input(z.object({ organizationId: z.string() }))
53 | .query(() => {
54 | // TODO: Implement organization members listing
55 | return {
56 | members: [],
57 | };
58 | }),
59 |
60 | invite: protectedProcedure
61 | .input(
62 | z.object({
63 | organizationId: z.string(),
64 | email: z.email({ message: "Invalid email address" }),
65 | role: z.enum(["admin", "member"]).default("member"),
66 | }),
67 | )
68 | .mutation(() => {
69 | // TODO: Implement organization invite logic
70 | return {
71 | success: true,
72 | inviteId: "invite_" + Date.now(),
73 | };
74 | }),
75 | });
76 |
--------------------------------------------------------------------------------
/packages/ui/components/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "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",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | },
35 | );
36 |
37 | export interface ButtonProps
38 | extends
39 | React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean;
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button";
47 | return (
48 |
53 | );
54 | },
55 | );
56 | Button.displayName = "Button";
57 |
58 | export { Button, buttonVariants };
59 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/components/GitHubStats.vue:
--------------------------------------------------------------------------------
1 |
2 |
40 |
41 |
42 |
73 |
--------------------------------------------------------------------------------
/apps/api/Dockerfile:
--------------------------------------------------------------------------------
1 | # Dockerfile for the tRPC API Server
2 | # docker build --tag api:latest -f ./apps/api/Dockerfile .
3 | # https://bun.com/guides/ecosystem/docker
4 | # https://docs.docker.com/guides/bun/containerize/
5 |
6 | FROM oven/bun:slim
7 |
8 | # Install dumb-init and jq for proper signal handling and JSON processing
9 | RUN apt-get update && apt-get install -y --no-install-recommends dumb-init jq && \
10 | rm -rf /var/lib/apt/lists/*
11 |
12 | # Set environment variables
13 | ENV NODE_ENV=production
14 |
15 | # Set working directory
16 | WORKDIR /usr/src/app
17 |
18 | # Copy package files for better layer caching
19 | COPY ./apps/api/package.json ./package.json
20 |
21 | # Remove workspace dependencies from package.json
22 | # Workspace dependencies like "workspace:*" or "workspace:^1.0.0" cannot be resolved
23 | # inside Docker since the workspace packages aren't available in the container context.
24 | # This jq command filters out any dependency where the version starts with "workspace:"
25 | # from both dependencies and devDependencies sections
26 | RUN jq '.dependencies |= with_entries(select(.value | startswith("workspace:") | not)) | \
27 | .devDependencies |= with_entries(select(.value | startswith("workspace:") | not))' \
28 | package.json > package.tmp.json && \
29 | mv package.tmp.json package.json
30 |
31 | # Install production dependencies only
32 | # Using --production flag to exclude devDependencies
33 | RUN bun install --production
34 |
35 | # Copy pre-built server files from dist directory
36 | # The build process should be done outside of Docker
37 | # Note: Run `bun --filter @repo/api build` before building the Docker image
38 | # This bundles all dependencies including workspace packages into dist/index.js
39 | COPY --chown=bun:bun ./apps/api/dist ./dist
40 |
41 | # Verify dist directory exists and has content
42 | RUN test -f ./dist/index.js || (echo "Error: dist/index.js not found" && exit 1)
43 |
44 | # Switch to non-root user
45 | USER bun
46 |
47 | # Expose the port your server runs on (default: 8080)
48 | EXPOSE 8080
49 |
50 | # Run the server using dumb-init for proper signal handling
51 | ENTRYPOINT ["/usr/bin/dumb-init", "--"]
52 | CMD ["bun", "dist/index.js"]
53 |
--------------------------------------------------------------------------------
/apps/api/lib/loaders.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Data loaders. Usage example:
3 | *
4 | * ```ts
5 | * protectedProcedure
6 | * .query(async ({ ctx }) => {
7 | * const user = await userById(ctx).load(ctx.session.userId);
8 | * ...
9 | * })
10 | * ```
11 | */
12 |
13 | import DataLoader from "dataloader";
14 | import { inArray } from "drizzle-orm";
15 | import { user } from "@repo/db/schema/user.js";
16 | import type { TRPCContext } from "./context";
17 |
18 | // The list of data loader keys to be used with the context cache
19 | // to avoid creating multiple instances of the same data loader.
20 | export const USER_BY_ID = Symbol("userById");
21 | export const USER_BY_EMAIL = Symbol("userByEmail");
22 |
23 | function createKeyMap(
24 | items: T[],
25 | keyField: K,
26 | ): Map {
27 | return new Map(items.map((item) => [item[keyField], item]));
28 | }
29 |
30 | export function userById(ctx: TRPCContext) {
31 | if (!ctx.cache.has(USER_BY_ID)) {
32 | const loader = new DataLoader(async (userIds: readonly string[]) => {
33 | if (userIds.length === 0) return [];
34 |
35 | const users = await ctx.db
36 | .select()
37 | .from(user)
38 | .where(inArray(user.id, [...userIds]));
39 | const userMap = createKeyMap(users, "id");
40 | return userIds.map((id) => userMap.get(id) || null);
41 | });
42 | ctx.cache.set(USER_BY_ID, loader);
43 | }
44 | return ctx.cache.get(USER_BY_ID) as DataLoader<
45 | string,
46 | typeof user.$inferSelect | null
47 | >;
48 | }
49 |
50 | export function userByEmail(ctx: TRPCContext) {
51 | if (!ctx.cache.has(USER_BY_EMAIL)) {
52 | const loader = new DataLoader(async (emails: readonly string[]) => {
53 | if (emails.length === 0) return [];
54 |
55 | const users = await ctx.db
56 | .select()
57 | .from(user)
58 | .where(inArray(user.email, [...emails]));
59 | const userMap = createKeyMap(users, "email");
60 | return emails.map((email) => userMap.get(email) || null);
61 | });
62 | ctx.cache.set(USER_BY_EMAIL, loader);
63 | }
64 | return ctx.cache.get(USER_BY_EMAIL) as DataLoader<
65 | string,
66 | typeof user.$inferSelect | null
67 | >;
68 | }
69 |
--------------------------------------------------------------------------------
/packages/ui/components/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = "CardTitle";
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = "CardDescription";
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = "CardContent";
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = "CardFooter";
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/apps/app/components/user-menu.tsx:
--------------------------------------------------------------------------------
1 | import { signOut, useSessionQuery } from "@/lib/queries/session";
2 | import { Avatar, AvatarFallback, Button } from "@repo/ui";
3 | import { useQueryClient } from "@tanstack/react-query";
4 | import { LogOut, RefreshCw, User } from "lucide-react";
5 |
6 | /**
7 | * Example component showing how to use session query
8 | * with loading states and error handling
9 | */
10 | export function UserMenu() {
11 | const queryClient = useQueryClient();
12 | const { data: session, isPending, error, refetch } = useSessionQuery();
13 |
14 | if (isPending) {
15 | return (
16 |
22 | );
23 | }
24 |
25 | if (error) {
26 | return (
27 |
28 | Failed to load session
29 |
38 |
39 | );
40 | }
41 |
42 | const user = session?.user;
43 |
44 | if (!user) {
45 | return null;
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 |
53 | {user.name?.[0]?.toUpperCase() || }
54 |
55 |
56 |
57 |
{user.name || "User"}
58 |
{user.email}
59 |
60 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/apps/api/dev.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Local development server emulating Cloudflare Workers runtime.
3 | *
4 | * Requires wrangler.jsonc with HYPERDRIVE_CACHED and HYPERDRIVE_DIRECT bindings.
5 | */
6 |
7 | import { Hono } from "hono";
8 | import { parseArgs } from "node:util";
9 | import { getPlatformProxy } from "wrangler";
10 | import api from "./index.js";
11 | import { createAuth } from "./lib/auth.js";
12 | import type { AppContext } from "./lib/context.js";
13 | import { createDb } from "./lib/db.js";
14 | import type { Env } from "./lib/env.js";
15 |
16 | const { values: args } = parseArgs({
17 | args: Bun.argv.slice(2),
18 | options: {
19 | env: { type: "string" },
20 | },
21 | });
22 |
23 | type CloudflareEnv = {
24 | HYPERDRIVE_CACHED: Hyperdrive;
25 | HYPERDRIVE_DIRECT: Hyperdrive;
26 | } & Env;
27 |
28 | const app = new Hono();
29 |
30 | // persist:true maintains state across restarts in .wrangler directory
31 | const cf = await getPlatformProxy({
32 | configPath: "./wrangler.jsonc",
33 | environment: args.env ?? "dev",
34 | persist: true,
35 | });
36 |
37 | // Inject context with two database connections:
38 | // - db: Hyperdrive caching for read-heavy queries
39 | // - dbDirect: No cache for writes and transactions
40 | app.use("*", async (c, next) => {
41 | const db = createDb(cf.env.HYPERDRIVE_CACHED);
42 | const dbDirect = createDb(cf.env.HYPERDRIVE_DIRECT);
43 |
44 | // Merge secrets from process.env (local dev) with Cloudflare bindings
45 | const secretKeys = [
46 | "BETTER_AUTH_SECRET",
47 | "GOOGLE_CLIENT_ID",
48 | "GOOGLE_CLIENT_SECRET",
49 | "OPENAI_API_KEY",
50 | "RESEND_API_KEY",
51 | "RESEND_EMAIL_FROM",
52 | ] as const;
53 |
54 | const env = {
55 | ...cf.env,
56 | ...Object.fromEntries(
57 | secretKeys.map((key) => [key, (process.env[key] || cf.env[key]) ?? ""]),
58 | ),
59 | APP_NAME: process.env.APP_NAME || cf.env.APP_NAME || "Example",
60 | APP_ORIGIN:
61 | c.req.header("x-forwarded-origin") ||
62 | process.env.APP_ORIGIN ||
63 | c.env.APP_ORIGIN ||
64 | "http://localhost:5173",
65 | };
66 |
67 | c.set("db", db);
68 | c.set("dbDirect", dbDirect);
69 | c.set("auth", createAuth(db, env));
70 | await next();
71 | });
72 |
73 | app.route("/", api);
74 |
75 | export default app;
76 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Tailwind CSS v4 configuration for the web app.
3 | * @see https://tailwindcss.com/docs/v4-beta
4 | */
5 |
6 | @import "tailwindcss";
7 |
8 | /* Content paths for Tailwind to scan */
9 | @source "./pages/**/*.{astro,js,ts,jsx,tsx}";
10 | @source "./layouts/**/*.{astro,js,ts,jsx,tsx}";
11 | @source "../../packages/ui/components/**/*.{ts,tsx}";
12 | @source "../../packages/ui/lib/**/*.{ts,tsx}";
13 |
14 | /* Custom dark mode variant */
15 | @custom-variant dark (&:is(.dark *));
16 |
17 | /* Theme configuration */
18 | @theme inline {
19 | /* Border radius values */
20 | --radius-sm: calc(var(--radius) - 4px);
21 | --radius-md: calc(var(--radius) - 2px);
22 | --radius-lg: var(--radius);
23 | --radius-xl: calc(var(--radius) + 4px);
24 |
25 | /* Color mappings for Tailwind utilities */
26 | --color-background: var(--background);
27 | --color-foreground: var(--foreground);
28 | --color-card: var(--card);
29 | --color-card-foreground: var(--card-foreground);
30 | --color-popover: var(--popover);
31 | --color-popover-foreground: var(--popover-foreground);
32 | --color-primary: var(--primary);
33 | --color-primary-foreground: var(--primary-foreground);
34 | --color-secondary: var(--secondary);
35 | --color-secondary-foreground: var(--secondary-foreground);
36 | --color-muted: var(--muted);
37 | --color-muted-foreground: var(--muted-foreground);
38 | --color-accent: var(--accent);
39 | --color-accent-foreground: var(--accent-foreground);
40 | --color-destructive: var(--destructive);
41 | --color-destructive-foreground: var(--destructive-foreground);
42 | --color-border: var(--border);
43 | --color-input: var(--input);
44 | --color-ring: var(--ring);
45 | --color-chart-1: var(--chart-1);
46 | --color-chart-2: var(--chart-2);
47 | --color-chart-3: var(--chart-3);
48 | --color-chart-4: var(--chart-4);
49 | --color-chart-5: var(--chart-5);
50 | --color-sidebar: var(--sidebar);
51 | --color-sidebar-foreground: var(--sidebar-foreground);
52 | --color-sidebar-primary: var(--sidebar-primary);
53 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
54 | --color-sidebar-accent: var(--sidebar-accent);
55 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
56 | --color-sidebar-border: var(--sidebar-border);
57 | --color-sidebar-ring: var(--sidebar-ring);
58 | }
59 |
--------------------------------------------------------------------------------
/apps/app/components/auth/social-login.tsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@/lib/auth";
2 | import { authConfig } from "@/lib/auth-config";
3 | import { sessionQueryKey } from "@/lib/queries/session";
4 | import { queryClient } from "@/lib/query";
5 | import { useRouterState } from "@tanstack/react-router";
6 | import { Button } from "@repo/ui";
7 |
8 | interface SocialLoginProps {
9 | onError: (error: string) => void;
10 | isDisabled?: boolean;
11 | }
12 |
13 | export function SocialLogin({ onError, isDisabled }: SocialLoginProps) {
14 | // Get returnTo from router state (already sanitized by validateSearch)
15 | const returnTo = useRouterState({
16 | select: (s) => (s.location.search as { returnTo?: string }).returnTo,
17 | });
18 |
19 | const handleGoogleLogin = async () => {
20 | try {
21 | onError(""); // Clear any previous errors
22 |
23 | // Clear stale session before OAuth redirect
24 | queryClient.removeQueries({ queryKey: sessionQueryKey });
25 |
26 | // Use sanitized returnTo or root as default
27 | const destination = returnTo || "/";
28 |
29 | // Initiate Google OAuth flow
30 | await auth.signIn.social({
31 | provider: "google",
32 | callbackURL: `${authConfig.oauth.defaultCallbackUrl}?returnTo=${encodeURIComponent(destination)}`,
33 | });
34 |
35 | // Note: This code won't execute as OAuth redirects the page
36 | } catch (err) {
37 | console.error("Google login error:", err);
38 | onError("Failed to sign in with Google");
39 | }
40 | };
41 |
42 | return (
43 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/packages/ws-protocol/README.md:
--------------------------------------------------------------------------------
1 | # WebSocket Protocol
2 |
3 | Type-safe WebSocket protocol definitions using [WS-Kit](https://github.com/kriasoft/ws-kit).
4 |
5 | ## Quick Start
6 |
7 | ```bash
8 | # Run example server
9 | bun run example
10 | ```
11 |
12 | ## Usage
13 |
14 | ### Define Messages
15 |
16 | ```typescript
17 | import { z, message, rpc } from "@ws-kit/zod";
18 |
19 | // Message with optional payload
20 | const Ping = message("PING", { timestamp: z.number().optional() });
21 |
22 | // Message with required payload
23 | const Greeting = message("GREETING", {
24 | name: z.string(),
25 | text: z.string(),
26 | });
27 |
28 | // RPC with request/response
29 | const GetUser = rpc("GET_USER", { id: z.string() }, "USER", {
30 | id: z.string(),
31 | name: z.string(),
32 | });
33 | ```
34 |
35 | ### Create Router
36 |
37 | ```typescript
38 | import { createRouter, withZod } from "@ws-kit/zod";
39 |
40 | const router = createRouter<{ userId?: string }>()
41 | .plugin(withZod())
42 | .on(Ping, (ctx) => {
43 | ctx.send(Pong, { timestamp: Date.now() });
44 | })
45 | .on(Greeting, (ctx) => {
46 | console.log(`${ctx.payload.name}: ${ctx.payload.text}`);
47 | })
48 | .rpc(GetUser, async (ctx) => {
49 | ctx.reply({ id: ctx.payload.id, name: "Alice" });
50 | });
51 | ```
52 |
53 | ### Start Server
54 |
55 | ```typescript
56 | import { createBunHandler } from "@ws-kit/bun";
57 |
58 | const { fetch, websocket } = createBunHandler(router);
59 |
60 | Bun.serve({
61 | port: 3000,
62 | fetch(req, server) {
63 | if (new URL(req.url).pathname === "/ws") {
64 | return fetch(req, server);
65 | }
66 | return new Response("WebSocket server");
67 | },
68 | websocket,
69 | });
70 | ```
71 |
72 | ## Built-in Messages
73 |
74 | | Message | Description |
75 | | --------------- | ------------------------------- |
76 | | `PING` / `PONG` | Connection heartbeat |
77 | | `ECHO` | Echo server example |
78 | | `NOTIFICATION` | Server-to-client broadcast |
79 | | `ERROR` | Protocol-level error reporting |
80 | | `GET_USER` | Example RPC with typed response |
81 |
82 | ## Project Structure
83 |
84 | ```
85 | ws-protocol/
86 | ├── messages.ts # Message schema definitions
87 | ├── router.ts # Router factory with handlers
88 | ├── example.ts # Example server
89 | └── index.ts # Public exports
90 | ```
91 |
--------------------------------------------------------------------------------
/apps/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/app",
3 | "version": "0.0.0",
4 | "private": true,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite serve",
8 | "build": "vite build",
9 | "preview": "vite preview",
10 | "test": "vitest",
11 | "coverage": "vitest --coverage",
12 | "typecheck": "tsc --noEmit",
13 | "deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local",
14 | "logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
15 | },
16 | "dependencies": {
17 | "@radix-ui/react-checkbox": "^1.3.3",
18 | "@radix-ui/react-radio-group": "^1.3.8",
19 | "@radix-ui/react-scroll-area": "^1.2.10",
20 | "@radix-ui/react-select": "^2.2.6",
21 | "@radix-ui/react-separator": "^1.1.8",
22 | "@radix-ui/react-slot": "^1.2.4",
23 | "@radix-ui/react-switch": "^1.2.6",
24 | "@repo/ui": "workspace:*",
25 | "@tanstack/react-query": "^5.90.12",
26 | "@tanstack/react-router": "^1.139.14",
27 | "@better-auth/passkey": "^1.4.5",
28 | "@trpc/client": "^11.7.2",
29 | "@trpc/tanstack-react-query": "^11.7.2",
30 | "better-auth": "^1.4.5",
31 | "class-variance-authority": "^0.7.1",
32 | "clsx": "^2.1.1",
33 | "jotai": "^2.15.2",
34 | "jotai-effect": "^2.1.5",
35 | "localforage": "^1.10.0",
36 | "lucide-react": "^0.556.0",
37 | "react": "^19.2.1",
38 | "react-dom": "^19.2.1",
39 | "react-error-boundary": "^6.0.0",
40 | "tailwind-merge": "^3.4.0"
41 | },
42 | "devDependencies": {
43 | "@repo/typescript-config": "workspace:*",
44 | "@tailwindcss/postcss": "^4.1.17",
45 | "@tanstack/react-query-devtools": "^5.91.1",
46 | "@tanstack/react-router-devtools": "^1.139.14",
47 | "@tanstack/router-plugin": "^1.139.14",
48 | "@types/bun": "^1.3.3",
49 | "@types/node": "^24.10.1",
50 | "@types/react": "^19.2.7",
51 | "@types/react-dom": "^19.2.3",
52 | "@vitejs/plugin-react": "^5.1.1",
53 | "@vitejs/plugin-react-swc": "^4.2.2",
54 | "autoprefixer": "^10.4.22",
55 | "envars": "^1.1.1",
56 | "execa": "^9.6.1",
57 | "globby": "^16.0.0",
58 | "happy-dom": "^20.0.11",
59 | "postcss": "^8.5.6",
60 | "tailwindcss": "^4.1.17",
61 | "tw-animate-css": "^1.4.0",
62 | "typescript": "~5.9.3",
63 | "vite": "~7.2.6",
64 | "vite-tsconfig-paths": "^5.1.4",
65 | "vitest": "~4.0.15",
66 | "wrangler": "^4.53.0"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/apps/app/CLAUDE.md:
--------------------------------------------------------------------------------
1 | ## Architecture
2 |
3 | This is a Single Page Application (SPA) and does not require Server-Side Rendering (SSR). All rendering is done on the client-side.
4 |
5 | ## Tech Stack
6 |
7 | React 19, TypeScript, Vite, TanStack Router, shadcn/ui, Tailwind CSS v4, Jotai, Better Auth.
8 |
9 | ## Commands
10 |
11 | - `bun --filter web dev` - Dev server
12 | - `bun --filter web test` - Run tests
13 | - `bun --filter web lint` - Lint code
14 |
15 | ## Structure
16 |
17 | - `routes/` - Page components with file-based routing
18 | - `components/` - Reusable UI components
19 | - `lib/` - Utilities and shared logic
20 | - `styles/` - Global CSS and Tailwind config
21 |
22 | ## Routing Conventions
23 |
24 | ### Route Groups
25 |
26 | - `(app)/` - Protected routes requiring authentication
27 | - `(auth)/` - Public authentication routes
28 | - Parentheses create logical groups without affecting URLs
29 |
30 | ### Components
31 |
32 | - Layout components use `` for nested routes
33 | - Access route context directly via `Route.useRouteContext()`, not props
34 | - Import route definitions for type-safe context access: `import { Route } from "@/routes/(app)/route"`
35 |
36 | ### Navigation
37 |
38 | - Use `` from TanStack Router for internal routes
39 | - Use `` for external links or undefined routes
40 | - Active styling via `activeProps` on ``
41 |
42 | ### Authentication
43 |
44 | #### Core Decisions
45 |
46 | - **Provider:** Better Auth (cookie-based sessions, OAuth, passkeys)
47 | - **State:** TanStack Query wraps all auth calls via `useSessionQuery()` / `useSuspenseSessionQuery()`
48 | - **Protection:** Routes validate auth in `beforeLoad` hooks, not components
49 | - **Storage:** Server sessions only, no localStorage/sessionStorage
50 |
51 | #### Implementation Rules
52 |
53 | - Never use Better Auth's `useSession()` directly - use TanStack Query wrappers
54 | - Route groups: `(app)/` = protected, `(auth)/` = public
55 | - Auth checks validate both `session?.user` AND `session?.session` exist
56 | - Session queries cached 30s, auto-refresh on focus/reconnect
57 | - Invalidate session cache after login/logout for fresh data
58 | - Auth errors (401/403) trigger redirects via error boundaries
59 |
60 | #### Files
61 |
62 | - `lib/auth.ts` - Better Auth client setup
63 | - `lib/queries/session.ts` - TanStack Query session hooks
64 | - `routes/(app)/route.tsx` - Protected route guard
65 | - `components/auth/` - Auth UI components
66 |
--------------------------------------------------------------------------------
/apps/app/tailwind.config.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Tailwind CSS v4 configuration for the main app.
3 | * @see https://tailwindcss.com/docs/v4-beta
4 | */
5 |
6 | @import "tailwindcss";
7 |
8 | /* Content paths for Tailwind to scan */
9 | @source "./lib/**/*.{js,ts,jsx,tsx}";
10 | @source "./routes/**/*.{js,ts,jsx,tsx}";
11 | @source "./components/**/*.{js,ts,jsx,tsx}";
12 | @source "./index.html";
13 | @source "./index.tsx";
14 | @source "../../packages/ui/components/**/*.{ts,tsx}";
15 | @source "../../packages/ui/lib/**/*.{ts,tsx}";
16 | @source "../../packages/ui/hooks/**/*.{ts,tsx}";
17 |
18 | /* Custom dark mode variant */
19 | @custom-variant dark (&:is(.dark *));
20 |
21 | /* Theme configuration */
22 | @theme inline {
23 | /* Border radius values */
24 | --radius-sm: calc(var(--radius) - 4px);
25 | --radius-md: calc(var(--radius) - 2px);
26 | --radius-lg: var(--radius);
27 | --radius-xl: calc(var(--radius) + 4px);
28 |
29 | /* Color mappings for Tailwind utilities */
30 | --color-background: var(--background);
31 | --color-foreground: var(--foreground);
32 | --color-card: var(--card);
33 | --color-card-foreground: var(--card-foreground);
34 | --color-popover: var(--popover);
35 | --color-popover-foreground: var(--popover-foreground);
36 | --color-primary: var(--primary);
37 | --color-primary-foreground: var(--primary-foreground);
38 | --color-secondary: var(--secondary);
39 | --color-secondary-foreground: var(--secondary-foreground);
40 | --color-muted: var(--muted);
41 | --color-muted-foreground: var(--muted-foreground);
42 | --color-accent: var(--accent);
43 | --color-accent-foreground: var(--accent-foreground);
44 | --color-destructive: var(--destructive);
45 | --color-destructive-foreground: var(--destructive-foreground);
46 | --color-border: var(--border);
47 | --color-input: var(--input);
48 | --color-ring: var(--ring);
49 | --color-chart-1: var(--chart-1);
50 | --color-chart-2: var(--chart-2);
51 | --color-chart-3: var(--chart-3);
52 | --color-chart-4: var(--chart-4);
53 | --color-chart-5: var(--chart-5);
54 | --color-sidebar: var(--sidebar);
55 | --color-sidebar-foreground: var(--sidebar-foreground);
56 | --color-sidebar-primary: var(--sidebar-primary);
57 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
58 | --color-sidebar-accent: var(--sidebar-accent);
59 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
60 | --color-sidebar-border: var(--sidebar-border);
61 | --color-sidebar-ring: var(--sidebar-ring);
62 | }
63 |
--------------------------------------------------------------------------------
/packages/ui/scripts/list.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bun
2 | import { existsSync } from "node:fs";
3 | import { readdir, stat } from "node:fs/promises";
4 | import { basename, join } from "node:path";
5 |
6 | interface ComponentInfo {
7 | name: string;
8 | size: number;
9 | modified: Date;
10 | }
11 |
12 | async function getComponentInfo(filePath: string): Promise {
13 | const stats = await stat(filePath);
14 | const name = basename(filePath, ".tsx");
15 |
16 | return {
17 | name,
18 | size: stats.size,
19 | modified: stats.mtime,
20 | };
21 | }
22 |
23 | async function listComponents(): Promise {
24 | console.log("📋 shadcn/ui Component Inventory");
25 | console.log("=============================\n");
26 |
27 | const componentsDir = join(import.meta.dirname, "../components");
28 |
29 | if (!existsSync(componentsDir)) {
30 | console.log(`❌ Components directory not found: ${componentsDir}`);
31 | console.log("💡 Run 'bunx shadcn@latest init' to set up shadcn/ui first");
32 | process.exit(1);
33 | }
34 |
35 | try {
36 | const files = await readdir(componentsDir);
37 | const tsxFiles = files.filter((file) => file.endsWith(".tsx"));
38 |
39 | if (tsxFiles.length === 0) {
40 | console.log("❌ No shadcn/ui components found");
41 | console.log("💡 Add components with: bun run ui:add ");
42 | return;
43 | }
44 |
45 | console.log("📦 Installed Components:\n");
46 |
47 | const components: ComponentInfo[] = [];
48 |
49 | for (const file of tsxFiles) {
50 | const filePath = join(componentsDir, file);
51 | const info = await getComponentInfo(filePath);
52 | components.push(info);
53 | }
54 |
55 | // Sort by name
56 | components.sort((a, b) => a.name.localeCompare(b.name));
57 |
58 | // Display components
59 | for (const component of components) {
60 | const formattedSize = component.size.toLocaleString();
61 | const formattedDate = component.modified
62 | .toISOString()
63 | .slice(0, 16)
64 | .replace("T", " ");
65 | console.log(
66 | `• ${component.name.padEnd(20)} ${formattedSize.padStart(8)} bytes ${formattedDate}`,
67 | );
68 | }
69 |
70 | console.log("\n📊 Summary:");
71 | console.log(`Total components: ${components.length}`);
72 |
73 | console.log("\n🔄 To update all components, run:");
74 | console.log(" bun run ui:update");
75 | } catch (error) {
76 | console.error("Error reading components:", error);
77 | process.exit(1);
78 | }
79 | }
80 |
81 | listComponents().catch(console.error);
82 |
--------------------------------------------------------------------------------
/apps/email/templates/email-verification.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Heading, Section, Text } from "@react-email/components";
2 | import { BaseTemplate, colors } from "../components/BaseTemplate";
3 |
4 | interface EmailVerificationProps {
5 | userName?: string;
6 | verificationUrl: string;
7 | appName?: string;
8 | appUrl?: string;
9 | }
10 |
11 | export function EmailVerification({
12 | userName,
13 | verificationUrl,
14 | appName,
15 | appUrl,
16 | }: EmailVerificationProps) {
17 | const preview = `Verify your email address for ${appName || "your account"}`;
18 |
19 | return (
20 |
21 | Verify your email address
22 |
23 | Hi{userName ? ` ${userName}` : ""},
24 |
25 |
26 | Thanks for signing up! Please click the button below to verify your
27 | email address and complete your account setup.
28 |
29 |
30 |
31 |
34 |
35 |
36 |
37 | Or copy and paste this URL into your browser:
38 |
39 |
40 | {verificationUrl}
41 |
42 |
43 | This verification link will expire in 24 hours for security reasons.
44 |
45 |
46 |
47 | If you didn't create an account with us, you can safely ignore this
48 | email.
49 |
50 |
51 | );
52 | }
53 |
54 | const heading = {
55 | fontSize: "24px",
56 | fontWeight: "600",
57 | color: colors.text,
58 | margin: "0 0 24px",
59 | };
60 |
61 | const paragraph = {
62 | fontSize: "16px",
63 | lineHeight: "24px",
64 | color: colors.textMuted,
65 | margin: "0 0 16px",
66 | };
67 |
68 | const buttonContainer = {
69 | textAlign: "center" as const,
70 | margin: "32px 0",
71 | };
72 |
73 | const button = {
74 | backgroundColor: colors.primary,
75 | borderRadius: "6px",
76 | color: colors.white,
77 | fontSize: "16px",
78 | fontWeight: "600",
79 | textDecoration: "none",
80 | textAlign: "center" as const,
81 | display: "inline-block",
82 | padding: "12px 24px",
83 | lineHeight: "20px",
84 | };
85 |
86 | const linkText = {
87 | fontSize: "14px",
88 | color: colors.textLight,
89 | wordBreak: "break-all" as const,
90 | margin: "0 0 16px",
91 | padding: "12px",
92 | backgroundColor: "#f8f9fa",
93 | borderRadius: "4px",
94 | border: "1px solid #e9ecef",
95 | };
96 |
--------------------------------------------------------------------------------
/eslint.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@eslint-react/eslint-plugin";
2 | import js from "@eslint/js";
3 | import * as tsParser from "@typescript-eslint/parser";
4 | import prettierConfig from "eslint-config-prettier";
5 | import { defineConfig } from "eslint/config";
6 | import globals from "globals";
7 | import ts from "typescript-eslint";
8 |
9 | /**
10 | * ESLint configuration.
11 | * @see https://eslint.org/docs/latest/use/configure/
12 | */
13 | export default defineConfig(
14 | // Global ignores
15 | {
16 | ignores: [
17 | ".cache",
18 | ".venv",
19 | "**/.astro",
20 | "**/.react-email",
21 | "**/dist",
22 | "**/node_modules",
23 | "docs/.vitepress/cache",
24 | "docs/.vitepress/dist",
25 | ],
26 | },
27 |
28 | // Base configs for all files
29 | js.configs.recommended,
30 | ...ts.configs.recommended,
31 |
32 | // TypeScript parser for all .ts/.tsx files
33 | {
34 | files: ["**/*.{ts,tsx}"],
35 | languageOptions: {
36 | parser: tsParser,
37 | },
38 | },
39 |
40 | // Node.js environment (servers, scripts, config files)
41 | {
42 | files: [
43 | "**/*.config.{js,ts,mjs}",
44 | "**/scripts/**/*",
45 | "apps/api/**/*",
46 | "apps/email/**/*",
47 | "db/**/*",
48 | "infra/**/*",
49 | "packages/core/**/*",
50 | "packages/ws-protocol/**/*",
51 | ],
52 | languageOptions: {
53 | globals: { ...globals.node },
54 | },
55 | },
56 |
57 | // React environment (frontend apps, email templates)
58 | {
59 | ...react.configs["recommended-typescript"],
60 | files: [
61 | "apps/app/**/*.{ts,tsx}",
62 | "apps/email/**/*.tsx",
63 | "apps/web/**/*.{ts,tsx}",
64 | "packages/ui/**/*.tsx",
65 | ],
66 | rules: {
67 | ...react.configs["recommended-typescript"].rules,
68 | "@eslint-react/dom/no-missing-iframe-sandbox": "off",
69 | },
70 | languageOptions: {
71 | parser: tsParser,
72 | parserOptions: {
73 | ecmaVersion: "latest",
74 | sourceType: "module",
75 | jsxImportSource: "react",
76 | ecmaFeatures: { jsx: true },
77 | },
78 | globals: {
79 | ...globals.browser,
80 | ...globals.es2021,
81 | },
82 | },
83 | },
84 |
85 | // Email templates: add Node globals (server-side rendering)
86 | {
87 | files: ["apps/email/**/*.tsx"],
88 | languageOptions: {
89 | globals: { ...globals.node },
90 | },
91 | },
92 |
93 | // UI package specific overrides
94 | {
95 | files: ["packages/ui/**/*.tsx"],
96 | rules: {
97 | "@eslint-react/no-forward-ref": "off",
98 | },
99 | },
100 |
101 | // Prettier must be last to override any formatting rules
102 | prettierConfig,
103 | );
104 |
--------------------------------------------------------------------------------
/apps/api/lib/context.ts:
--------------------------------------------------------------------------------
1 | import type { DatabaseSchema } from "@repo/db";
2 | import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
3 | import type { Session, User } from "better-auth/types";
4 | import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
5 | import type { Resend } from "resend";
6 | import type { Auth } from "./auth.js";
7 | import type { Env } from "./env.js";
8 |
9 | /**
10 | * Context object passed to all tRPC procedures.
11 | *
12 | * @remarks
13 | * This context is created for each incoming request and provides access to:
14 | * - Request-specific data (headers, session, etc.)
15 | * - Shared resources (database, cache)
16 | * - Environment configuration
17 | *
18 | * The context is immutable within a single request but can be extended
19 | * by middleware functions before reaching the procedure.
20 | *
21 | * @example
22 | * ```typescript
23 | * // Access context in a tRPC procedure
24 | * export const getUser = publicProcedure
25 | * .input(z.object({ id: z.string() }))
26 | * .query(async ({ ctx, input }) => {
27 | * return await ctx.db.select().from(user).where(eq(user.id, input.id));
28 | * });
29 | * ```
30 | */
31 | export type TRPCContext = {
32 | /** The incoming HTTP request object */
33 | req: Request;
34 |
35 | /** tRPC request metadata (headers, connection info) */
36 | info: CreateHTTPContextOptions["info"];
37 |
38 | /** Drizzle ORM database instance (PostgreSQL via Hyperdrive cached connection) */
39 | db: PostgresJsDatabase;
40 |
41 | /** Drizzle ORM database instance (PostgreSQL via Hyperdrive direct connection) */
42 | dbDirect: PostgresJsDatabase;
43 |
44 | /** Authenticated user session (null if not authenticated) */
45 | session: Session | null;
46 |
47 | /** Authenticated user data (null if not authenticated) */
48 | user: User | null;
49 |
50 | /** Request-scoped cache for storing computed values during request lifecycle */
51 | cache: Map;
52 |
53 | /** Optional HTTP response object (available in Hono middleware) */
54 | res?: Response;
55 |
56 | /** Optional response headers (for setting cookies, CORS headers, etc.) */
57 | resHeaders?: Headers;
58 |
59 | /** Environment variables and secrets */
60 | env: Env;
61 | };
62 |
63 | /**
64 | * Hono application context.
65 | *
66 | * @example
67 | * ```typescript
68 | * app.get("/api/health", async (c) => {
69 | * const db = c.get("db");
70 | * const user = c.get("user");
71 | * return c.json({ status: "ok", user: user?.email });
72 | * });
73 | * ```
74 | */
75 | export type AppContext = {
76 | Bindings: Env;
77 | Variables: {
78 | db: PostgresJsDatabase;
79 | dbDirect: PostgresJsDatabase;
80 | auth: Auth;
81 | resend?: Resend;
82 | session: Session | null;
83 | user: User | null;
84 | };
85 | };
86 |
--------------------------------------------------------------------------------
/db/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # Database Layer
2 |
3 | ## Schema (Drizzle casing: snake_case)
4 |
5 | - user, session, identity, verification, organization, member, team, team_member, invitation, passkey.
6 | - UUID ids via `gen_random_uuid()` (built-in). For UUID v7, use `uuidv7()` (PostgreSQL 18+) or `uuid_generate_v7()` (pg_uuidv7 extension).
7 | - Timestamps use `timestamp(..., withTimezone: true, mode: "date")` with `defaultNow()` and `$onUpdate`.
8 | - Indexes on all FK columns; composite uniques on membership, team membership, identity provider/account, and invitation (org/email/team).
9 | - No FKs on `session.activeOrganizationId/activeTeamId` to stay compatible with Better Auth's dynamic context.
10 | - `organization.metadata` is `text` (JSON serialized); Better Auth expects string for adapter compatibility.
11 |
12 | ## Extended Fields (beyond Better Auth defaults)
13 |
14 | ### Passkey
15 |
16 | - `lastUsedAt`: Tracks last authentication for security audits
17 | - `deviceName`: User-friendly name (e.g., "MacBook Pro")
18 | - `platform`: Authenticator type ("platform" | "cross-platform")
19 |
20 | ### Invitation
21 |
22 | - `invitationStatusEnum`: DB-level enum ("pending", "accepted", "rejected", "canceled")
23 | - `acceptedAt`, `rejectedAt`: Lifecycle timestamps for audit trails
24 | - ⚠️ If Better Auth adds new statuses, enum migration required before upgrading
25 |
26 | ### Member Roles
27 |
28 | - Free text `role` column; allowed values: "owner", "admin", "member"
29 | - Not using pgEnum to stay compatible with Better Auth's role customization
30 | - To add custom roles, configure `organization({ roles: {...} })` in auth.ts
31 |
32 | ## Conventions
33 |
34 | - Keep singular table names and snake_case columns; avoid adding FKs to dynamic Better Auth fields.
35 | - Use `gen_random_uuid()` for portability. UUID v7 alternatives: `uuidv7()` (PG 18+) or `uuid_generate_v7()` (pg_uuidv7).
36 | - Keep `updatedAt` on all tables for audit trails.
37 |
38 | ## References
39 |
40 | ### Better Auth Core Schemas (source)
41 |
42 | - `node_modules/@better-auth/core/src/db/schema/shared.ts` — coreSchema (id, createdAt, updatedAt)
43 | - `node_modules/@better-auth/core/src/db/schema/user.ts`
44 | - `node_modules/@better-auth/core/src/db/schema/session.ts`
45 | - `node_modules/@better-auth/core/src/db/schema/account.ts`
46 | - `node_modules/@better-auth/core/src/db/schema/verification.ts`
47 | - `node_modules/@better-auth/core/src/db/schema/rate-limit.ts`
48 | - `node_modules/@better-auth/core/src/db/plugin.ts` — BetterAuthPluginDBSchema
49 | - `node_modules/@better-auth/core/src/types/init-options.ts` — BetterAuthOptions
50 |
51 | ### Better Auth Plugin Schemas (source)
52 |
53 | - `~/gh/better-auth/packages/better-auth/src/plugins/organization/schema.ts`
54 | - `~/gh/better-auth/packages/better-auth/src/plugins/anonymous/schema.ts`
55 | - `~/gh/better-auth/packages/passkey/src/schema.ts`
56 |
57 | ### Configuration
58 |
59 | - `apps/api/lib/auth.ts`
60 |
--------------------------------------------------------------------------------
/apps/app/lib/auth-config.ts:
--------------------------------------------------------------------------------
1 | // All durations in milliseconds. Providers must match server-side config.
2 | // Changing api.basePath requires updating server routing.
3 | export const authConfig = {
4 | oauth: {
5 | defaultCallbackUrl: "/login",
6 | providers: ["google"] as const,
7 | },
8 |
9 | passkey: {
10 | enableConditionalUI: true,
11 | timeout: 60_000,
12 | userVerification: "preferred" as const,
13 | },
14 |
15 | security: {
16 | allowedRedirectOrigins: [
17 | typeof window !== "undefined"
18 | ? window.location.origin
19 | : "http://localhost:5173",
20 | ],
21 | csrfTokenHeader: "x-csrf-token",
22 | sessionCookieName: "better-auth.session",
23 | },
24 |
25 | api: {
26 | basePath: "/api/auth",
27 | requestTimeout: process.env.NODE_ENV === "development" ? 60_000 : 30_000,
28 | },
29 |
30 | retry: {
31 | attempts: 3,
32 | initialDelay: 1000,
33 | maxDelay: 5000,
34 | backoffMultiplier: 2,
35 | },
36 |
37 | session: {
38 | checkInterval: 5 * 60 * 1000,
39 | refreshThreshold: 10 * 60 * 1000,
40 | },
41 |
42 | errors: {
43 | sessionExpired: "Your session has expired. Please sign in again.",
44 | unauthorized: "You need to sign in to access this page.",
45 | networkError: "Network error. Please check your connection and try again.",
46 | passkeyNotSupported: "Your browser doesn't support passkeys.",
47 | passkeyNotFound:
48 | "No passkey found for this account. Please sign in with Google first.",
49 | genericError: "Something went wrong. Please try again.",
50 | },
51 | } as const;
52 |
53 | // Rejects protocol-relative URLs (//example.com) and external domains
54 | export function isValidRedirectUrl(url: string): boolean {
55 | if (!url.startsWith("/") || url.startsWith("//")) {
56 | return false;
57 | }
58 |
59 | try {
60 | const parsed = new URL(url, window.location.origin);
61 | return authConfig.security.allowedRedirectOrigins.includes(parsed.origin);
62 | } catch {
63 | return false;
64 | }
65 | }
66 |
67 | // Returns "/" for invalid or missing URLs
68 | export function getSafeRedirectUrl(url: unknown): string {
69 | if (typeof url !== "string" || !url) {
70 | return "/";
71 | }
72 |
73 | return isValidRedirectUrl(url) ? url : "/";
74 | }
75 |
76 | // Refresh when expiry is within threshold to prevent mid-operation failures.
77 | // Returns false for already-expired sessions.
78 | export function shouldRefreshSession(
79 | expiresAt: Date | string | undefined,
80 | ): boolean {
81 | if (!expiresAt) return false;
82 |
83 | const expiryTime =
84 | typeof expiresAt === "string"
85 | ? new Date(expiresAt).getTime()
86 | : expiresAt.getTime();
87 |
88 | const now = Date.now();
89 | const timeUntilExpiry = expiryTime - now;
90 |
91 | return (
92 | timeUntilExpiry > 0 && timeUntilExpiry < authConfig.session.refreshThreshold
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/apps/app/hooks/use-login-form.ts:
--------------------------------------------------------------------------------
1 | import { auth } from "@/lib/auth";
2 | import { queryClient } from "@/lib/query";
3 | import { sessionQueryOptions } from "@/lib/queries/session";
4 | import { useNavigate } from "@tanstack/react-router";
5 | import { useState } from "react";
6 | import type { FormEvent } from "react";
7 |
8 | interface UseLoginFormOptions {
9 | onSuccess?: () => void;
10 | isExternallyLoading?: boolean;
11 | }
12 |
13 | export function useLoginForm({
14 | onSuccess,
15 | isExternallyLoading,
16 | }: UseLoginFormOptions = {}) {
17 | const navigate = useNavigate();
18 | const [email, setEmail] = useState("");
19 | const [isLoading, setIsLoading] = useState(false);
20 | const [error, setError] = useState(null);
21 | const [showOtpInput, setShowOtpInput] = useState(false);
22 |
23 | // Combine internal and external loading states
24 | const isDisabled = isLoading || isExternallyLoading;
25 |
26 | const handleSuccess = async () => {
27 | // First, fetch the fresh session to ensure it's in the cache
28 | await queryClient.fetchQuery(sessionQueryOptions());
29 |
30 | // Then invalidate all queries to refresh session state
31 | await queryClient.invalidateQueries();
32 |
33 | // Call custom success handler or navigate to home
34 | if (onSuccess) {
35 | onSuccess();
36 | } else {
37 | navigate({ to: "/" });
38 | }
39 | };
40 |
41 | const handleError = (errorMessage: string) => {
42 | setError(errorMessage || null);
43 | };
44 |
45 | const clearError = () => {
46 | setError(null);
47 | };
48 |
49 | const sendOtp = async (e?: FormEvent) => {
50 | e?.preventDefault();
51 | if (!email) return;
52 |
53 | try {
54 | setIsLoading(true);
55 | setError(null);
56 |
57 | // Send OTP to the user's email
58 | const result = await auth.emailOtp.sendVerificationOtp({
59 | email,
60 | type: "sign-in",
61 | });
62 |
63 | if (result.data) {
64 | setShowOtpInput(true);
65 | setError(null);
66 | } else if (result.error) {
67 | setError(result.error.message || "Failed to send OTP");
68 | }
69 | } catch (err) {
70 | console.error("Email OTP error:", err);
71 | setError("Failed to send verification code");
72 | } finally {
73 | setIsLoading(false);
74 | }
75 | };
76 |
77 | const resetOtpFlow = () => {
78 | setShowOtpInput(false);
79 | setError(null);
80 | };
81 |
82 | return {
83 | // State
84 | email,
85 | isLoading,
86 | isDisabled,
87 | error,
88 | showOtpInput,
89 |
90 | // Setters
91 | setEmail,
92 | setIsLoading,
93 | setError,
94 | setShowOtpInput,
95 |
96 | // Handlers
97 | handleSuccess,
98 | handleError,
99 | clearError,
100 | sendOtp,
101 | resetOtpFlow,
102 | };
103 | }
104 |
--------------------------------------------------------------------------------
/db/scripts/generate-auth-schema.ts:
--------------------------------------------------------------------------------
1 | import { getAuthTables } from "better-auth/db";
2 | import type { BetterAuthOptions } from "better-auth/types";
3 | import { createAuth } from "../../apps/api/lib/auth";
4 | import { env } from "../../apps/api/lib/env";
5 |
6 | /**
7 | * Generates the complete database structure from Better Auth configuration
8 | * Outputs the schema as formatted JSON showing all tables, fields, and relationships
9 | */
10 | async function generateAuthSchema() {
11 | // Mock database instance - Better Auth only needs this for type checking, not actual queries
12 | const mockDb = {} as Record;
13 |
14 | // Create the auth instance to get the configuration
15 | const auth = createAuth(mockDb, {
16 | APP_NAME: env.APP_NAME || "React Starter Kit",
17 | APP_ORIGIN: env.APP_ORIGIN || "http://localhost:3000",
18 | BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET || "mock-secret",
19 | GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID || "mock-client-id",
20 | GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET || "mock-client-secret",
21 | });
22 |
23 | // WARNING: Type assertion needed as Better Auth doesn't export the auth instance type
24 | const authOptions = (auth as { options: BetterAuthOptions }).options;
25 |
26 | // Get the complete database schema
27 | const tables = getAuthTables(authOptions);
28 |
29 | // Format the output for better readability
30 | const schemaOutput = {
31 | metadata: {
32 | description: "Better Auth database schema",
33 | generatedAt: new Date().toISOString(),
34 | tableCount: Object.keys(tables).length,
35 | },
36 | tables: {},
37 | };
38 |
39 | // Process each table
40 | for (const [tableKey, table] of Object.entries(tables)) {
41 | const processedFields: Record> = {};
42 |
43 | // Process each field in the table
44 | for (const [fieldKey, field] of Object.entries(table.fields)) {
45 | processedFields[fieldKey] = {
46 | type: field.type,
47 | required: field.required || false,
48 | unique: field.unique || false,
49 | };
50 |
51 | // Add references if they exist
52 | if (field.references) {
53 | processedFields[fieldKey].references = {
54 | model: field.references.model,
55 | field: field.references.field,
56 | };
57 | }
58 | }
59 |
60 | (schemaOutput.tables as Record)[tableKey] = {
61 | modelName: table.modelName,
62 | fields: processedFields,
63 | };
64 | }
65 |
66 | return schemaOutput;
67 | }
68 |
69 | // Main execution
70 | async function main() {
71 | try {
72 | const schema = await generateAuthSchema();
73 | console.log(JSON.stringify(schema, null, 2));
74 | } catch (error) {
75 | console.error("Error generating auth schema:", error);
76 | process.exit(1);
77 | }
78 | }
79 |
80 | if (require.main === module) {
81 | main();
82 | }
83 |
84 | export { generateAuthSchema };
85 |
--------------------------------------------------------------------------------
/db/schema/team.ts:
--------------------------------------------------------------------------------
1 | // Better Auth teams plugin tables
2 |
3 | import { relations, sql } from "drizzle-orm";
4 | import { index, pgTable, text, timestamp, unique } from "drizzle-orm/pg-core";
5 | import { organization } from "./organization";
6 | import { user } from "./user";
7 |
8 | /**
9 | * Teams table for Better Auth teams plugin.
10 | * Teams belong to organizations and contain members.
11 | */
12 | export const team = pgTable(
13 | "team",
14 | {
15 | id: text()
16 | .primaryKey()
17 | .default(sql`gen_random_uuid()`),
18 | name: text().notNull(),
19 | organizationId: text()
20 | .notNull()
21 | .references(() => organization.id, { onDelete: "cascade" }),
22 | createdAt: timestamp({ withTimezone: true, mode: "date" })
23 | .defaultNow()
24 | .notNull(),
25 | updatedAt: timestamp({ withTimezone: true, mode: "date" })
26 | .defaultNow()
27 | .$onUpdate(() => new Date())
28 | .notNull(),
29 | },
30 | (table) => [index("team_organization_id_idx").on(table.organizationId)],
31 | );
32 |
33 | export type Team = typeof team.$inferSelect;
34 | export type NewTeam = typeof team.$inferInsert;
35 |
36 | /**
37 | * Team membership table for Better Auth teams plugin.
38 | * Links users to teams within organizations.
39 | */
40 | export const teamMember = pgTable(
41 | "team_member",
42 | {
43 | id: text()
44 | .primaryKey()
45 | .default(sql`gen_random_uuid()`),
46 | teamId: text()
47 | .notNull()
48 | .references(() => team.id, { onDelete: "cascade" }),
49 | userId: text()
50 | .notNull()
51 | .references(() => user.id, { onDelete: "cascade" }),
52 | createdAt: timestamp({ withTimezone: true, mode: "date" })
53 | .defaultNow()
54 | .notNull(),
55 | updatedAt: timestamp({ withTimezone: true, mode: "date" })
56 | .defaultNow()
57 | .$onUpdate(() => new Date())
58 | .notNull(),
59 | },
60 | (table) => [
61 | unique("team_member_team_user_unique").on(table.teamId, table.userId),
62 | index("team_member_team_id_idx").on(table.teamId),
63 | index("team_member_user_id_idx").on(table.userId),
64 | ],
65 | );
66 |
67 | export type TeamMember = typeof teamMember.$inferSelect;
68 | export type NewTeamMember = typeof teamMember.$inferInsert;
69 |
70 | // —————————————————————————————————————————————————————————————————————————————
71 | // Relations for better query experience
72 | // —————————————————————————————————————————————————————————————————————————————
73 |
74 | export const teamRelations = relations(team, ({ one, many }) => ({
75 | organization: one(organization, {
76 | fields: [team.organizationId],
77 | references: [organization.id],
78 | }),
79 | members: many(teamMember),
80 | }));
81 |
82 | export const teamMemberRelations = relations(teamMember, ({ one }) => ({
83 | team: one(team, {
84 | fields: [teamMember.teamId],
85 | references: [team.id],
86 | }),
87 | user: one(user, {
88 | fields: [teamMember.userId],
89 | references: [user.id],
90 | }),
91 | }));
92 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": {
3 | "source.organizeImports": "explicit"
4 | },
5 | "editor.defaultFormatter": "esbenp.prettier-vscode",
6 | "editor.formatOnSave": true,
7 | "editor.quickSuggestions": {
8 | "strings": "on"
9 | },
10 | "editor.tabSize": 2,
11 | "[terraform]": {
12 | "editor.defaultFormatter": "hashicorp.terraform",
13 | "editor.formatOnSave": true
14 | },
15 | "eslint.nodePath": "./node_modules",
16 | "eslint.runtime": "node",
17 | "prettier.prettierPath": "./node_modules/prettier",
18 | "typescript.tsdk": "./node_modules/typescript/lib",
19 | "typescript.enablePromptUseWorkspaceTsdk": true,
20 | "vitest.commandLine": "bun run vitest",
21 | "files.associations": {
22 | ".env.*.local": "properties",
23 | ".env.*": "properties",
24 | "*.css": "tailwindcss"
25 | },
26 | "files.exclude": {
27 | "**/.cache": true,
28 | "**/.DS_Store": true,
29 | "**/.editorconfig": true,
30 | "**/.eslintcache": true,
31 | "**/.git": true,
32 | "**/.gitattributes": true,
33 | "**/.husky": true,
34 | "**/.pnp.*": true,
35 | "**/.prettierignore": true,
36 | "**/node_modules": true,
37 | "**/bun.lock": true
38 | },
39 | "files.readonlyInclude": {
40 | "**/routeTree.gen.ts": true
41 | },
42 | "files.watcherExclude": {
43 | "**/routeTree.gen.ts": true
44 | },
45 | "search.exclude": {
46 | "**/dist/": true,
47 | "**/node_modules/": true,
48 | "**/bun.lock": true,
49 | "**/routeTree.gen.ts": true
50 | },
51 | "tailwindCSS.experimental.classRegex": [
52 | ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"],
53 | ["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
54 | ],
55 | "terminal.integrated.env.linux": {
56 | "CACHE_DIR": "${workspaceFolder}/.cache"
57 | },
58 | "terminal.integrated.env.osx": {
59 | "CACHE_DIR": "${workspaceFolder}/.cache"
60 | },
61 | "terminal.integrated.env.windows": {
62 | "CACHE_DIR": "${workspaceFolder}\\.cache"
63 | },
64 | "cSpell.ignoreWords": [
65 | "browserslist",
66 | "bunx",
67 | "cloudfunctions",
68 | "corejs",
69 | "corepack",
70 | "endregion",
71 | "entrypoint",
72 | "envalid",
73 | "envars",
74 | "eslintcache",
75 | "esmodules",
76 | "esnext",
77 | "execa",
78 | "firestore",
79 | "globby",
80 | "hono",
81 | "hyperdrive",
82 | "identitytoolkit",
83 | "jamstack",
84 | "koistya",
85 | "konstantin",
86 | "kriasoft",
87 | "localforage",
88 | "miniflare",
89 | "neondatabase",
90 | "nodenext",
91 | "notistack",
92 | "oidc",
93 | "openai",
94 | "pathinfo",
95 | "pino",
96 | "pnpify",
97 | "reactstarter",
98 | "refetch",
99 | "refetchable",
100 | "relyingparty",
101 | "signup",
102 | "sourcemap",
103 | "swapi",
104 | "tarkus",
105 | "trpc",
106 | "tslib",
107 | "typechecking",
108 | "vite",
109 | "vitest",
110 | "webflow"
111 | ]
112 | }
113 |
--------------------------------------------------------------------------------
/apps/api/lib/app.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @file Hono app construction and tRPC router initialization.
3 | *
4 | * Combines authentication, tRPC, and health check endpoints into a single HTTP router.
5 | */
6 |
7 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
8 | import { Hono } from "hono";
9 | import type { AppContext } from "./context.js";
10 | import { router } from "./trpc.js";
11 | import { organizationRouter } from "../routers/organization.js";
12 | import { userRouter } from "../routers/user.js";
13 |
14 | // tRPC API router
15 | const appRouter = router({
16 | user: userRouter,
17 | organization: organizationRouter,
18 | });
19 |
20 | // HTTP router
21 | const app = new Hono();
22 |
23 | app.get("/", (c) => c.redirect("/api"));
24 |
25 | // Root endpoint with API information
26 | app.get("/api", (c) => {
27 | return c.json({
28 | name: "@repo/api",
29 | version: "0.0.0",
30 | endpoints: {
31 | trpc: "/api/trpc",
32 | auth: "/api/auth",
33 | health: "/health",
34 | },
35 | documentation: {
36 | trpc: "https://trpc.io",
37 | auth: "https://www.better-auth.com",
38 | },
39 | });
40 | });
41 |
42 | // Health check endpoint
43 | app.get("/health", (c) => {
44 | return c.json({ status: "healthy", timestamp: new Date().toISOString() });
45 | });
46 |
47 | // Authentication routes
48 | app.on(["GET", "POST"], "/api/auth/*", (c) => {
49 | const auth = c.get("auth");
50 | if (!auth) {
51 | return c.json({ error: "Authentication service not initialized" }, 503);
52 | }
53 | return auth.handler(c.req.raw);
54 | });
55 |
56 | // tRPC API routes
57 | app.use("/api/trpc/*", (c) => {
58 | return fetchRequestHandler({
59 | req: c.req.raw,
60 | router: appRouter,
61 | endpoint: "/api/trpc",
62 | async createContext({ req, resHeaders, info }) {
63 | const db = c.get("db");
64 | const dbDirect = c.get("dbDirect");
65 | const auth = c.get("auth");
66 |
67 | if (!db) {
68 | throw new Error("Database not available in context");
69 | }
70 |
71 | if (!dbDirect) {
72 | throw new Error("Direct database not available in context");
73 | }
74 |
75 | if (!auth) {
76 | throw new Error("Authentication service not available in context");
77 | }
78 |
79 | const sessionData = await auth.api.getSession({
80 | headers: req.headers,
81 | });
82 |
83 | return {
84 | req,
85 | res: c.res,
86 | resHeaders,
87 | info,
88 | env: c.env,
89 | db,
90 | dbDirect,
91 | session: sessionData?.session ?? null,
92 | user: sessionData?.user ?? null,
93 | cache: new Map(),
94 | };
95 | },
96 | batching: {
97 | enabled: true,
98 | },
99 | onError({ error, path }) {
100 | console.error("tRPC error on path", path, ":", error);
101 | },
102 | });
103 | });
104 |
105 | export { appRouter };
106 | export type AppRouter = typeof appRouter;
107 | export default app;
108 |
--------------------------------------------------------------------------------
/packages/ws-protocol/messages.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * WebSocket message schemas for the application protocol.
3 | *
4 | * Uses @ws-kit/zod for type-safe message definitions with Zod validation.
5 | * All messages follow the envelope structure: { type, meta, payload }.
6 | *
7 | * @example
8 | * ```ts
9 | * import { Ping, Pong, Echo } from "@repo/ws-protocol";
10 | *
11 | * // Send a ping
12 | * ctx.send(Ping);
13 | *
14 | * // Send an echo with payload
15 | * ctx.send(Echo, { text: "Hello" });
16 | * ```
17 | */
18 |
19 | import { message, rpc, z } from "@ws-kit/zod";
20 |
21 | // ============================================================================
22 | // Connection Health
23 | // ============================================================================
24 |
25 | /**
26 | * Ping message for connection health checks.
27 | * Server responds with Pong.
28 | */
29 | export const Ping = message("PING", { timestamp: z.number().optional() });
30 |
31 | /**
32 | * Pong message sent in response to Ping.
33 | */
34 | export const Pong = message("PONG", { timestamp: z.number().optional() });
35 |
36 | // ============================================================================
37 | // Echo (Simple Request/Response)
38 | // ============================================================================
39 |
40 | /**
41 | * Echo message - simple example demonstrating request/response pattern.
42 | * Server echoes back the same text.
43 | */
44 | export const Echo = message("ECHO", { text: z.string() });
45 |
46 | // ============================================================================
47 | // Notifications
48 | // ============================================================================
49 |
50 | /**
51 | * Server notification broadcast to clients.
52 | */
53 | export const Notification = message("NOTIFICATION", {
54 | level: z.enum(["info", "warning", "error"]),
55 | message: z.string(),
56 | });
57 |
58 | // ============================================================================
59 | // Error Handling
60 | // ============================================================================
61 |
62 | /**
63 | * Error message for communicating protocol-level errors.
64 | */
65 | export const ErrorMessage = message("ERROR", {
66 | code: z.enum(["INVALID_MESSAGE", "UNAUTHORIZED", "SERVER_ERROR"]),
67 | message: z.string(),
68 | });
69 |
70 | // ============================================================================
71 | // RPC Examples
72 | // ============================================================================
73 |
74 | /**
75 | * Get user by ID - example RPC with request/response pattern.
76 | * Request: GET_USER with { id }
77 | * Response: USER with { id, name, email }
78 | */
79 | export const GetUser = rpc("GET_USER", { id: z.string() }, "USER", {
80 | id: z.string(),
81 | name: z.string(),
82 | email: z.string().optional(),
83 | });
84 |
85 | // ============================================================================
86 | // Type Exports
87 | // ============================================================================
88 |
89 | export type { InferMessage, InferPayload, InferResponse } from "@ws-kit/zod";
90 |
--------------------------------------------------------------------------------