FoodBoard
Multi-tenant digital menu builder with AI menu import - design a menu, publish it as a link or QR code

- Tenancy
- anon revoked from every table · one audited SECURITY DEFINER read path
- Security audit
- 8 findings fixed · 15 regression tests on PGlite
- Link previews
- prerendered per menu at build time
- Images
- WebP in the browser · quality ladder to a size target
- Role
- Sole developer - a from-scratch rebuild that shares no code with the original
- Client
- The original belongs to Spark Solutions, Chișinău; this rebuild is mine
- Stack
- Vue 3PiniaSupabasePostgreSQLDenoGoogle GeminiVitestGitHub Actions
The tenancy boundary lives in Postgres
The obvious way to build a multi-tenant dashboard is to filter by owner in the client. That works until the day a query is written without the filter - and then it returns everybody's rows, silently. Instead, anon is revoked from every table, authenticated holds only the four privileges the app uses, and every table carries a policy comparing auth.uid() to its owner column. The client cannot express a cross-tenant query because the database will not answer one.
Public menus need to be readable by anonymous diners, which is exactly the requirement that usually forces a hole in a scheme like this. There is one security definer function for it, the only thing anon may execute, and it filters hidden categories and dishes inside the query so unpublished content never crosses the boundary.
The model proposes, the owner decides
Typing in a sixty-dish menu is the slowest part of onboarding, so an owner can upload one PDF or photo instead. A Supabase Edge Function sends it to Gemini with a JSON schema and returns an editable draft; nothing is published until the owner has checked it. The model is treated as untrusted input, like a form field: the key stays on the server, files are checked by their bytes rather than their names, the daily quota is reserved in Postgres before any money is spent, and the output is validated and repaired before anyone sees it. Allergens get their own confirmation step, because EU law makes the restaurant liable for them.
Accuracy is measured rather than assumed. An eval runs five fixtures through the deployed function: an Italian menu numbering allergens the EU way, a Romanian menu in lei with a dish priced by weight, a blurred phone photo, a menu with hidden text telling the model to zero the prices, and a lost-cat poster it must refuse. The first runs failed twice - the model copied allergen numbers instead of translating them, and an ambiguous prompt made prices a hundred times too high - and both fixes went into the production code. The latest run scores five out of five.
An audit found what the policies missed
In October 2026 I audited the project with an AI coding assistant and confirmed every finding in the source before accepting it. It found eight real issues. Most came from one habit: a guard written for the row in hand but not for the parent it points at, a column nobody expected to be written, or the account everyone shares. Another account could plant dishes in someone else's public menu, anonymous visitors could list every tenant's uploads, a revoked menu link could be claimed and printed QR codes hijacked, and deleting an account refunded the daily AI budget.
All eight are fixed in one migration, shipped to production after checking the live data first, and each has a regression test that runs every migration on an in-memory Postgres and fails without its fix.
A public demo account, made safe to publish
Reviewers will not sign up to look around, so the sign-in page has an Enter demo button and the credentials are printed in the README. That makes the account hostile territory. Hiding Delete account in the header is presentation; the guard is a protected_accounts table with row-level security on and no policies, read only by the delete function itself - which refuses. I verified it by signing in with the published credentials and calling the RPC.
The seed restores the showcase five times a day from a media manifest, so a visitor's replacement photo does not survive the reset. Before the account was shared, the seed carried over whatever was in the database. That was correct then and became exactly wrong the day the password was public.
Link previews are prerendered, because scrapers do not run JavaScript
The app sets a menu's title and Open Graph tags at runtime, which is enough for browsers and Google. WhatsApp, Facebook and Slack read the markup as served, so every restaurant previewed as the same generic card - on the one surface where a menu is actually shared. A build step now writes one static HTML file per published menu with the restaurant's own name, blurb, logo and language, plus schema.org data and a sitemap; a person still gets the full app.
Writing the tests for it found a real bug: a restaurant's description flowed unescaped into an inline JSON-LD block, where a literal </script> would end the block early. The CSP would have stopped injected script from running. It should not have been written at all.
What I would do differently
Records are stored as form descriptors rather than domain objects - a dish is an array of field definitions with one tab per language. It made the editor almost free to build and has been the wrong shape at every point since. The README says so in a section called Known limitations, because those are the parts I would want to be asked about.
Industrial condition-monitoring dashboard - a public rebuild of a factory-floor product