DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A missing environment variable should make our app useless, not open

Munchable is an Expo app: a native Android build, and the same code served by react-native-web at app.munchable.app. It asks three questions about whoever is holding the phone.

  1. Is there a session at all?
  2. Is it a real account, or the anonymous one a device can mint for itself?
  3. Does it hold Premium?

Each of those can be answered wrongly, and for each one the two possible wrong answers cost very different amounts. This post is about writing the code so the cheap mistake is the one that happens.

The constant that refuses to trust its own configuration

/**
 * An account is required whenever a backend is configured, and ALWAYS in a
 * production build, so a mis-built production app (missing Supabase keys) fails
 * closed to the sign-in screen instead of silently opening. Local dev without
 * keys stays open for convenience.
 */
export const REQUIRE_ACCOUNT =
  isSupabaseConfigured || process.env.EXPO_PUBLIC_ENV === 'production';
Enter fullscreen mode Exit fullscreen mode

The first clause is the obvious one. The second is the one that matters.

Think about what a missing auth key does to a gate written the obvious way. No keys means no auth client, which means every check of "is there a session" answers no, which means a gate that says "require an account when we can check for one" opens for everybody. One forgotten environment variable in a release build, and the account requirement is gone from the whole app, with nothing failing and nothing in the logs.

Hardcoding the production environment into the condition means that build cannot open. It is useless instead, which is a bug report arriving in minutes rather than a hole nobody notices.

Gating a layout is not gating a route

Our main group gates itself at the layout, which covers the hub, profile, history, search and recipes:

if (REQUIRE_ACCOUNT && !restored) return null;
if (REQUIRE_ACCOUNT && !account) return <Redirect href="/(auth)/sign-in" />;
Enter fullscreen mode Exit fullscreen mode

Four screens sit outside that group, because they are modals presented over it: label capture, the menu scanner, the paywall and the result sheet. A layout cannot gate a sibling. And these are not screens nobody can reach: expo-router makes every one of them a URL, both as a munchable:// deep link on the device and as a plain path on the web.

So they gate individually, with a hook that returns an element rather than a boolean:

export function useAccountGate(): React.ReactElement | null {
  const account = useAuth((s) => s.account);
  const restored = useAuth((s) => s.restored);
  if (!REQUIRE_ACCOUNT) return null;
  if (!restored) return <></>;
  if (!account) return <Redirect href="/(auth)/sign-in" />;
  return null;
}
Enter fullscreen mode Exit fullscreen mode

At the top of each of those four screens, after the other hooks:

const gate = useAccountGate();
if (gate) return gate;
Enter fullscreen mode Exit fullscreen mode

Two things about that shape. Returning an element means the caller cannot forget what to do with the answer: a boolean invites if (!allowed) return null, which renders a blank screen and leaves the user staring at it. And the <></> in the middle is the state people skip, the half second where the session is still being read out of secure storage. Treating "not yet known" as "signed out" would flash the sign-in screen at a signed-in user every cold start. It is the same discipline as deciding which facts are allowed to hold the splash screen: "unknown" is a third answer, and it renders nothing rather than guessing.

You can watch it work in a browser. Open app.munchable.app/capture signed out and you land on /sign-in, which is the web spelling of the same deep link a stranger could send to a phone.

Premium is the server's answer, and an error is not an answer

The subscription lives on our server, where Stripe's webhooks land. The client holds a reflection of it:

refresh: async () => {
  set({ loading: true });
  try {
    const ent = await fetchEntitlement();
    set({ isPremium: ent.isPremium, plan: ent.plan, status: ent.status, renewsAt: ent.renewsAt, loading: false });
    useProfile.getState().setPremium(ent.isPremium);
  } catch {
    // Never grant Premium on error; keep prior state, drop the spinner.
    set({ loading: false });
  }
},
Enter fullscreen mode Exit fullscreen mode

That catch block is three decisions in one line.

It does not grant. A failed request is not evidence of a subscription, and an app that unlocks when the network is bad has an exploit, not a fallback.

It does not revoke either. A paying subscriber on a train should not be demoted because one request timed out. The last known answer stands, which is the honest thing to do with a value we cannot currently check.

And it drops the spinner, because a UI stuck in loading forever is its own failure. Three different wrong behaviours, all avoided by writing down what "we do not know right now" means before writing the happy path.

The anonymous case is handled before any request happens:

// Only a real (non-anonymous) account can hold a subscription; the anonymous
// device session is always free.
const account = Boolean(session) && !session?.user.is_anonymous;
if (!account) {
  applyFree(set);
  return;
}
Enter fullscreen mode Exit fullscreen mode

An anonymous id is free to mint and carries no verified address, so it can never be the subject of an entitlement. Signing out resets to free rather than leaving the previous account's state behind, which matters on a shared phone.

The duplicated flag, and why it is deliberate

isPremium exists in two stores: the entitlement store that fetches it, and the on-device profile store that the scan gate and paywall UI read. That is a copy, and copies drift.

It stays because the write is one-directional and there is exactly one writer. The entitlement store mirrors into the profile store, on every path including the reset, and nothing else in the app sets that field. A single writer plus a cheap mirror turned out to be a smaller change than rewiring every consumer, and the invariant is small enough to state in one sentence, which is the test of whether a duplication is acceptable.

The pattern, in one line

For every identity question, write the uncertain answer first. "No keys" is not "no gate". "Request failed" is not "free" and not "Premium". "Session not restored yet" is not "signed out". Each of those is a third value, and giving it its own branch is most of what makes the app safe to ship.

Try it yourself: app.munchable.app runs the whole onboarding with no account, then asks you to sign in when you want to scan. The conditions, allergies and preferences you picked stay on the device either way, which is the other half of the design and is spelled out at munchable.app/privacy.

Top comments (0)