A venue owner on pub-trivia.app can invite someone else to host quiz nights for them. The whole feature is one email with one button in it, and that button is a link to /auth/invite?token=....
Which means the feature is a GET request that changes state, sent to a person who may not have an account yet, delivered through a channel that will be read by at least one machine before it is read by a human. Every awkward part of this is a consequence of those three facts.
You can watch the first two branches of it right now, without an account and without an invite.
curl -sI "https://pub-trivia.app/auth/invite" | grep -i '^location'
location: https://pub-trivia.app/auth/invite/error?reason=missing_token
curl -sI "https://pub-trivia.app/auth/invite?token=nope" | grep -i '^location'
location: https://pub-trivia.app/signup?invite=nope
Two 307s, two different destinations, and the second one is the interesting design decision.
The recipient usually has no account
An invite goes to an email address, not to a user. Most of the time the person reading it has never heard of us: they are a quizmaster whose manager just signed the pub up. So the first thing the route checks is not the token, it is the session.
if (!user) {
const url = new URL(`${PUBLIC_URL}/signup`)
url.searchParams.set('invite', token)
return NextResponse.redirect(url.toString())
}
The token is carried onto the signup page and carried through email confirmation, so that after creating an account the new host lands back at the invite route, now signed in, and the acceptance happens on the second pass. The alternative, which I built first, is to show an invited user a login wall and let them come back to the link afterwards. That works right up until they click through, sign up, land on an empty dashboard with no venue attached, and quite reasonably conclude the product is broken. The invite was still sitting in the database waiting, but nothing on screen said so.
Carrying the token through the signup flow is a small amount of plumbing that removes a dead end. It is the difference between a flow that completes itself and a flow that depends on the user remembering to go back to an email.
A GET that mutates is fine here, and only because of what guards it
The usual rule is that a GET should not change anything, and the usual reason given is browser prefetching. The real reason, with an emailed link, is worse: corporate mail gateways and link scanners fetch URLs in incoming mail to check them for malware. By the time the recipient sees the message, something has probably already requested that URL.
So the invite route has to be safe to fetch by a machine that is not the recipient. It is, because accepting an invite requires an authenticated session whose email matches the address the invite was sent to. A scanner has no session. The worst it can do is receive a redirect to the signup page, which is a public page, and accepting that redirect does nothing.
That is the whole safety argument, and it is worth saying out loud rather than leaving implicit, because it means the auth check is not just an access control on the acceptance. It is the thing that lets the feature be a link in an email at all. A version of this route that accepted on token alone would be accepted by the mail scanner, and the human would get an error saying the invite had already been used.
Five ways to say no, and only one of them is a code
Once there is a session and a token, acceptance can fail for five distinct reasons, and each returns its own sentence rather than a generic failure: the token matches no invite, the invite is no longer pending, the signed in account's email is not the invited address, the account already owns a venue, or the account is already an active member somewhere else.
The third one is the one real people actually hit. Someone forwards the invite, or they are already signed in on a personal account and the invite went to their work address. A generic "this invite is invalid" leaves them with nothing to do about it. So that message names the address the invite was sent to and tells them to log in with it.
The route passes the failure to an error page as a query parameter, and the page renders it:
const url = new URL(`${PUBLIC_URL}/auth/invite/error`)
url.searchParams.set('reason', result.error)
return NextResponse.redirect(url.toString())
This is the part of the feature I am least happy with, and the missing_token case above is the tell. That one is a code, which the page maps to its own wording. The other five are full sentences written by the server and passed through a URL for the page to display.
React escapes the value, so there is no markup injection to worry about, but a message that travels in a query string is a message anyone can write, and a page that renders text handed to it by its own URL is a page whose wording is no longer entirely yours. The right shape is the one missing_token already has: send a short code, keep every sentence in the page, and let the page decide what each code means. Five codes is a small refactor and it closes the question completely. The reason it has not happened yet is that it only matters for a URL somebody constructs by hand, which is exactly the kind of reasoning that leaves these things in place for a year.
The email failure that is deliberately not an error
One more decision, in the function that sends the invite. If the mail provider rejects the send, the error is logged and nothing is thrown.
if (error) {
console.error('[invite-email] failed to send invite to', to, error)
}
The invite row has already been written. Letting the send failure propagate would roll the whole thing back, so the owner's screen would say the invite failed and they would try again. That sounds like the correct behaviour until you notice that the row and the email are not in one transaction anyway: the email can succeed and the response can still be lost. Swallowing the failure means the pending invite stays visible in the team settings list, where the owner can resend it. One source of truth, which is the database, and an email that is a notification about the database rather than the thing itself.
The email also states its own expiry, seven days, in the same words the backend enforces. Small thing, and it is the difference between "this invite link is invalid" reading as a bug and reading as an expiry.
See it from the outside
The two curl commands at the top are the honest demonstration: the route's first two branches are observable by anyone, because neither of them requires an invite to exist. missing_token shows the code path, and ?token=nope shows the signup handoff with the token preserved in the URL you get bounced to.
If you want the rest of it, the pricing page explains which plans include extra hosts, and the free tier needs no card. Create a venue and the team section of the dashboard settings will send a real one to any address you own, at which point you can read the email, click the button, and watch the fourth refusal fire, because the account you are signed in as already owns a venue.
The general shape
If you are building an invite link, the three questions that decide most of the design are: what happens when the recipient has no account yet, what happens when a machine fetches the link before they do, and what each refusal says to the human reading it. Answer the first with a token carried through signup, the second with an auth check that makes a stray fetch inert, and the third with codes rather than sentences, which is the one I would do differently if I were starting again.
Top comments (0)