Writing

Guards that throw, because a forgotten if fails open

5 min read

Every route handler under app/api/ in the DevPilot platform calls exactly one authorization guard as its first statement, and every one of those guards throws. None of them returns a boolean, an error object, or a discriminated union. That is a deliberate choice, and it exists because the alternative already failed here once.

The shape of the failure

An earlier version of the bridge API checked authorization the way most code does — by asking, and then acting on the answer:

const ctx = await getOrgContext(orgId)
if (!ctx) return NextResponse.json({ error: 'not found' }, { status: 404 })
// ... handler

This is fine when it is there. The problem is what happens when it is not.

A handler written in a hurry, or copied from a sibling route and edited, or added during a refactor that moved the check one layer up and then moved it back — any of those produces a route with no if. And a route with no if does not fail. It succeeds, for everybody, and returns another tenant's data with a 200.

That is the defining property of a check that returns: omitting it is indistinguishable from passing it. There is no signal. The tests pass, because the tests exercise the authorized path. The types check, because a discarded return value is legal TypeScript. Code review has to notice an absence, which is the thing human review is worst at.

Making the omission impossible

The guards throw instead:

export async function requireSession(): Promise<SessionCtx> {
  const supabase = await createClient()
  const { data, error } = await supabase.auth.getUser()
  if (error || !data?.user) throw unauthenticated()
  return { userId: data.user.id, email: data.user.email ?? '' }
}

The signature returns SessionCtx, not SessionCtx | null. There is no falsy branch to forget, because there is no falsy value. A handler that needs the user id has to call this, and calling it is the check.

The distinction is small in code and total in consequence. With a returning check, safety is a property of the caller remembering. With a throwing check, safety is a property of the value existing. You cannot get the userId without having been authenticated, in the same way you cannot read a field off a value you never obtained.

This is the same move as requiring a transaction handle for an enqueue, which is how the dispatch path made a lost-write window unrepresentable rather than merely discouraged. Both replace "remember to" with "cannot express otherwise".

Non-membership is 404, never 403

The second decision in that file is smaller and gets argued about more:

// Not a member => 404. Do not distinguish "org does not exist" from
// "you are not in it".
if (rows.length === 0) throw notFound()

A 403 is the semantically correct status. It also confirms the resource exists.

Given an endpoint that returns 403 for organizations you are not in and 404 for organizations that do not exist, anyone with a valid session can enumerate which organization ids are real. On a platform where the identifier is a cuid2 that is not a catastrophe, but it is free information about a customer's existence, handed to an unauthenticated-for-this-resource caller, forever.

Returning 404 costs one thing: a developer debugging a genuine membership problem sees "not found" and briefly suspects a bad id. That is a worse debugging experience for a handful of people and a strictly smaller information surface for everyone. We took the trade.

The rule generalises: an error response should not answer a question the caller was not authorized to ask. "Does this org exist" is such a question.

Proving the guards are actually there

A guard that throws is only useful on routes that call it, so "every handler calls exactly one" needs to be enforced rather than asserted. A CI gate walks app/api/, parses each route module, and fails the build on any exported handler whose first statement is not a guard call — with a small allowlist for the three genuinely public routes.

That last part matters more than the rule. The allowlist is what makes the gate survivable: without it, the Linear webhook and the cron endpoint would fail forever, someone would add || true, and the gate would join the large family of checks that are green regardless of outcome. An exception you can name is much safer than a rule you cannot keep.

The webhook is worth noting as the case that proves the shape. It cannot use a session guard — Linear does not send a bearer token — so its authentication is an HMAC signature over the raw request body, and the signature is mandatory. An earlier version read if (signature && workspace.webhookSecret), which meant omitting the header skipped verification entirely: anyone who learned a Linear organization id could forge a dispatch. A missing header and a missing stored secret are each a 401 now. Same disease, same cure — there is no path that writes without verifying.

What this does not solve

Guards establish who is calling and which tenant they may act within. They say nothing about whether a particular column of that tenant's data should reach a browser. That is a different failure with a different fix, which is the encryption and column-grant story.

And none of this helps if a guard is called with the wrong org id — passing params.orgId when you meant the session's own org is a bug the type system cannot see, because both are strings. That one is still on us.