Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -112,6 +124,7 @@ marketing site rebrand.
- Invalid nested `<a>` 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
Expand Down
89 changes: 89 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -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<User | null> => {
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://<project-ref>.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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "binarysemaphore",
"version": "0.1.4",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
12 changes: 11 additions & 1 deletion src/app/resume/(app)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -15,13 +21,17 @@ 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}`);
}

/** 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);
Expand Down
50 changes: 40 additions & 10 deletions src/app/resume/(app)/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<div className="mx-auto w-full max-w-5xl px-5 py-12">
Expand All @@ -47,21 +54,44 @@ export default async function ResumeHome() {
{/* Your resumes */}
<section className="mt-12">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-mono text-xs uppercase tracking-[0.2em] text-[color:var(--rx-muted)]">
<h2 className="flex items-center gap-2 font-mono text-xs uppercase tracking-[0.2em] text-[color:var(--rx-muted)]">
Your resumes
{user ? (
<span className="normal-case tracking-normal text-subtle">
({resumes.length}/{MAX_RESUMES})
</span>
) : null}
</h2>
{user ? (
<form action={createResumeAction}>
<SubmitButton
className="rx-pill rx-accent font-mono text-xs"
pendingLabel="creating…"
atLimit ? (
<span
className="rx-pill cursor-not-allowed font-mono text-xs opacity-60"
title={RESUME_LIMIT_MESSAGE}
>
+ new resume
</SubmitButton>
</form>
limit reached
</span>
) : (
<form action={createResumeAction}>
<SubmitButton
className="rx-pill rx-accent font-mono text-xs"
pendingLabel="creating…"
>
+ new resume
</SubmitButton>
</form>
)
) : null}
</div>

{user && (limitHit || atLimit) ? (
<div
role={limitHit ? "alert" : undefined}
className="mb-3 rounded-xl border border-amber-300/60 bg-amber-50 px-4 py-2.5 font-mono text-xs text-amber-800 dark:border-amber-400/20 dark:bg-amber-400/10 dark:text-amber-200"
>
{RESUME_LIMIT_MESSAGE}
</div>
) : null}

{!user ? (
<div className="rounded-xl border border-black/10 bg-white/70 p-10 text-center dark:border-white/10 dark:bg-white/[0.04]">
<p className="text-sm text-[color:var(--rx-muted)]">
Expand Down
35 changes: 35 additions & 0 deletions src/lib/resume/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<number> {
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<ResumeSummary[]> {
const supabase = await createClient();
Expand Down Expand Up @@ -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({
Expand Down