Munchable is a gut-health app: scan a barcode, find out whether the product suits the conditions you manage. Like most products it has a support form, and unlike most it does not ask you to be signed in first.
The reason is written at the top of the route, because it is the kind of decision somebody will otherwise "tidy up" six months from now:
// Opening a ticket does NOT require an account. The people most in need of
// support are often the ones who cannot sign in, or who are deciding whether to
// subscribe at all, and turning them away at the door is how a support channel
// stops hearing about its worst bugs. A signed-out request must therefore
// supply its own contact address, and pays for that with a much tighter
// per-IP limit.
An auth check in front of a support form is not a security measure. It is a sampling bias, and it is aimed precisely at the reports you need most: the broken sign-in, the email that never arrived, the payment that went through on a page that then errored. Every one of those arrives from somebody who cannot authenticate, by definition.
You can go and use it right now without an account: munchable.app/support.
Three doors, one queue
A conversation is one support_tickets row plus append-only support_messages, and it can start three ways:
- Web. The form above. No account needed; a signed-out visitor supplies their own address.
- Mobile. Profile, or the "tell us about it" fallback on a scan result whose report could not be recorded. The app attaches its platform and build string for triage, and the barcode when the report is about a product.
- Email. Mail to support@, bugs@ or feedback@ opens a ticket rather than landing in a personal inbox. The threading behind that is a catch-all MX record and a plus address, which I have written up separately.
One queue behind all three, and the schema follows from that rather than from the web form:
userId: text('user_id'), // nullable: email and signed-out tickets have no account
email: text('email').notNull(), // the contact identity, always present
source: text('source').notNull(), // 'web' | 'mobile' | 'email'
The identity of a conversation is an email address. The account, where there is one, is an extra.
One case that looks signed in and is not: the app can mint an anonymous, device-scoped Supabase session. Those are treated as signed out for attribution, because the id is free to mint and carries no verified address. A ticket filed against one would be attributed to nobody in particular, which is worse than attributing it to the address the person typed.
What the open door costs, stated plainly
Two things, and both of them have to be paid rather than waved at.
The route sends email on every call, one acknowledgement to the sender and one alert to the operator. That makes an unlimited version of it a spam relay with our domain on it. So the limiter fails closed, with no "well, Redis is down, let it through":
} catch {
return NextResponse.json(
{ error: 'rate_limiter_unavailable' },
{ status: 503, headers: NO_STORE },
);
}
A signed-out request has nothing to key on except the IP. When somebody is signed in, the budget is keyed on their account with the IP as a backstop. When they are not, the IP carries the whole thing:
decision = await enforce(
signedIn && user
? [
{ limiter: lim.supportDevMin, key: user.id },
{ limiter: lim.supportOpenDay, key: user.id },
{ limiter: lim.supportIp, key: ip },
]
: [
// No account to key on, so the IP carries the whole budget and the
// daily ceiling is shared by everyone behind it.
{ limiter: lim.supportOpenDay, key: `ip:${ip}` },
{ limiter: lim.supportIp, key: ip },
],
);
The daily ceiling for opening tickets is ten. Shared, for everyone behind one address. That is a real cost and I am not going to pretend otherwise: an office or a student house on one NAT could in principle exhaust it, and I have written before about how little an IP tells you about a person. It is tolerable here only because two other doors exist: that household can email support@ instead, and the mobile app keys on its own session. A limit that cannot be escaped at all would be the wrong trade for a support channel.
Note also what is capped harder than what. Opening a ticket is ten a day; replying to an existing thread is not, because a reply lands on a conversation that already exists and is already ours. The expensive thing is creating work and sending the first pair of emails, not continuing a conversation.
A signed-out ticket is an email thread, not a page
This is the asymmetry that makes the whole arrangement hold together, and it took me a while to be comfortable with it.
Anybody may open a ticket. Reading one back in the browser needs an account:
export async function GET(request: NextRequest, context: Context) {
const user = await getUser(request);
if (!user || user.isAnonymous) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401, headers: NO_STORE });
}
const { ticketId } = await context.params;
const ticket = await getTicketForUser(user.id, ticketId);
if (!ticket) {
return NextResponse.json({ error: 'not_found' }, { status: 404, headers: NO_STORE });
}
return NextResponse.json(ticket, { headers: NO_STORE });
}
So for a signed-out requester the conversation lives in their inbox: our reply arrives by email, their reply comes back through the inbound webhook and lands on the same ticket. They never need a page.
Two details keep that from being a hole:
Ownership is resolved in the service layer, which returns null for "does not exist" and for "is not yours" alike. The route answers 404 to both, so the endpoint cannot be used to find out whether a ticket id is real, and the per-ticket page is marked noindex because a conversation has nothing to offer a crawler.
And the reference we print in emails is not a key:
export function ticketRef(id: string): string {
return `MU-${id.slice(-6).toUpperCase()}`;
}
Six characters from the random half of a ULID. It is distinctive enough to quote back at us in a reply, and it does not open anything: the full id is still required to read a thread. A short reference and a capability are different objects, and conflating them is how support systems end up with enumerable conversations.
Small decisions that keep the door genuinely open
An open door is easy to close by accident, one validation rule at a time.
A missing subject is filled from the message instead of rejected, because the form asks for one but a mobile client or a future entry point should not be able to fail on a field the user does not think of as required:
const subject =
(typeof body.subject === 'string' ? body.subject.trim() : '') ||
message.split('\n')[0].slice(0, 80);
The acknowledgement and the operator alert are sent after the write, both best effort. A ticket that exists with no acknowledgement is recoverable. A lost ticket is not.
A user may set exactly one status, resolved. Reopening happens implicitly by replying, and closed is our word for a thread that went nowhere, which is not a judgement a requester should be able to record about themselves. When a status does change, the thread gets a system message that names the new status and never the operator.
The privacy shape, because the body is free text
Everywhere else in this product, the sensitive data never reaches us: conditions and allergies live on the device and the engine runs there, so a barcode lookup carries a barcode and nothing about you.
A support message breaks that pattern by being whatever somebody typed. So it is treated as the most sensitive thing we hold. The tables are RLS-enabled with no policies at all, which means the server's privileged connection is the only reader, there is no client-side path to them, and the whole history is erased with the account. The form says so in plain words rather than in a policy:
We read everything. Please do not include health details you would rather not put in an email: we do not need them to help, and Munchable never stores your conditions on our servers.
And one line in the schema that is a product promise rather than a constraint:
// Authors: 'user' (the requester, from any door), 'admin' (us), 'system' (a
// status-change note shown in the user-facing thread). There is no bot or AI
// author: Munchable's support is answered by a person.
Go and send one
-
munchable.app/support works signed out. Type a sentence, send it, and watch it come back as
MU-XXXXXXin your inbox with a reply address that threads. - Sign in from the menu on that same page and the form grows a list of your past conversations, because now there is an account to hang them on.
- app.munchable.app is the same app the phone runs. Profile, then get help, opens the same queue with your platform and build attached.
- What we do and do not keep is in the privacy policy, and the account deletion path that takes the support history with it is its own post.
Top comments (0)