On the Lees: Private Network for Sommeliers
A personal project: a members-only, real-name network for sommeliers and wine professionals, live since June 2026, where the database itself decides what each member may read or write.
2026
- Live since June 2026, with member moderators
- Row-level security on every table, with integration tests that check the rules against a local Postgres in CI
- End-to-end tests run against a production build on Chromium and on WebKit, the engine behind iPhone browsers
- Self-serve data export and account deletion, with group ownership handed off and every stored file swept
Problem
Wine professionals who had trained together ended up scattered across group chats, with no members-only place to find each other, talk or study. Findability is the point, so members use real names, and a real-name directory of working sommeliers must never be public or indexable. New members join with an invite code from an existing member, or ask for access and wait for a moderator's approval. And it had to work on a phone first, as an app that installs to a home screen.
Approach
It has four parts: a member directory, a forum, study groups with events, and Reconnect, which helps a member find someone whose contact details they lack. Every member-facing page sits behind sign-in, and the whole site is marked noindex. Postgres row-level security is on for every table, so the database, not the interface, decides who may read or write each row. Scope decisions are recorded with a date and a reason: the launch audit cut error monitoring and a CI gate, and once the app was live both were adopted, so it stays diagnosable and safe to change.
Architecture
Every page a member opens passes three layers on Vercel, and Postgres row-level security has the last word. A Next.js 16 proxy (the file that replaced middleware) refreshes the Supabase session and sends any signed-out visitor to the join page; the signed-in layout requires a verified profile; and Server Components and Server Actions validate input with Zod at the boundary and query Postgres as the member, so row-level security binds every read and write a member's session makes; the few server tasks that must act beyond one member's rights, such as redeeming an invite, run under a server role instead. Files sit in private Storage buckets and are served only after an access check, through a short-lived signed URL. Sign-in is a link or an 8-digit code through Supabase Auth, with Cloudflare Turnstile verified inside Auth. Every notification except the access-approved email leaves through one delivery function that first checks suspension, preferences and per-member send caps, then sends by email through Resend or by web push. Sentry reports errors with personal data scrubbed and no session replay.
More Results
- A trigger freezes every column that names an owner, a parent or a scope, because an update policy cannot compare a column's old and new values; integration tests that sign in as different members pin each case
- The human check runs before anything about an invite code is read, and applying a code after sign-in is limited per account and fails closed
- Per-member hourly and daily email limits, decided and recorded in one function call under a per-member lock, so one member's burst queues behind itself and nobody else waits
Lessons Learned
Write policies need the same scrutiny as read policies: a row could change the very column its read policy scopes on. A limit that reads and then writes is not a limit; the check and the record have to be one locked statement. And a local stack is not evidence about production, so grants are stated explicitly in a migration rather than trusted to defaults.
