diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 401cc8b..530d78c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,7 +58,7 @@ async function getDashboardData(userId: string) { `lib/supabase/server.ts` reads cookies → never call it from a `'use cache'` function. Call it at the page/layout/Server-Action level and pass the data (not the client) into cached helpers. -`lib/supabase/admin.ts` does not depend on cookies and is safe to call from cached scopes — but only when the call is genuinely user-agnostic. +`lib/supabase/admin.ts` does not depend on cookies and is safe to call inside `'use cache'`. When the result depends on a caller's identity (user, role, tenant), the cache key MUST partition on that identity — otherwise one user sees another user's data. For genuinely user-agnostic reads (e.g. clinic settings, services list), no key partitioning is needed. ### 4. Realtime placement diff --git a/README.md b/README.md index fed92be..58fe675 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Planning phase. See [PLAN.md](./PLAN.md) for the architecture and phased roadmap ## Stack -- **pnpm** + **Next.js 15** (App Router) + **TypeScript** +- **pnpm** + **Next.js 16** (App Router) + **TypeScript** - **Supabase** (Postgres + Auth + Storage) — shared across personal projects via schema-per-app - **Upstash** Redis + QStash — shared across personal projects via key prefixes - **Vercel** for hosting diff --git a/app/layout.tsx b/app/layout.tsx index e3e0f19..583387a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,16 @@ import type { ReactNode } from "react"; // Intentionally empty pass-through. The real `` + `
` live in // `app/[locale]/layout.tsx` so `lang={locale}` and the i18n provider can // be set per locale. Do not put global UI here. +// +// `global-error.tsx` (anywhere in the tree) MUST render its own +// `` — Next.js renders it instead of the root layout. This rule +// is universal, not specific to this codebase. +// +// `error.tsx` normally inherits its closest layout's shell. But because the +// root layout here is a passthrough, a hypothetical `app/error.tsx` would +// render without ``. Prefer placing `error.tsx` files under +// `app/[locale]/` (where the real shell lives), or render the document +// shell explicitly inside any root-level error file. export default function RootLayout({ children }: { children: ReactNode }) { return children; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 4f82d7b..5e8d7da 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,8 +13,47 @@ const config = [ "error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], + // Force every call site through the prefixed / schema-scoped factories. + // Raw clients bypass the bsk:{env}: Redis prefix and the schema='bsk' + // scoping, which collide with sibling apps sharing the same project. + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "@upstash/redis", + message: + "Import the `cache` / `createRateLimiter` helpers from '@/lib/upstash' instead. Raw Redis bypasses the bsk:{env}: key prefix and can collide with sibling apps.", + }, + { + name: "@upstash/ratelimit", + message: + "Use `createRateLimiter` from '@/lib/upstash' instead — it bakes in the bsk:{env}:ratelimit prefix.", + }, + { + name: "@supabase/supabase-js", + message: + "Import the schema-scoped factory from '@/lib/supabase/{server,client,admin}' instead. Raw createClient bypasses db.schema='bsk' and reads/writes leak to public.", + }, + ], + }, + ], }, }, + // Only the named factory files may import the raw infrastructure libs. + // Explicit filenames (not a glob) keep the trust boundary tight — adding a + // new factory should be a deliberate PR change here, not an accidental + // file landing under `lib/supabase/*`. + { + files: [ + "lib/upstash.ts", + "lib/supabase/server.ts", + "lib/supabase/client.ts", + "lib/supabase/admin.ts", + "lib/supabase/session.ts", + ], + rules: { "no-restricted-imports": "off" }, + }, ]; export default config; diff --git a/lib/env/server.ts b/lib/env/server.ts index f61a218..41d4f03 100644 --- a/lib/env/server.ts +++ b/lib/env/server.ts @@ -2,19 +2,42 @@ import "server-only"; import { z } from "zod"; import { APP_SLUG, SUPABASE_SCHEMA, clientEnv } from "./client"; -const serverSchema = z.object({ - NODE_ENV: z.enum(["development", "preview", "production", "test"]).default("development"), +const VERCEL_TO_APP_ENV = { + production: "prod", + preview: "preview", + development: "dev", +} as const; - SUPABASE_SECRET_KEY: z.string().min(1), +const serverSchema = z + .object({ + NODE_ENV: z.enum(["development", "preview", "production", "test"]).default("development"), + // Vercel sets VERCEL_ENV automatically on every deploy. Absent → local dev. + VERCEL_ENV: z.enum(["production", "preview", "development"]).optional(), - UPSTASH_REDIS_REST_URL: z.url(), - UPSTASH_REDIS_REST_TOKEN: z.string().min(1), + SUPABASE_SECRET_KEY: z.string().min(1), - QSTASH_URL: z.url().optional(), - QSTASH_TOKEN: z.string().optional(), - QSTASH_CURRENT_SIGNING_KEY: z.string().optional(), - QSTASH_NEXT_SIGNING_KEY: z.string().optional(), -}); + UPSTASH_REDIS_REST_URL: z.url(), + UPSTASH_REDIS_REST_TOKEN: z.string().min(1), + + QSTASH_URL: z.url().optional(), + QSTASH_TOKEN: z.string().optional(), + QSTASH_CURRENT_SIGNING_KEY: z.string().optional(), + QSTASH_NEXT_SIGNING_KEY: z.string().optional(), + }) + .superRefine((env, ctx) => { + // Cross-check NEXT_PUBLIC_APP_ENV against VERCEL_ENV so prod credentials + // never silently write into a dev keyspace (and vice-versa). Skipped + // locally (no VERCEL_ENV) where the developer owns their .env.local. + if (!env.VERCEL_ENV) return; + const expected = VERCEL_TO_APP_ENV[env.VERCEL_ENV]; + if (clientEnv.NEXT_PUBLIC_APP_ENV !== expected) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["NEXT_PUBLIC_APP_ENV"], + message: `VERCEL_ENV=${env.VERCEL_ENV} requires NEXT_PUBLIC_APP_ENV=${expected}, got "${clientEnv.NEXT_PUBLIC_APP_ENV}". Set NEXT_PUBLIC_APP_ENV in Vercel project settings for this environment.`, + }); + } + }); const parsed = serverSchema.safeParse(process.env); diff --git a/lib/supabase/admin.ts b/lib/supabase/admin.ts index 590d6e5..27f10d0 100644 --- a/lib/supabase/admin.ts +++ b/lib/supabase/admin.ts @@ -5,7 +5,13 @@ import { serverEnv, SUPABASE_SCHEMA } from "@/lib/env/server"; /** * Privileged Supabase client (uses the secret key, bypasses RLS on behalf of no user). * Use only for admin tasks: invites, cron sweeps, system writes. - * NEVER expose this client to the browser or pass its results through `'use cache'`. + * NEVER expose this client to the browser. + * + * `'use cache'` interaction: this factory does NOT read cookies, so it is safe + * to call inside a cached scope. But if the result depends on a caller's identity + * (user / role / tenant), the cache key MUST partition on that identity — otherwise + * one user sees another user's data. For genuinely user-agnostic reads (e.g. + * clinic settings, services list), no key partitioning is needed. */ export function createSupabaseAdminClient() { return createClient(serverEnv.NEXT_PUBLIC_SUPABASE_URL, serverEnv.SUPABASE_SECRET_KEY, { diff --git a/lib/upstash.ts b/lib/upstash.ts index d6afef0..af55843 100644 --- a/lib/upstash.ts +++ b/lib/upstash.ts @@ -7,6 +7,15 @@ import { serverEnv, redisKeyPrefix } from "@/lib/env/server"; const RATE_LIMIT_NS = "ratelimit"; const CACHE_NS = "cache"; +// Cache keys: lowercase alphanumerics, '-' for words, ':' for sub-namespacing. +// Forbids spaces, glob chars (* ? [ ]), control chars, uppercase. Matches +// the strictness of `createRateLimiter` so callers cannot accidentally write +// keys that interact with SCAN globs or pollute neighboring apps. +const KEY_RE = /^[a-z0-9][a-z0-9:-]*$/; + +// SCAN patterns allow '*' for sweeps but are otherwise constrained. +const SCAN_PATTERN_RE = /^[a-z0-9:*-]+$/; + type Json = string | number | boolean | null | { [k: string]: Json } | Json[]; const redis = new Redis({ @@ -15,12 +24,21 @@ const redis = new Redis({ }); function withPrefix(ns: string, key: string) { - if (!key || key.includes(" ")) { - throw new Error(`Invalid cache key: ${JSON.stringify(key)}`); + if (!KEY_RE.test(key)) { + throw new Error( + `Invalid cache key (lowercase + digits + '-' + ':' only, must start with alphanumeric): ${JSON.stringify(key)}`, + ); } return `${redisKeyPrefix}:${ns}:${key}`; } +function withScanPattern(ns: string, pattern: string) { + if (!SCAN_PATTERN_RE.test(pattern)) { + throw new Error(`Invalid SCAN pattern: ${JSON.stringify(pattern)}`); + } + return `${redisKeyPrefix}:${ns}:${pattern}`; +} + export const cache = { async get