diff --git a/apps/web/src/lib/readiness.ts b/apps/web/src/lib/readiness.ts new file mode 100644 index 00000000..90a0bb22 --- /dev/null +++ b/apps/web/src/lib/readiness.ts @@ -0,0 +1,80 @@ +type ReadinessCheckStatus = "ok" | "skipped" | "error"; +type ReadinessStatus = "ok" | "degraded"; + +const DB_CHECK_TIMEOUT_MS = 1_500; + +function hasDatabaseConfig() { + return Boolean(process.env.DATABASE_URL ?? process.env.POSTGRES_URL); +} + +function isProductionRuntime() { + return process.env.NODE_ENV === "production"; +} + +async function withTimeout(operation: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined; + + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("Readiness check timed out")); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +async function checkDatabase(): Promise<{ + status: ReadinessCheckStatus; + configured: boolean; + durationMs?: number; +}> { + const configured = hasDatabaseConfig(); + const startedAt = Date.now(); + + if (!configured) { + return { + status: isProductionRuntime() ? "error" : "skipped", + configured, + }; + } + + try { + const { pool } = await import("@repo/db/client"); + await withTimeout(pool.query("select 1"), DB_CHECK_TIMEOUT_MS); + + return { + status: "ok", + configured, + durationMs: Date.now() - startedAt, + }; + } catch { + return { + status: "error", + configured, + durationMs: Date.now() - startedAt, + }; + } +} + +export async function buildReadinessResponse() { + const database = await checkDatabase(); + const status: ReadinessStatus = database.status === "error" ? "degraded" : "ok"; + + return { + body: { + status, + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round(process.uptime()), + checks: { + app: { status: "ok" as const }, + database, + }, + }, + httpStatus: status === "ok" ? 200 : 503, + }; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index b644a032..0e36343e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthForgotPasswordRouteImport } from './routes/auth/forgot-password' import { Route as AuthErrorRouteImport } from './routes/auth/error' import { Route as AuthConfirmRouteImport } from './routes/auth/confirm' +import { Route as ApiReadyRouteImport } from './routes/api/ready' import { Route as ApiHealthRouteImport } from './routes/api/health' import { Route as ProtectedSettingsRouteImport } from './routes/_protected/settings' import { Route as ProtectedSearchRouteImport } from './routes/_protected/search' @@ -126,6 +127,11 @@ const AuthConfirmRoute = AuthConfirmRouteImport.update({ path: '/auth/confirm', getParentRoute: () => rootRouteImport, } as any) +const ApiReadyRoute = ApiReadyRouteImport.update({ + id: '/api/ready', + path: '/api/ready', + getParentRoute: () => rootRouteImport, +} as any) const ApiHealthRoute = ApiHealthRouteImport.update({ id: '/api/health', path: '/api/health', @@ -355,6 +361,7 @@ export interface FileRoutesByFullPath { '/search': typeof ProtectedSearchRoute '/settings': typeof ProtectedSettingsRoute '/api/health': typeof ApiHealthRoute + '/api/ready': typeof ApiReadyRoute '/auth/confirm': typeof AuthConfirmRoute '/auth/error': typeof AuthErrorRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute @@ -405,6 +412,7 @@ export interface FileRoutesByTo { '/search': typeof ProtectedSearchRoute '/settings': typeof ProtectedSettingsRoute '/api/health': typeof ApiHealthRoute + '/api/ready': typeof ApiReadyRoute '/auth/confirm': typeof AuthConfirmRoute '/auth/error': typeof AuthErrorRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute @@ -460,6 +468,7 @@ export interface FileRoutesById { '/_protected/search': typeof ProtectedSearchRoute '/_protected/settings': typeof ProtectedSettingsRoute '/api/health': typeof ApiHealthRoute + '/api/ready': typeof ApiReadyRoute '/auth/confirm': typeof AuthConfirmRoute '/auth/error': typeof AuthErrorRoute '/auth/forgot-password': typeof AuthForgotPasswordRoute @@ -516,6 +525,7 @@ export interface FileRouteTypes { | '/search' | '/settings' | '/api/health' + | '/api/ready' | '/auth/confirm' | '/auth/error' | '/auth/forgot-password' @@ -566,6 +576,7 @@ export interface FileRouteTypes { | '/search' | '/settings' | '/api/health' + | '/api/ready' | '/auth/confirm' | '/auth/error' | '/auth/forgot-password' @@ -620,6 +631,7 @@ export interface FileRouteTypes { | '/_protected/search' | '/_protected/settings' | '/api/health' + | '/api/ready' | '/auth/confirm' | '/auth/error' | '/auth/forgot-password' @@ -667,6 +679,7 @@ export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren AboutRoute: typeof AboutRoute ApiHealthRoute: typeof ApiHealthRoute + ApiReadyRoute: typeof ApiReadyRoute AuthConfirmRoute: typeof AuthConfirmRoute AuthErrorRoute: typeof AuthErrorRoute AuthForgotPasswordRoute: typeof AuthForgotPasswordRoute @@ -780,6 +793,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthConfirmRouteImport parentRoute: typeof rootRouteImport } + '/api/ready': { + id: '/api/ready' + path: '/api/ready' + fullPath: '/api/ready' + preLoaderRoute: typeof ApiReadyRouteImport + parentRoute: typeof rootRouteImport + } '/api/health': { id: '/api/health' path: '/api/health' @@ -1168,6 +1188,7 @@ const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, AboutRoute: AboutRoute, ApiHealthRoute: ApiHealthRoute, + ApiReadyRoute: ApiReadyRoute, AuthConfirmRoute: AuthConfirmRoute, AuthErrorRoute: AuthErrorRoute, AuthForgotPasswordRoute: AuthForgotPasswordRoute, diff --git a/apps/web/src/routes/api/health.ts b/apps/web/src/routes/api/health.ts index c673a68c..b229f4bb 100644 --- a/apps/web/src/routes/api/health.ts +++ b/apps/web/src/routes/api/health.ts @@ -3,7 +3,20 @@ import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/api/health")({ server: { handlers: { - GET: async () => Response.json({ status: "ok" }), + GET: async () => { + return Response.json( + { + status: "ok", + timestamp: new Date().toISOString(), + uptimeSeconds: Math.round(process.uptime()), + }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ); + }, }, }, }); diff --git a/apps/web/src/routes/api/ready.ts b/apps/web/src/routes/api/ready.ts new file mode 100644 index 00000000..6a4c702c --- /dev/null +++ b/apps/web/src/routes/api/ready.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { buildReadinessResponse } from "../../lib/readiness"; + +export const Route = createFileRoute("/api/ready")({ + server: { + handlers: { + GET: async () => { + const readiness = await buildReadinessResponse(); + + return Response.json(readiness.body, { + status: readiness.httpStatus, + headers: { + "Cache-Control": "no-store", + }, + }); + }, + }, + }, +}); diff --git a/docs/load-testing.md b/docs/load-testing.md new file mode 100644 index 00000000..34295cff --- /dev/null +++ b/docs/load-testing.md @@ -0,0 +1,122 @@ +# Production workload and load testing plan + +This plan is intentionally tool-agnostic and dependency-free. It documents the first production-readiness workload pass without adding k6, Artillery, or other runner dependencies to the workspace. Use existing smoke/performance coverage to prove user journeys, then run controlled HTTP load from an external runner against a staging or production-like environment. + +## Existing coverage to reuse + +- Web E2E: `pnpm --filter web test:e2e` runs Playwright specs in `apps/web/e2e/specs/` for auth and smoke coverage. +- Mobile E2E: `pnpm --filter mobile test:e2e` runs Maestro flows under `apps/mobile/.maestro/flows/main`. +- Mobile performance budgets: start the app with `pnpm --filter mobile dev:e2e:perf`, then run `pnpm --filter mobile test:e2e:perf`. The performance flow currently covers tab navigation budgets via `apps/mobile/.maestro/flows/performance/tab_navigation_budgets.yaml`. +- API performance unit/integration coverage exists in `packages/api/src/test/performance.ts` and `packages/api/src/routers/__tests__/endpoint-performance.test.ts`; treat these as contract/per-request regression checks, not production load tests. + +## Environment requirements + +Run load tests only against an approved target with production-like data shape, rate limits, storage, webhook secrets, and OAuth callback configuration. Do not run destructive or high-volume tests against production without a maintenance window and rollback owner. + +Minimum target information: + +- Public app origin and API origin. +- Dedicated load-test user accounts and OAuth provider sandbox/test applications. +- Service-role or admin fixture setup path for creating users, followers, activities, routes, and provider links before the run. +- Wahoo webhook token and callback URL for the approved target. +- Upload storage quota and max file size policy for FIT/GPX files. +- Observability dashboard links for app errors, p95/p99 latency, database CPU/connections, queue/backlog depth, storage failures, webhook failure rate, and auth/email delivery. + +## Workload model + +Start with three profiles and scale only after SLOs are stable: + +| Profile | Purpose | Suggested duration | +| --- | --- | --- | +| Smoke load | Verifies scripts and fixtures safely. | 5-10 minutes | +| Expected peak | Simulates near-term peak launch traffic. | 30-60 minutes | +| Stress/soak | Finds saturation point or long-tail leaks. | 2-4 hours for stress, 8+ hours for soak when approved | + +Do not begin stress/soak until smoke load passes without elevated 5xx, webhook failures, auth lockouts, or runaway database/storage cost. + +## Critical scenarios + +### 1. Auth and session lifecycle + +- Sign in existing users, refresh active sessions, sign out, and request passwordless/email flows if enabled. +- Exercise web and mobile redirect paths separately. +- Metrics: auth success rate, p95/p99 sign-in latency, session refresh error rate, email delivery latency, lockout/rate-limit counts. + +### 2. Feed and social graph + +- Load home/feed views, profile views, activity detail, likes, comments, follows, messages/notifications inbox views, and read-all notifications. +- Use fixture users with realistic follower counts and mixed public/private content. +- Metrics: feed query p95/p99, database rows scanned, cache hit rate if available, mutation latency, notification fanout lag, realtime/subscription errors. + +### 3. Activity file upload and processing + +- Upload representative FIT/GPX files from web and mobile routes, including small, normal, and max-policy files. +- Validate async processing, storage write/read behavior, duplicate upload handling, and user-visible result availability. +- Metrics: upload success rate, upload p95/p99, processing queue latency, storage errors, parser failures, memory/CPU spikes, time to activity-visible. + +### 4. Trends, charts, and training-load views + +- Open dashboard/trends, activity detail charts, plan projections, training preferences, and load-related views for users with sparse, normal, and high-volume histories. +- Metrics: chart/data endpoint p95/p99, expensive-query count, database CPU, payload size, client render/performance beacons where available. + +### 5. OAuth callback and provider sync + +- Use sandbox OAuth applications where possible to connect/disconnect providers and complete `/api/integrations/callback/` flows. +- Include failed callback states: missing code, invalid state, expired state, duplicate callback, provider error response. +- Metrics: callback success/error rate by provider, token persistence failures, retry count, redirect latency, provider API throttling. + +### 6. Wahoo webhook ingestion + +- Replay valid Wahoo webhook payloads to `/api/webhooks/wahoo` using the approved webhook token. +- Include duplicate events, bursts, invalid signatures/tokens, missing user/provider mapping, and provider-sync drain behavior. +- Metrics: accepted/rejected counts, p95/p99 ingestion latency, idempotency hit rate, queue/drain backlog, provider API errors, dead-letter/manual-retry count. + +### 7. Mobile startup sync + +- Measure cold launch, warm relaunch, authenticated home readiness, tab navigation, offline-to-online recovery, and startup with large cached account data. +- Reuse Maestro performance beacons first; use a release/preview-style build for final gates because dev-client mode adds overhead. +- Metrics: cold start to interactive, authenticated home ready, startup sync p95/p99, runtime errors, memory growth, failed query retries. + +## Metrics and release gates + +Define exact SLOs before the first official run. Until product SLOs are approved, capture at minimum: + +- HTTP request rate, p50/p95/p99 latency, 4xx/5xx rate, and timeout rate by route/procedure. +- Database CPU, memory, active connections, slow queries, lock waits, and connection pool saturation. +- Storage upload/read error rate and p95/p99 upload time. +- Background processing queue depth, oldest job age, retry count, and failure count. +- Auth/email provider success rate, rate-limit counts, and callback errors. +- Mobile perf beacons for launch and navigation budgets. +- Cost indicators: database/storage egress, provider API quota consumption, and error-reporting volume. + +Suggested go/no-go defaults for the first production-readiness run: + +- No sustained 5xx rate above 1% for critical paths. +- No unbounded queue growth after load stops. +- No data loss, duplicate side effects, or broken auth/session recovery. +- p95 latency remains inside the agreed user-facing SLO for auth, feed, upload, trends, callbacks, and webhook ingestion. + +## Dependency-free runner skeleton + +If a no-dependency local probe is needed before adopting a dedicated load runner, use a small shell loop from outside the app host. Keep concurrency low and avoid authenticated mutations unless fixture accounts and cleanup are approved. + +```bash +TARGET_ORIGIN="https://staging.example.com" +for i in $(seq 1 100); do + curl --fail --silent --show-error "$TARGET_ORIGIN/" >/dev/null & + if [ $((i % 10)) -eq 0 ]; then wait; fi +done +wait +``` + +For real workload testing, prefer an external runner with explicit concurrency, arrival rate, thresholds, structured metrics export, and secret handling. Add that tool only after selecting k6, Artillery, or a hosted load-testing provider and accepting the lockfile/CI impact. + +## Reporting template + +Each run should produce a short report with: + +- Target environment, commit SHA, data fixture version, runner location, date/time, and approver. +- Scenario mix, duration, peak concurrency/arrival rate, and total requests/events/uploads. +- Pass/fail summary against SLOs and go/no-go gates. +- Top bottlenecks with dashboard links and representative request IDs. +- Follow-up work items for fixes, missing instrumentation, data-fixture gaps, or safer runner automation. diff --git a/packages/api/src/lib/rate-limit.ts b/packages/api/src/lib/rate-limit.ts new file mode 100644 index 00000000..8bc54e52 --- /dev/null +++ b/packages/api/src/lib/rate-limit.ts @@ -0,0 +1,140 @@ +import type { Context } from "../context"; + +const WINDOW_MS = 60_000; +const AUTHENTICATED_LIMIT = 300; +const ANONYMOUS_LIMIT = 60; +const MAX_BUCKETS_BEFORE_SWEEP = 10_000; + +export interface RateLimitBucket { + count: number; + resetAt: number; +} + +export interface RateLimitIdentity { + key: string; + limit: number; +} + +export interface ApiRateLimitResult { + allowed: boolean; + limit: number; + remaining: number; + resetAt: Date; + retryAfterSeconds: number; +} + +const unlimitedResult: ApiRateLimitResult = { + allowed: true, + limit: Number.POSITIVE_INFINITY, + remaining: Number.POSITIVE_INFINITY, + resetAt: new Date(Number.MAX_SAFE_INTEGER), + retryAfterSeconds: 0, +}; + +const buckets = new Map(); + +export interface ApiRateLimitStore { + increment(identity: RateLimitIdentity, now: number): RateLimitBucket; +} + +function sweepExpiredBuckets(now: number) { + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } +} + +function getForwardedIp(headers: Headers) { + const directIp = headers.get("cf-connecting-ip") ?? headers.get("x-real-ip"); + + if (directIp) { + return directIp.trim(); + } + + const forwardedFor = headers.get("x-forwarded-for"); + + if (forwardedFor) { + return forwardedFor.split(",")[0]?.trim() || "unknown"; + } + + const forwarded = headers.get("forwarded"); + const forwardedForMatch = forwarded?.match(/for=(?:"?)([^;,"]+)/i); + + return forwardedForMatch?.[1]?.trim() || "unknown"; +} + +class InMemoryApiRateLimitStore implements ApiRateLimitStore { + increment(identity: RateLimitIdentity, now: number): RateLimitBucket { + if (buckets.size > MAX_BUCKETS_BEFORE_SWEEP) { + sweepExpiredBuckets(now); + } + + const existing = buckets.get(identity.key); + const bucket = + existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + WINDOW_MS }; + + bucket.count += 1; + buckets.set(identity.key, bucket); + + return bucket; + } +} + +const defaultStore = new InMemoryApiRateLimitStore(); + +function getRateLimitIdentity(ctx: Context): RateLimitIdentity { + const userId = ctx.session?.user.id; + + if (userId) { + return { + key: `user:${userId}`, + limit: AUTHENTICATED_LIMIT, + }; + } + + const ip = getForwardedIp(ctx.headers); + const source = ctx.trpcSource || ctx.clientType || "unknown"; + + return { + key: `anonymous:${source}:${ip}`, + limit: ANONYMOUS_LIMIT, + }; +} + +function shouldBypassRateLimit(ctx: Context) { + return ctx.trpcSource.startsWith("vitest"); +} + +/** + * Lightweight tRPC API abuse guard. + * + * Production caveat: this is an in-memory, per-process limiter. It is safe for + * local/dev and provides a best-effort guard in a single Node process, but it is + * not durable across restarts and is not shared across scaled/serverless + * instances. Replace the bucket store with Redis/Upstash (or an edge/WAF + * provider) before relying on it as the sole production abuse control. + */ +export function checkApiRateLimit( + ctx: Context, + now = Date.now(), + store: ApiRateLimitStore = defaultStore, +): ApiRateLimitResult { + if (shouldBypassRateLimit(ctx)) { + return unlimitedResult; + } + + const identity = getRateLimitIdentity(ctx); + const bucket = store.increment(identity, now); + + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1_000)); + const remaining = Math.max(0, identity.limit - bucket.count); + + return { + allowed: bucket.count <= identity.limit, + limit: identity.limit, + remaining, + resetAt: new Date(bucket.resetAt), + retryAfterSeconds, + }; +} diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 0224d988..a88f8171 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -4,6 +4,7 @@ import superjson from "superjson"; import z, { ZodError } from "zod"; import type { Context } from "./context"; import { isTrainingPlanCommitErrorCause } from "./lib/errors/trainingPlanCommitErrors"; +import { checkApiRateLimit } from "./lib/rate-limit"; const t = initTRPC.context().create({ sse: { @@ -27,9 +28,20 @@ const t = initTRPC.context().create({ }); export const createTRPCRouter = t.router; -export const publicProcedure = t.procedure; +export const publicProcedure = t.procedure.use(async ({ ctx, next }) => { + const rateLimit = checkApiRateLimit(ctx); -export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { + if (!rateLimit.allowed) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Rate limit exceeded. Retry after ${rateLimit.retryAfterSeconds} seconds.`, + }); + } + + return next(); +}); + +export const protectedProcedure = publicProcedure.use(async ({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); } diff --git a/packages/auth/src/runtime/server.ts b/packages/auth/src/runtime/server.ts index 4550c6c2..f4c11958 100644 --- a/packages/auth/src/runtime/server.ts +++ b/packages/auth/src/runtime/server.ts @@ -83,7 +83,7 @@ function resolveAuthSecret(explicitSecret?: string) { return `gradientpeak-${process.env["NODE_ENV"] ?? "unknown"}-fallback-auth-secret`; } - return undefined; + throw new Error("BETTER_AUTH_SECRET is required outside development/build-time auth setup."); } export function createGradientPeakAuth(options: CreateGradientPeakAuthOptions) { diff --git a/packages/db/supabase/migrations/20260707120000_harden_profile_avatar_storage_and_signup_trigger.sql b/packages/db/supabase/migrations/20260707120000_harden_profile_avatar_storage_and_signup_trigger.sql new file mode 100644 index 00000000..80a92de6 --- /dev/null +++ b/packages/db/supabase/migrations/20260707120000_harden_profile_avatar_storage_and_signup_trigger.sql @@ -0,0 +1,10 @@ +-- Keep profile avatars publicly addressable by URL while preventing broad +-- unauthenticated Storage object listing through the public bucket policy. +drop policy if exists "Anyone can view avatars" on storage.objects; + +-- SECURITY DEFINER trigger functions do not need to be directly executable by +-- application API roles. Revoke explicit and inherited execute grants so the +-- auth.users trigger remains the only intended invocation path. +revoke execute on function public.handle_new_user() from public; +revoke execute on function public.handle_new_user() from anon; +revoke execute on function public.handle_new_user() from authenticated; diff --git a/packages/db/supabase/migrations/20260707120100_add_fk_composite_indexes.sql b/packages/db/supabase/migrations/20260707120100_add_fk_composite_indexes.sql new file mode 100644 index 00000000..30222734 --- /dev/null +++ b/packages/db/supabase/migrations/20260707120100_add_fk_composite_indexes.sql @@ -0,0 +1,26 @@ +create index if not exists idx_activity_file_ingestions_activity_profile + on public.activity_file_ingestions(activity_id, profile_id); + +create index if not exists idx_activity_geometry_activity_profile + on public.activity_geometry(activity_id, profile_id); + +create index if not exists idx_activity_imports_activity_profile + on public.activity_imports(activity_id, profile_id); + +create index if not exists idx_activity_laps_activity_profile + on public.activity_laps(activity_id, profile_id); + +create index if not exists idx_activity_summaries_activity_profile + on public.activity_summaries(activity_id, profile_id); + +create index if not exists idx_event_external_links_event_profile + on public.event_external_links(event_id, profile_id); + +create index if not exists idx_event_payloads_event_profile + on public.event_payloads(event_id, profile_id); + +create index if not exists idx_event_recurrence_event_profile + on public.event_recurrence(event_id, profile_id); + +create index if not exists idx_event_schedule_links_event_profile + on public.event_schedule_links(event_id, profile_id); diff --git a/packages/db/supabase/seed.sql b/packages/db/supabase/seed.sql index c7c345ef..937bcce6 100644 --- a/packages/db/supabase/seed.sql +++ b/packages/db/supabase/seed.sql @@ -78,6 +78,10 @@ create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user(); +revoke execute on function public.handle_new_user() from public; +revoke execute on function public.handle_new_user() from anon; +revoke execute on function public.handle_new_user() from authenticated; + -- PROFILE AVATAR BUCKET insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ( @@ -103,10 +107,6 @@ with check ( ); drop policy if exists "Anyone can view avatars" on storage.objects; -create policy "Anyone can view avatars" -on storage.objects -for select -using (bucket_id = 'profile-avatars');