diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2d68a..9e343e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every release corresponds to a `staging` to `main` pull request and a matching `vX.Y.Z` tag on `main`. +## [0.2.0] - 2026-07-01 + +### Added + +- Per-user resume limit: each account can create at most 3 resumes. Enforced + server-side in `createResume` (RLS-scoped count) and surfaced in the hub, which + shows the count (n/3), disables "+ new resume" at the cap, and explains the + limit with a banner (including when a template "use" was blocked). +- `docs/auth.md` documenting the authentication and authorization architecture + (Supabase Auth + `@supabase/ssr` cookies + `getUser()` validation + Postgres + Row-Level Security). + ## [0.1.4] - 2026-07-01 ### Fixed @@ -112,6 +124,7 @@ marketing site rebrand. - Invalid nested `` in template cards (hydration error). - Tolerate a missing `text_align` column before the migration runs. +[0.2.0]: https://github.com/BiSemaphore/binarysemaphore.com/releases/tag/v0.2.0 [0.1.4]: https://github.com/BiSemaphore/binarysemaphore.com/releases/tag/v0.1.4 [0.1.3]: https://github.com/BiSemaphore/binarysemaphore.com/releases/tag/v0.1.3 [0.1.2]: https://github.com/BiSemaphore/binarysemaphore.com/releases/tag/v0.1.2 diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..e5f41e4 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,89 @@ +# Authentication & authorization + +How sign-in and per-user data access work across this app (the marketing site +plus the `resume.binarysemaphore.com` product). Short version: we use **Supabase +Auth** as the identity provider, the official `@supabase/ssr` package for +cookie-based sessions in the Next.js App Router, and **Postgres Row-Level +Security (RLS)** for authorization. There is no bespoke auth code to trust; the +security lives in Supabase and the database, not in application logic. + +## The pieces + +- **Supabase Auth (GoTrue)** — issues and validates JWTs, runs the GitHub/Google + OAuth flows, and manages refresh tokens. This is our equivalent of + NextAuth/Auth0/Clerk. We don't hand-roll password hashing, token signing, or + session storage. +- **`@supabase/ssr`** — the official SSR integration. It stores the session in + **httpOnly cookies** (not readable by browser JS) and gives us server/browser + Supabase clients that read and refresh those cookies. See + `src/utils/supabase/server.ts`, `client.ts`, and `middleware.ts`. +- **Middleware (`src/proxy.ts` → `updateSession`)** — runs on matching requests, + calls `supabase.auth.getUser()` to keep the session fresh and rotate refresh + tokens, and forwards the updated cookies. It no-ops when Supabase env vars are + absent, so the site still builds and renders signed-out. +- **RLS policies (`supabase/schema.sql`)** — every `resumes` row is owned by a + `user_id`, and policies restrict select/insert/update/delete to + `auth.uid() = user_id`. Authorization is enforced by Postgres, so a bug in app + code cannot leak another user's data. + +## `getCurrentUser()` and why it's in a layout + +`src/utils/supabase/auth.ts` exports: + +```ts +export const getCurrentUser = cache(async (): Promise => { + if (!isSupabaseConfigured()) return null; + const supabase = await createClient(); + const { data } = await supabase.auth.getUser(); + return data.user; +}); +``` + +Two things matter here: + +1. **We use `getUser()`, not `getSession()`.** `getSession()` only decodes the + cookie locally and is not trustworthy on the server (a cookie can be forged). + `getUser()` sends the token to the Supabase Auth server and **validates it** + before returning. Every server-side identity read in this repo uses + `getUser()` — the auth wrapper, the middleware, and the data layer + (`src/lib/resume/db.ts`). This is the pattern Supabase and Vercel document as + the correct, secure approach for the App Router. +2. **It's wrapped in React `cache`.** A single request (e.g. the resume layout + rendering the header plus the page reading the user) shares one validated + `getUser()` call instead of hitting the auth server multiple times. + +So seeing `const user = await getCurrentUser()` in a layout is the right thing: +it renders the header's signed-in/out state from a validated user, deduped per +request. + +## The request lifecycle (signed-in user loading a resume) + +1. Browser sends its Supabase auth cookies with the request. +2. Middleware (`proxy.ts`) refreshes the session via `getUser()` and forwards + fresh cookies. +3. The Server Component (page/layout) calls `getCurrentUser()`, which validates + the JWT with Supabase and returns the user (or `null` → redirect to + `/login`). +4. Data access in `src/lib/resume/db.ts` runs queries as that user. **RLS** on + `public.resumes` guarantees the query can only ever see or modify that user's + rows, regardless of what the app code asks for. + +## Defense in depth + +Authorization does **not** depend on application code remembering to filter by +user. Even if a query in `db.ts` forgot its scoping, the RLS policies would still +return only the caller's rows. App code scopes queries for clarity and +efficiency; the database enforces the security boundary. That is stricter than +many NextAuth-style setups, where authorization lives entirely in app code. + +## Configuration + +- Env vars: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` + (safe in the browser). Set in `.env.local` and in the Vercel project env. +- OAuth providers (GitHub, Google) are enabled in the **hosted Supabase + dashboard** (Authentication → Providers), with each provider's callback set to + `https://.supabase.co/auth/v1/callback`. The app's own callback + route is `src/app/auth/callback/route.ts`, which exchanges the OAuth code for a + session. Local dev can enable providers via `supabase/config.toml`. +- Redirect allowlist (Authentication → URL Configuration) must include the apex + and the `resume` subdomain so the OAuth/magic-link callback lands correctly. diff --git a/package.json b/package.json index 4b5f382..7522f76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "binarysemaphore", - "version": "0.1.4", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/resume/(app)/actions.ts b/src/app/resume/(app)/actions.ts index 46fd358..b539d9c 100644 --- a/src/app/resume/(app)/actions.ts +++ b/src/app/resume/(app)/actions.ts @@ -3,7 +3,13 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { getCurrentUser } from "@/utils/supabase/auth"; -import { createResume, deleteResume, updateResume } from "@/lib/resume/db"; +import { + MAX_RESUMES, + countResumes, + createResume, + deleteResume, + updateResume, +} from "@/lib/resume/db"; import { DEFAULT_TEMPLATE, isTemplateId } from "@/lib/resume/schema"; async function requireUser() { @@ -15,6 +21,9 @@ async function requireUser() { /** Create a blank resume and open it in the editor. */ export async function createResumeAction() { await requireUser(); + // Send the user back to the hub with a banner instead of an error page when + // they're already at the cap. createResume also enforces this server-side. + if ((await countResumes()) >= MAX_RESUMES) redirect("/?limit=1"); const id = await createResume(); redirect(`/editor/${id}`); } @@ -22,6 +31,7 @@ export async function createResumeAction() { /** Create a resume from a chosen template and open it in the editor. */ export async function useTemplateAction(formData: FormData) { await requireUser(); + if ((await countResumes()) >= MAX_RESUMES) redirect("/?limit=1"); const tpl = String(formData.get("templateId") ?? ""); const templateId = isTemplateId(tpl) ? tpl : DEFAULT_TEMPLATE; const id = await createResume(undefined, templateId); diff --git a/src/app/resume/(app)/page.tsx b/src/app/resume/(app)/page.tsx index 4c9959c..dbbbb25 100644 --- a/src/app/resume/(app)/page.tsx +++ b/src/app/resume/(app)/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { getCurrentUser } from "@/utils/supabase/auth"; -import { listResumes } from "@/lib/resume/db"; +import { MAX_RESUMES, RESUME_LIMIT_MESSAGE, listResumes } from "@/lib/resume/db"; import { TEMPLATES } from "@/lib/resume/schema"; import { TemplateCard } from "@/components/resume/template-card"; import { SubmitButton } from "@/components/resume/submit-button"; @@ -24,10 +24,17 @@ function formatDate(iso: string): string { }); } -export default async function ResumeHome() { +export default async function ResumeHome({ + searchParams, +}: { + searchParams: Promise<{ limit?: string }>; +}) { const user = await getCurrentUser(); const resumes = user ? await listResumes() : []; const featured = TEMPLATES.slice(0, 6); + const atLimit = resumes.length >= MAX_RESUMES; + // Set when a create attempt was blocked by the cap (e.g. from a template). + const limitHit = (await searchParams).limit === "1"; return (
@@ -47,21 +54,44 @@ export default async function ResumeHome() { {/* Your resumes */}
-

+

Your resumes + {user ? ( + + ({resumes.length}/{MAX_RESUMES}) + + ) : null}

{user ? ( -
- - + new resume - -
+ limit reached + + ) : ( +
+ + + new resume + +
+ ) ) : null}
+ {user && (limitHit || atLimit) ? ( +
+ {RESUME_LIMIT_MESSAGE} +
+ ) : null} + {!user ? (

diff --git a/src/lib/resume/db.ts b/src/lib/resume/db.ts index eb9f836..0a44382 100644 --- a/src/lib/resume/db.ts +++ b/src/lib/resume/db.ts @@ -27,6 +27,22 @@ import { /** Upper bound on a resume's serialized content (~256KB) to keep rows sane. */ const MAX_CONTENT_BYTES = 256 * 1024; +/** Max resumes a single user may own. Enforced in createResume (authoritative) + * and surfaced in the UI so the create button disables at the cap. */ +export const MAX_RESUMES = 3; + +/** Human-readable message shown when the per-user resume cap is hit. */ +export const RESUME_LIMIT_MESSAGE = `You've reached the limit of ${MAX_RESUMES} resumes. Delete one to create another.`; + +/** Thrown by createResume when the user is already at MAX_RESUMES, so callers + * can distinguish the cap from other failures and show a friendly message. */ +export class ResumeLimitError extends Error { + constructor() { + super(RESUME_LIMIT_MESSAGE); + this.name = "ResumeLimitError"; + } +} + export type Resume = { id: string; title: string; @@ -78,6 +94,16 @@ function toResume(row: Row): Resume { }; } +/** How many resumes the current user owns (RLS scopes the count to them). */ +export async function countResumes(): Promise { + const supabase = await createClient(); + const { count, error } = await supabase + .from("resumes") + .select("id", { count: "exact", head: true }); + if (error) throw error; + return count ?? 0; +} + /** The current user's resumes, newest first. */ export async function listResumes(): Promise { const supabase = await createClient(); @@ -128,6 +154,15 @@ export async function createResume( } = await supabase.auth.getUser(); if (!user) throw new Error("Not authenticated"); + // Authoritative per-user cap. RLS scopes this count to the current user, so it + // holds even if a caller skips the UI guard. (A concurrent double-create could + // still slip one over the cap; acceptable for a soft limit.) + const { count, error: countError } = await supabase + .from("resumes") + .select("id", { count: "exact", head: true }); + if (countError) throw countError; + if ((count ?? 0) >= MAX_RESUMES) throw new ResumeLimitError(); + const { data, error } = await supabase .from("resumes") .insert({