DEV Community

Daniel Pertu
Daniel Pertu

Posted on

48 of our functions are POST endpoints, and the three that read the host page are deliberately not

PubTrivia has 11 files with a use server directive at the top and 48 exported functions inside them. Thirteen of those 48 only read. And then there is one more file of reads, sitting next to the page that uses them, that is deliberately not one of the 11.

Its header comment is the shortest useful thing in the repository:

/**
 * Server-side reads for the host page.
 *
 * Deliberately not a "use server" file: these are called from the page's
 * Server Component only, and must not be reachable as POST endpoints.
 */
Enter fullscreen mode Exit fullscreen mode

What the directive actually does

It is worth being blunt about this, because the mental model that gets people into trouble is "a Server Action is a function I call from the client".

A Server Action is an HTTP endpoint. Adding use server to a file gives every exported function in it a stable id, and that id is a POST target on your domain. The client reference that React hands your component is a URL plus an id, and it is in the page the user downloaded. Nothing about the mechanism checks who is calling. Next.js gives you the transport. The authorization is yours, every time, in every function.

Which is why those 48 exports start the same way:

export async function getSessionHistory() {
    const { venue } = await getUserVenueOrThrow()
    // ...
}

export async function getQuestionPacks() {
    await getAuthenticatedUser() // auth guard, must be logged in
    return getQuestionPacksCached()
}
Enter fullscreen mode Exit fullscreen mode

Some guard harder. The pack writing functions call an assertProSubscription() helper that throws if the venue is not on a paid plan, because the plan limit is not a UI concern and a button that is not rendered is not a permission system.

That is 48 opportunities to forget, and the guard has to be the first thing in the body, because anything above it runs for a stranger.

So what is the alternative

For a read that is only ever called while a Server Component renders, the alternative is: do not make it an endpoint.

import { and, asc, eq, inArray } from 'drizzle-orm'
import { db } from '@/utils/db/db'

export async function getActiveSession(venueId: string) { /* ... */ }
export async function getVenueSession(sessionId: string, venueId: string) { /* ... */ }
export async function getLobbyParticipants(sessionId: string) { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

No directive. A plain module. It is imported by page.tsx and runs inside the render, and because it has no use server there is no action id, no POST target, and no way to reach it other than by rendering the page. The page has already done the authentication:

export default async function HostPage({ searchParams }) {
    const user = await getAuthenticatedUser()
    const [venue, packs] = await Promise.all([getUserVenue(user.id), getQuestionPacks()])
Enter fullscreen mode Exit fullscreen mode

The guard moves from three functions to one page, and it cannot be skipped, because reaching those reads requires having already passed it.

Three functions is not a large saving in lines. The saving is in the number of places the question "who is calling this" has to be answered.

The parameter that is the authorization

getVenueSession takes two ids, and the second one is the entire access check:

export async function getVenueSession(sessionId: string, venueId: string) {
    const [row] = await db
        .select()
        .from(gameSessionsTable)
        .where(and(
            eq(gameSessionsTable.id, sessionId),
            eq(gameSessionsTable.venue_id, venueId),
        ))
        .limit(1)
    return row ?? null
}
Enter fullscreen mode Exit fullscreen mode

The host page reads a session id out of the query string:

const requested =
    venue && params.session && isUuid(params.session)
        ? await getVenueSession(params.session, venue.id)
        : null
Enter fullscreen mode Exit fullscreen mode

So the id is attacker controlled by definition. Somebody pastes ?session= plus a uuid they got from somewhere, and the only thing between them and another venue's live quiz is that second eq in the where.

Note what is not here: a fetch followed by if (row.venue_id !== venue.id) return null. Both are correct. Only one of them makes "not yours" and "does not exist" the same outcome, returns a single row, and cannot be broken by somebody later adding an early return between the fetch and the check. Authorization belongs in the where clause when it can possibly fit there.

The elsewhere-in-the-codebase version of the same decision, for comparison, is in the pack reads, where ownership is checked after the select because the rule has an or in it:

const [pack] = await db.select().from(questionPacksTable).where(eq(questionPacksTable.id, packId)).limit(1)
if (!pack) return null
// Only owner or public packs
if (!pack.is_public && pack.created_by !== user.id) return null
Enter fullscreen mode Exit fullscreen mode

That one cannot be a single eq, and so it pays the cost of being a check rather than a filter.

Why 13 reads are still endpoints

The rule is not "reads go in a queries file". Thirteen read functions are server actions on purpose, and the deciding question is who calls them.

A client component cannot import a plain server module. So the moment a read is triggered by something a user clicks rather than by a page render, it must be an endpoint, and then it must carry its own guard. Session history is the clearest case. The list renders on the server, and the detail read is handed to the client component as a prop:

<SessionHistoryList sessions={sessions} fetchDetail={getSessionDetail} />
Enter fullscreen mode Exit fullscreen mode

Clicking a row opens a modal, the modal calls fetchDetail, and that call is a POST from the browser. So getSessionDetail starts with getUserVenueOrThrow() and scopes every query by the venue it returns, exactly like the read that rendered the list it was clicked from.

Written as a rule: a read that only a Server Component calls should not be an action, and a read that a client component calls has no choice. The file boundary is how the codebase remembers which is which.

The cheap part

The page above calls getAuthenticatedUser(), and so does the layout above it, and so does every action the page's buttons will later invoke. That is potentially several network round trips to the auth provider per render, so the function is wrapped:

export const getAuthenticatedUser = cache(async () => {
    const supabase = await createClient()
    const { data: { user }, error } = await supabase.auth.getUser()
    if (error || !user) redirect('/login')
    return user
})
Enter fullscreen mode Exit fullscreen mode

React's cache deduplicates within a single request. Layout, page and any action in the same render tree share one call, and the guard stays cheap enough that nobody is ever tempted to skip it "just here, because the layout already did it".

The redirect inside it is load bearing too. The guard does not return a nullable user that 48 call sites then have to handle. It either returns a user or it stops being a render.

Where this shows up for a customer

Venue scoping is not theoretical for us, because an account can have more than one person allowed to host, which is the whole of the multi host feature. Two hosts, one venue, two phones, and a URL that carries a session id. The reason one of them cannot open the other venue's quiz by changing a query parameter is three functions that take a venue id and a page that does not let anybody reach them without one.

11 files, 48 endpoints, 1 file of plain functions. The directive is not a decoration on a file of server code. It is the line where your code becomes a URL.

Top comments (0)