Here is the entire role system of a product that bills real money, as it appears in the schema:
role: text('role').notNull().default('host'), // 'owner' | 'host' A text column. Not an enum, no check constraint, with a comment carrying the only statement anywhere in the database of what the legal values are. Two words, one of which is the default.
I want to defend about half of that and then show you the part that is genuinely odd, which is that this column is almost never what decides whether you are allowed to do something.
What the two roles mean
A venue is a pub. The owner is the account that signed up and holds the card. Hosts are people invited by email who can run quiz nights but cannot touch billing: the person who covers Tuesdays, the manager who is not the owner, the quizmaster working several rooms. Our hosts and venues page puts it in the terms a landlord would: it is about not sharing a password with the three people who cover Tuesdays. Pro is one account, Ultimate is up to five.
Two roles is not an under-designed version of a real RBAC system. It is the whole domain. There is no "can edit packs but not run sessions" person in a pub, and inventing a permission matrix for one would be inventing users.
So: two values, and the comment is the spec. What I would change about the schema line is small and real. text with a comment permits 'Owner', 'hosts', 'manager' and the empty string, and Postgres will take all four happily. An enum or a check constraint costs one line and turns "this is documented" into "this is impossible". The reason it is still text is the reason it usually is: the column was written before there was a second value to put in it.
The check that actually runs
Here is what guards the invite action:
// Only the owner can invite if (venue.owner_user_id !== user.id) { return { success: false, error: 'Only the venue owner can invite team members.' } } Not role === 'owner'. The authority is venues.owner_user_id, a column on a different table, compared against the signed-in user. The same two lines appear in the revoke action with a different message. Those two actions are the complete set of owner-gated checks in the product, plus one boolean in the settings page that decides whether the team panel renders at all. Billing is gated by Stripe for us, since the subscription hangs off the customer who bought it.
Which means we have two representations of "this person owns this venue", written in two places at signup:
const [venue] = await db.insert(venuesTable).values({ owner_user_id: signUpData.user.id, // ... }).returning({ id: venuesTable.id }) await db.insert(venueMembersTable).values({ venue_id: venue.id, user_id: signUpData.user.id, invited_email: signUpData.user.email!, role: 'owner', status: 'active', invited_by: signUpData.user.id, }) And again, nearly character for character, in the OAuth callback for people who sign up with Google. Two paths, both of which must write both facts.
Is that duplication a bug? Not today, and I want to be precise about why, because "two sources of truth" is the kind of phrase that gets repeated without the second half. They cannot disagree in a way that grants access, because only one of them is ever consulted for a decision. role = 'owner' is used for three things: showing a badge in the team list, refusing to let the owner's own row be revoked, and excluding the owner when a plan downgrade culls seats. All three are about presentation and safety, none of them about permission.
The risk is the reverse direction. If the membership row were missing or its role were wrong, the owner would keep every power they have and merely look wrong on the settings page. If owner_user_id were wrong, they would lose the venue. So the fragile fact is the one that lives in a column with a foreign key and a NOT NULL on it, and the decorative one is the free text. That is the right way round, by accident rather than design.
The helper that deliberately does not care
Every signed-in page starts the same way:
export const getUserVenue = cache(async (userId?: string) => { const uid = userId ?? (await getAuthenticatedUser()).id // 1. Fast path: user is the owner of a venue // 2. Membership path: user was invited to a venue as a host }) It answers "which venue is this person working in" and it never looks at the role. Owners and hosts get the identical object, because for everything a quiz night consists of, launching questions, closing them, building packs, reading history, they have identical powers. The role only reappears at the three gates above.
I have come round to liking this shape. The alternative, a getUserVenueAndRole returning a capability bag that every page then has to interpret, spreads the decision over sixty call sites to protect three of them. Keeping the common helper role-blind means a new page cannot accidentally leak an owner-only action, because a new page is not handed anything role-shaped to leak.
The cost is that the three gates are copy-pasted comparisons rather than one function. If there is ever a fourth, it becomes a requireOwner(venue, user) helper. Three is the number where I would rather have the check visible in the action than hidden behind a name.
Seats, and the webhook that revokes people
The seat limit is counted at invite time, and pending invites hold a seat:
const currentSeats = Number(countRow?.count ?? 0) const limits = getPlanLimits((venue.plan_type ?? 'trial') as PlanType) if (currentSeats >= limits.maxAccounts) { return { success: false, error: `Your ${planLabel(...)} plan allows up to ${limits.maxAccounts} account...` } } Counting pending invites against the limit is the small decision I would defend hardest here. Without it, an owner on a one seat plan can send five invites, and the product has made five promises it cannot keep, to five people who do not know each other.
The more consequential piece is what happens on the way down. A plan change or a cancellation reaches us as a Stripe webhook, and the handler enforces the new limit by revoking excess hosts, oldest first:
const excess = members.length - (newLimit - 1) // -1 to account for the owner seat if (excess <= 0) return That is a billing event changing an authorization fact. Somebody's access to their workplace's quiz account ends because a card failed in another country, and the order of the cull is created_at ascending, meaning the host who has been there longest is the first one out.
I do not have a better rule. Newest-first punishes the person who just accepted an invite, and asking the owner to choose means leaving the account over its limit until they answer, which is the one thing a seat limit cannot do. What it clearly deserves and does not have is an email to the person being revoked. Right now the first they learn of it is a screen that no longer has their venue in it.
What is worth copying from this
Three things, none of which are about roles specifically:
- Be clear which fact is load-bearing. Duplicating a fact is survivable when exactly one copy makes decisions and the other is decoration. Write down which is which, because the next person cannot tell by looking.
- Put the enum in the database when it is cheap. A comment that lists legal values is a comment. A check constraint is a guarantee, and the difference is one line.
- Watch for authorization changing from outside the app. The code path that revokes a host is not in the settings page where a human would look for it. It is in a webhook handler, triggered by an event from a payment processor, and it is the only place in the product where access is taken away without anybody in the building asking for it.
If you want to see what the two roles are actually for from the outside, the plan difference is on our pricing page, and the case the revoke action exists for is spelled out on our page for freelance quizmasters: each host gets their own login rather than sharing yours, which matters when one of them stops working for you. Everything above exists to make those two sentences of copy true.


