Nakodo is a Next.js app with two halves. One half is 76 public pages that show the same thing to everybody, like how it works and pricing. The other half is behind a login and every row in it belongs to exactly one account.
All of the second half's access control lives in one file, src/lib/auth.ts, 87 lines including comments. Thirty-seven files import it. There are five functions that answer "may this request see this row", and the thing I find most worth writing about is what they answer when the answer is no.
The session read, which is cached per request
export const getUser = cache(async (): Promise<SessionUser | null> => {
const supabase = await createSupabase();
const { data } = await supabase.auth.getClaims();
const claims = data?.claims;
if (!claims?.sub) return null;
return { id: claims.sub, email: typeof claims.email === "string" ? claims.email : "" };
});
Two things carry their weight here. getClaims() verifies the session token rather than asking the auth server who the bearer is, so a page render does not wait on a network round trip to find out it is signed in. And cache() is React's per-request memo, not a time cache: a page whose layout, page component and three Server Components all ask for the user asks once.
Sixteen call sites use getUser directly, and they are all places where being signed out is a normal, non-error answer: the login page redirects you onward if you already have a session, the export route answers its own error shape, and the admin check wants null rather than a redirect.
requireUser, which writes
export const requireUser = cache(async (): Promise<SessionUser> => {
const user = await getUser();
if (!user) redirect("/login");
if (!(await getProfile(user.id))) await db.insert(profiles).values({ id: user.id }).onConflictDoNothing();
return user;
});
Forty-three call sites across 14 files: eight pages, four action files, the app's layout and the one route handler left over from onboarding.
The insert is the part I would not have written on purpose if I had planned it, and I would not remove it now. Sign-up creates the profile row. But an account can arrive without going through our sign-up form, through a password recovery link or through the auth provider's dashboard, and a signed-in user with no profile row is a broken app rather than an empty one. So the authorisation helper creates it. onConflictDoNothing makes the race between two parallel Server Components harmless, and getProfile is cache()d and read by the app's layout anyway, so the common case costs no extra query and only the first visit of an account's life writes.
A helper on a read path that can write is the kind of thing that deserves a comment, and it has one.
The three functions that answer 404
export const requireCampaign = cache(async (campaignId: string) => {
const user = await requireUser();
if (!isUuid(campaignId)) notFound();
const find = () =>
db.query.campaigns.findFirst({
where: and(eq(campaigns.id, campaignId), eq(campaigns.userId, user.id)),
columns: { researchPages: false },
});
let campaign = await find();
if (!campaign) notFound();
...
Forty-six call sites, 18 files. The userId predicate is in the same WHERE as the id, which is the only structural guarantee in the file: there is no code path that loads a campaign by id and then checks the owner, because the owner is part of the lookup.
When it fails, it calls notFound(). Not 403, not "this campaign belongs to someone else". A campaign id that exists but is not yours is indistinguishable from one that was never created, which means the app does not confirm the existence of other accounts' rows to anybody holding a guessed id. The admin panel does the same thing for the same reason, and its comment says so: a signed-in account that is not on the list gets a 404, not a 403, so the panel never tells you it is there.
The whole app contains seven notFound() calls in five files, and not one 403 response for a resource that belongs to another account.
isUuid is in front of the query rather than inside it because Postgres raises on a malformed uuid literal. Without that line, /campaigns/not-a-uuid would be a 500 with a database error in it; with it, it is the same 404 as a well-formed id you do not own.
You can see the outer layer of this from outside, signed out. These two paths are indistinguishable:
$ curl -sI https://nakodo.app/campaigns/00000000-0000-4000-8000-000000000000 | grep -i location
location: /login?next=%2Fcampaigns%2F00000000-0000-4000-8000-000000000000
$ curl -sI https://nakodo.app/campaigns/not-a-uuid | grep -i location
location: /login?next=%2Fcampaigns%2Fnot-a-uuid
Both 307 to the login page with the path preserved, and nothing in either response says whether the id means anything. The public pages next to them, by contrast, answer 200 to the same anonymous request, because they never read the session at all.
The third of the three, requireThread, is the exception that proves the rule about shape rather than about answers:
export async function requireThread(threadId: unknown) {
const user = await requireUser();
if (!isUuid(threadId)) return null;
const thread = await db.query.outreachThreads.findFirst({
where: and(eq(outreachThreads.id, threadId), eq(outreachThreads.userId, user.id), isNotNull(outreachThreads.campaignId)),
});
return thread ? { user, thread } : null;
}
It returns null instead of throwing, because its six call sites are Server Actions rather than pages. An action that calls notFound() renders the not-found page over a form the user was in the middle of using. Returning null lets the action answer with a message in the shape every other action failure uses. Same predicate, same guarantee, different failure type, and the choice is decided entirely by who is calling.
The column that is excluded by name
columns: { researchPages: false },
researchPages is a jsonb column holding the text of the pages we read from the customer's own website when the campaign was created. It can be tens of kilobytes. Forty-six call sites in 18 files load a campaign; eight of them need that text.
So the default lookup excludes the column, and there is a second function, requireCampaignWithResearch, which is the same check plus a second query for just that one column:
export const requireCampaignWithResearch = cache(async (campaignId: string) => {
const { user, campaign } = await requireCampaign(campaignId);
const row = await db.query.campaigns.findFirst({ where: eq(campaigns.id, campaignId), columns: { researchPages: true } });
return { user, campaign: { ...campaign, researchPages: row?.researchPages ?? [] } };
});
Eight call sites. Two queries for the three screens that need the text, one slim query for the other 38 call sites. The alternative, one query with the column included everywhere, is one line shorter and moves the text out of the database on every page view in the app.
The eight lines I would test first
export function safeNext(value: unknown, fallback = "/campaigns"): string {
const next = typeof value === "string" ? value : "";
return next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : fallback;
}
This is what reads the ?next= you saw in the curl output above, and it is the only function in the file that a hostile URL reaches directly. Eight call sites. //evil.example is a protocol-relative URL that browsers will happily treat as another origin, and /\evil.example is the same trick with the slash the other way round, which some URL parsers normalise. Both are rejected, and anything that is not a string becomes the fallback.
The repo has 62 test files and 623 tests. There is no auth.test.ts. The three string prefixes above are the single most testable thing in the file and the one piece of it with no test, which is a bad ratio when the failure mode is an open redirect.
One thing in here that surprises me every time I read it
requireCampaign does not end at the 404. It also recovers a stuck campaign:
if (researchIsStale(campaign) && (await recoverStaleResearch(user.id, campaignId)) > 0) campaign = (await find())!;
A campaign whose website research has been in the "researching" state for more than three minutes is treated as dead, and the recovery is a single UPDATE with three case when expressions that puts it back to draft, failed or paused depending on how far it got. Opening the page is what triggers it.
I understand why it is here. The person most likely to notice a stuck campaign is the person looking at it, and they are already holding a database connection. But an authorisation helper is a strange home for a state machine repair, and the cost is that every page in the campaign section can now write before it renders.
If I split this file, the split would not be along the lines of "auth" and "not auth". It would be requireX as the question, and a separate recover call that pages make deliberately, so that the file that answers who you are does nothing else.
The counts, in one place
| Function | Call sites | What it answers when no |
|---|---|---|
getUser |
16 | null |
getProfile |
13 | undefined |
requireUser |
43 | redirect to /login
|
requireCampaign |
46 | notFound() |
requireCampaignWithResearch |
8 | notFound() |
requireThread |
6 | null |
safeNext |
8 | /campaigns |
isUuid |
11 | false |
Eighty-seven lines, 151 call sites, and the only design decision in it that I would defend in an argument is the one that looks like a mistake in a log: somebody else's row is missing, not forbidden.
The public half of the app, where none of this runs, is the part you can go and poke at: everything in the sitemap answers 200 to an anonymous request, and what the app does with the rows behind the login is described on how it works and in the privacy notice.
Top comments (0)