Let’s talk

App rescue / Illustrative report

What a health check
report looks like.

Illustrative example, not a client project. Brambleford Paddle Hire, its app and every finding below are invented to show what the £495 health check report contains. Your findings depend on your app.

Reviewed by Alec Pedersen · Report issued within five working days of access · Scope one web app: one code repository, one database project and its hosting · Fee £495, deducted in full from fixes booked within 30 days of this report.

1. Summary for the owner

The app. Customers book a two-hour kayak or paddleboard session, pay a deposit on Stripe’s hosted page and manage the booking afterwards; staff see the day’s bookings, keep the timetable and add walk-ups; a public “Ask about availability” box answers questions through an AI provider. Built with an AI builder; runs on Supabase, Stripe, Vercel and GitHub.

The overall picture. The expensive parts are right: money moves the way Stripe says it should, live and test data are apart, the code builds cleanly, and the database is laid out the way the business works. Two things need fixing now: any customer with an account can read every other customer’s bookings, names and phone numbers included, because the database checks that someone is signed in, not who; and the AI provider’s secret key is in the app’s browser code, so anyone who looks can spend your allowance. Around those sit settings never chosen for a live business, and two structural gaps, no tests and booking rules in three places, that make every change riskier.

Recommendation: repair. The two urgent findings are narrow and can be contained today from a dashboard, and what a rebuild would produce, a sound data model and correct payments, already exists (05, 19). The reasoning, the alternative considered and what would change my mind are in section 9; the terms for fixes in section 13.

Everything found, grouped by label; section 10 gives the order. Labels are defined in section 2.

Fix now

  • 01 Customers can see each other’s bookings. The database doesn’t check who is asking.
  • 02 A secret key reaches the browser. Move it to the server, then replace the key.

Next

  • 03 No tests on sign-up or payment.
  • 06 The public timetable exposes staff notes. Every column of every session, published or not, is readable without signing in.
  • 07 Two customers can pay for the last place.
  • 08 Preview deployments use the live database and live Stripe key.
  • 09 Nothing tells you when something fails, and the logs that exist contain customer emails.
  • 10 The domain could lapse.
  • 11 Everything sits on one person’s personal accounts.
  • 12 The availability box has no limits.
  • 13 Password reset emails don’t reach customers.

Later

  • 04 The same form logic in three places.
  • 14 Stripe uses the account’s full secret key, never rotated.
  • 15 Nothing is ever deleted, and a customer can’t be.
  • 16 Packages: one advisory, two unused, no alerts.

Observations

  • 17 Three small performance items. Nothing is slow today.
  • 18 Backups exist; a restore has never been rehearsed.

Keep

  • 05 The booking journey works well.
  • 19 Live and test are apart, and the keys are where they should be.

If you only do three things this week, each a dashboard action, not code.

  1. Contain 01. In the Supabase dashboard, disable the three bookings policies (bookings_select, bookings_insert, bookings_update); “My bookings”, the staff list and staff walk-ups go dark until the fix is live, while online booking and payment keep working (01 says why).
  2. Contain 02. At the AI provider, delete the current key and read the usage log for unfamiliar calls; the box stops until the server change is live.
  3. Turn on two-factor authentication for Vercel, Supabase and the AI provider (11); a fourth: auto-renew on the domain (10).

What I told you straight away. I told you about 01 and 02, with the containment steps above, as soon as I had confirmed each on the test copy; neither step had been taken when this report was issued, so both are open; section 8 sets out what I established about 01, what I did not, and where the ICO’s guidance comes in.

2. How to read this report

Who it is written for.

  • You, the owner. Section 1, the plain-English part of each finding, and sections 9, 10 and 13; the developer blocks and Appendix A can be skipped.
  • A developer, or an AI tool you point at it. The “For a developer” block under each finding, Appendix A and section 3; each block stands alone.
  • Someone you show it to. Appendix A records what was checked, how, and what was found; you write your own answers from it; they should read section 4 first.

The scale used throughout

Every finding carries one label, answering how likely it is to bite and how much it would hurt, and saying what to do. The labels are mine, not a standard’s severity ratings.

  • Fix now. Could be happening already, or would on the first attempt; serious: personal data exposed, money at risk, or the app unusable. Contain it today (each says how), then fix it before anything else; I told you about these as soon as I confirmed them.
  • Next. Likely within a season or the next change; real but recoverable, or a small fix that closes a serious door. Before the next feature; most are settings and small code that batch.
  • Later. Unlikely or slow to bite; limited today, mostly making later work dearer or riskier. Batch with related work when you next touch the area.
  • Keep. Not a problem. Leave it alone; don’t let a rewrite or an AI tool “improve” it.

Observation (the label; section 1 heads the group “Observations”) is separate: not a defect, so no likelihood or impact; a fact about the set-up, a default nobody chose, or an unknown I could not confirm without changing the live app. No work is required unless you say so.

Effort bands. Relative, not time estimates; fixes are quoted separately.

  • Small: one place changes: a setting, a policy, a short function, a configuration file.
  • Medium: several files or a database migration, with its own tests and deployment plan.
  • Large: a redesign of one area, scoped as its own project.
  • n/a: nothing to do.

The parts of a finding

Each finding opens with its label, its area and one line you could repeat to someone else; then, in plain English, what could happen, what to do and the effort band; then a “For a developer” block: the evidence, what I ran on the test copy, the fix concretely enough to quote and build, how to verify it, and the documentation behind each claim. Section 7’s findings are shorter.

Cross-references. Findings 01 to 19 make up the register (sections 6 and 7). Appendix A’s checks are numbered by area (WHO, KEY, PAY, DAT, AI, STA, HOS, its seven headings) and each records Pass, Finding, Not applicable or Not checked; a Not checked is an unknown recorded, not a pass.

Sources. Where a sentence rests on how a platform behaves, the vendor’s page is linked there and listed in Appendix B; where it rests on what I saw in your app, the sentence says what I did to see it. Where I could not check a claim either way, the report says so.

3. The app as I found it

Reviewed at. main at commit 3e7c1f0 (13 September 2026); access agreed Monday 14 September 2026; report issued Friday 18 September 2026. Access and its limits are in section 4.

How it is put together

Front end. React with TypeScript, built with Vite; React Router; Tailwind; served by Vercel as static files. It talks to Supabase from the browser with the publishable key, public by design provided RLS is on (API keys). Server code. Two Vercel Functions, api/create-checkout-session.ts and api/stripe-webhook.ts, in Vercel’s default US region (17); the AI call comes from the browser (02). Database, sign-in, files. Supabase, Pro plan: brambleford-live and brambleford-test, both in London; RLS on every table (RLS); Auth with email and password; one public Storage bucket for session photos. Payments. Stripe Checkout, hosted page, card only, one-off deposits; one webhook endpoint, subscribed to checkout.session.completed (webhooks). AI. The availability box calls an AI provider’s chat endpoint, pay-as-you-go. Email. Supabase Auth’s default service; no custom SMTP (13). Code. Private GitHub repository paddle-hire-app under your personal account; the builder’s GitHub app pushes to main. Domain. A .co.uk domain in your name at a UK registrar; DNS there, pointing at Vercel.

No tests, no CI workflow, no vercel.json. The migrations match the live schema except bookings.updated_at, added by hand (19).

What data it holds, where, and who can reach it

DataWhere it livesWho can reach it todayKept for
Sign-in: email, password hashauth.users, Supabase liveSupabase Auth; you; the two functionsUntil deleted, which fails (15)
Name, phonepublic.profilesThe customer, own row only; you; the functionsIndefinitely
Bookings: session, party size, status, amount, Checkout Session IDpublic.bookingsAny signed-in user (01), not just the customer and staffIndefinitely
Card detailsStripe onlyThe Stripe accountStripe’s business
Name and email alongside each paymentStripe DashboardThe Stripe accountStripe’s business
Availability questions and answerspublic.ai_queries; also sent to the AI providerWritten by anyone; readable by nobody via the APIIndefinitely (12, 15)
The 14-day availability listSent to the AI provider with every questionThe providerThe provider’s terms
Session photosBucket session-images, publicAnyone by URL; any signed-in user can upload (01) (access control)Indefinitely; not backed up (18)
Webhook payloads, with customer name and emailVercel runtime logsAnyone with Vercel accessOne hour (09)
Daily database backupsSupabaseThe organisation ownerSeven days (backups); PITR off (18)

No postal addresses, dates of birth, analytics or tracking scripts; no error tracking, uptime monitoring or transactional email. stripe_events holds event IDs only, and the test project invented data only (19). Third parties holding something: Supabase, Stripe, Vercel, GitHub, the AI provider, the registrar and the builder, still linked and holding the code, the chat history and every pasted value.

Environments and accounts

Your laptop points at brambleford-test and Stripe test keys; Vercel Preview (any other branch) and Production (main) both run against brambleford-live with live keys, Preview with Deployment Protection off (08); every push to main deploys at once (git).

All six Vercel variables are set for Production, Preview and Development together, although each environment can carry its own value (environment variables). Five are where they should be: the Supabase URL and publishable key in the browser by design, the three server secrets read only in api/. The sixth, VITE_AI_API_KEY, is the exception (02): Vite embeds every VITE_-prefixed variable in the bundle and says they must not contain secrets (env and mode).

Every account is yours alone, with no second owner or recovery contact; two-factor off for Vercel, Supabase and the AI provider; Vercel on Hobby; Stripe on its default unrestricted key; no AI spending limit; auto-renew off at the registrar (10, 11, 12, 14).

The AI feature

One feature, in one file: src/components/AskBox.tsx, on the public timetable page, no sign-in needed. It sends the provider a fixed system prompt, the question as typed and the next 14 days’ published sessions, using VITE_AI_API_KEY from the browser (02); it has no tools, so a steered question gets a wrong answer rather than a leak (section 8). No length cap, rate limit or spending limit; every question and answer is kept forever in ai_queries (12).

4. Scope, method and limits

What was in scope. One web app, as the terms define it: one code repository, one database project and its hosting. Here: the private GitHub repository paddle-hire-app at main, commit 3e7c1f0 (13 September 2026); the Supabase project brambleford-live, with brambleford-test as the working copy; the Vercel project. The accounts the app depends on were reviewed for what they hold and who can get in.

Access. Read-only throughout: the repository, the Supabase organisation (both projects) and the Stripe account; the AI provider’s usage page and the registrar’s renewal page with you; Vercel’s settings by screen share, as Hobby cannot add a reviewer (Hobby plan). Data processing terms were signed before any database access (the ICO’s general background: controllers and processors); no key or password travelled by email; every credential was removed when the review ended. Every reproduction ran on brambleford-test, seeded with invented data, in Stripe test mode; the live project was only read.

Method. Five kinds of evidence.

  • Static reading. Every file in src/, api/ and supabase/; every policy as written and as live (supabase db diff), since they can differ (DAT-09); the git history, searched for each secret’s prefix.
  • Configuration review. Each dashboard, setting by setting, as found, with Supabase’s Security and Performance Advisors (advisors) and the builder’s own scan.
  • Dynamic walkthroughs on the test copy. The customer and staff journeys on a phone and a desktop; valid, forged, altered and duplicated webhook events from the Stripe CLI (webhooks); two checkouts for one place; a password reset; a wrong-password loop.
  • Registry checks. Every package name resolved on the npm registry, then npm audit (npm audit) and depcheck.
  • Probes against the availability box. Steering attempts, dates outside its list, a loop of requests from one address.

The tools ran first; both Fix now findings were then found by reading.

Not in scope. The platforms’ own systems; the builder’s platform beyond its dashboard (HOS-12) and the AI provider’s handling of what it receives (AI-11), governed by their terms; a live restore (DAT-07), which takes the project offline (backups); whether anyone has already reached the data (09); refunds and disputes (PAY-12); anything after 3e7c1f0.

Limits. This is an engineering review by one engineer. It is not a penetration test, not an audit against a standard, not a certification and not legal advice, and I am not a lawyer. No review can prove an app has no weaknesses; this one gives you a written record of what was checked, what was found and what remains. The findings are dated: they describe main at 3e7c1f0 on 13 September 2026, not the app after the next change.

Confidence. Every Fix now and Next finding about the code or the database was reproduced on the test copy; evidence that the exposed key (02) or the open policies (01) had been used by someone else would leave the findings standing, but make whether personal data was reached a live question, yours to decide with the ICO’s guidance (ICO).

5. Coverage by area

One paragraph per area: what I checked, what it led to, what was sound, what I could not check; IDs point to Appendix A, numbers to the register.

Who can see what (WHO-01 to WHO-14). Checked: RLS and every policy, as written and as live; the functions’ caller check; the trigger; the Security Advisor (WHO-10); what a customer can reach. Led to 01 (policies test only that someone is signed in; the database cannot tell staff from customers), 06 (every column and unpublished rows public) and 13 (reset emails never arrive). Sound: profiles keyed on the owner; stripe_events unreachable; the functions verify the caller. Not checked: whether anyone has used the open policies (09).

Keys & secrets (KEY-01 to KEY-11). Checked: every variable’s path to the browser; the git history; the builder’s chat; every key; local secrets; Vercel’s environment targets; the database password; connected apps. Led to 02 (the AI key in the bundle, the history and the builder’s chat), 14 (the unrestricted Stripe key) and 08 (every variable in all three environments). Sound: the server secrets exist only in Vercel, read only in api/; the laptop points at test (19). Not checked: whether anyone else used the exposed key (AI-08); what the builder’s platform holds (HOS-12).

Payments (PAY-01 to PAY-13). Checked: the amount’s source; card entry; signature verification; fulfilment; duplicates; reconciliation; capacity at checkout and on abandonment; failure alerting; walk-ups; test-mode discipline. Led to 07 (two customers can both pay for the last place), 04 (free-text status hides walk-ups) and 09 (a failing webhook would go unnoticed). Sound: the core is right, recorded as 05. Not checked: whether the webhook has ever failed (09); refunds and disputes (PAY-12).

Data & backups (DAT-01 to DAT-12). Checked: live and test separation; the data inventory and recipients; backups and restores; uploaded files; migrations against the live schema; retention; deletion; logs. Led to 15 (nothing is ever deleted; a customer cannot be), 18 (no restore rehearsed; photos in no backup) and 09 (the webhook logs each payment’s full payload). Sound: two projects, test seeded from seed.sql, no card data held, Stripe’s independent record, migrations matching live bar one column (19). Not checked: a live restore (DAT-07); the AI provider’s handling (AI-11).

AI features (AI-01 to AI-11). Checked: every AI call and what leaves in it; where the key is held; steering; dates it cannot see; caps, rate limits, spending limit; the table recording every question; the account. Led to 02 (the call is made from the browser with the secret key), 12 (no caps, rate limit or spending limit; invented sessions; every question kept forever) and 11 (a personal account, two-factor off, AI-10). Sound: nothing personal in the request and no tools, so a steered answer can only be wrong (section 8). Not checked: the provider’s retention and training terms (AI-11).

Stability & code (STA-01 to STA-12). Checked: a clean-clone build; tests and the deploy gate; the booking rules; the status column; error tracking; package advisories, provenance, use and alerts; indexes, policy cost and function region; the journeys. Led to 03 (no tests, workflow or branch protection), 04 (three copies plus a partial fourth, drifted; free-text status), 09 (no error tracking or alert), 16 (one advisory, two unused packages, alerts off) and 17 (unindexed foreign keys, per-row auth.uid(), functions far from the database). Sound: a clean build with strict TypeScript; every package resolves to the expected one (16); every journey completed. Not checked: whether errors have happened before (09).

Hosting & ownership (HOS-01 to HOS-12). Checked: ownership of code, hosting and domain; renewal; a second person and two-factor per account; plan and rollback; the deploy path; previews; the builder’s access. Led to 11 (every account yours alone, two-factor off on three, the Hobby plan, HOS-07), 10 (auto-renew off, reminders unread), 08 (previews on live data, open to anyone) and 03 (no gate before production). Sound: code, hosting and domain in your name, over HTTPS (HOS-01, HOS-02, HOS-03); your builder’s own documentation says the code you create is yours — check it, and keep the GitHub repository as the copy you control. Not checked: the Vercel settings directly (screen share); what the builder’s platform retains (HOS-12).

6. The five headline findings

These five decide the recommendation. Each has the same shape: what it is, what could happen, how to fix it, the effort band, and a check you or a developer can run to confirm it is done; the indented block under each is for whoever does the work, and the IDs in brackets point at Appendix A.

01 · Fix now · Who can see what · Customers can see each other’s bookings

What it is. Every table has Row Level Security on, which is the right start, but the three rules on bookings only ask “is this person signed in?”, never “is this their booking?”; “My bookings” looks correct only because the app asks for the right rows. The same gap covers the timetable (any signed-in customer can change a session’s deposit or capacity) and the photo bucket, because the database cannot know who is staff: that is a list of emails in the browser code (WHO-02, WHO-03, WHO-04).

What could happen. A customer with a little technical knowledge could list every customer’s name, phone number and booking times, book or change bookings in someone else’s name, or alter the timetable and deposit; unauthorised access to personal data is within the ICO’s definition of a personal data breach (ICO guide); section 8 sets out what I established and whose decision the rest is. Whether anyone has done this is unknown (09).

How to fix it. Change the bookings rules so a customer can read only their own bookings and change only the party size or a cancellation. Customers never create a booking row (the server function does, 05), so the customer insert rule goes; staff add walk-ups from the browser, so they keep an insert rule. Then give the database a way to know who staff are: a small user_roles table only you write to, from the dashboard, with staff rules on bookings, sessions, the staff notes (06) and the photo bucket. The public timetable stays readable (its columns are 06); the email list in the browser goes.

Containment today. Remove all three bookings rules (bookings_select, bookings_insert, bookings_update) in the dashboard. “My bookings” and the staff list go blank, and customers cannot change or cancel, nor staff add walk-ups, until the fix is live; online bookings and payment keep working, because the two server functions use the secret key and these rules do not apply to them. Leaving bookings_insert would let any signed-in customer insert a booking in anyone’s name.

Effort. Medium.

How to verify it is fixed. As customer B on the test copy: bookings returns only B’s rows; inserts, for any customer_id, are refused; updates to B’s own status or amount_pence are refused; cancel_booking works on B’s booking and does nothing to A’s; a change to a session is refused. As staff: the day’s bookings are visible, a walk-up can be added, sessions can be edited. Read the policies (Authentication → Policies) and re-run the Security Advisor.

For a developer (illustrative). Evidence. 0003_bookings_policies.sql defines bookings_select, bookings_insert and bookings_update, each on auth.uid() is not null; the same expression gates writes on sessions (0002_sessions.sql) and uploads to session-images (0006_session_images.sql). Reproduced on the test project as seeded customer B with the publishable key: select('*, profiles(full_name, phone)') returned A’s rows with name and phone; an insert with A’s customer_id, an update to A’s party_size, an update to a session’s price_pence and an upload to session-images all succeeded. The Security Advisor is silent: its 0024_permissive_rls_policy lint looks for always-true conditions such as using (true) (advisors, lint list). Fix, in shape:

drop policy bookings_select on public.bookings;
drop policy bookings_insert on public.bookings;  -- customers never insert; the server function does
drop policy bookings_update on public.bookings;  -- no customer update policy: see the two functions below
create policy bookings_select_own on public.bookings for select to authenticated
  using ((select auth.uid()) = customer_id);
create policy bookings_insert_staff on public.bookings for insert to authenticated  -- walk-ups from admin/CreateBooking.tsx
  with check (exists (select 1 from public.user_roles where user_id = (select auth.uid()) and role = 'staff'));
create function public.cancel_booking(booking_id uuid) returns void
  language sql security definer set search_path = '' as $$
  update public.bookings set status = 'cancelled'
  where id = booking_id and customer_id = (select auth.uid()) and status in ('pending', 'paid');
$$;
revoke execute on function public.cancel_booking(uuid) from public, anon;
grant execute on function public.cancel_booking(uuid) to authenticated;

update_party_size(booking_id uuid, n int) follows the same pattern and applies the rule from 04; EditBooking.tsx calls both with rpc instead of updating the row. With no customer update policy, nothing with the publishable key can change status, session_id or amount_pence; the functions are the only route, and each checks the caller owns the row. security definer with search_path pinned is Supabase’s form (database functions); the Security Advisor will list both under 0029_authenticated_security_definer_function_executable (lint list), expected here. (select auth.uid()) is evaluated once per statement (RLS), which also clears 17’s 0003_auth_rls_initplan item. For staff: create table public.user_roles (user_id uuid primary key references auth.users on delete cascade, role text not null) with RLS on and one policy, for select to authenticated using (user_id = (select auth.uid())), so each user reads only their own row and only the dashboard writes it; without that policy the staff check, which runs as the caller, would see no rows. Then exists (select 1 from public.user_roles where user_id = (select auth.uid()) and role = 'staff') in staff policies on bookings (select, insert, update), sessions (select, insert, update, delete), session_staff_notes (select, insert, update, delete; 06) and storage.objects (insert, bucket_id = 'session-images'; storage policies are ordinary RLS, access control); or a custom access token hook that puts the role in the token (RBAC). Never key a policy on user_metadata, which a signed-in user can change (RLS). Remove src/lib/staff.ts once the policies land; broken access control is OWASP A01:2025 (A01).

02 · Fix now · Keys & secrets · A secret key reaches the browser

What it is. The availability box calls the AI provider from the customer’s browser with your account’s secret key. Anything a browser needs, anyone can read: the key is in the live site’s JavaScript, the repository’s early history and the builder’s chat (KEY-01, KEY-02, KEY-03, AI-03).

What could happen. Anyone who finds it can use the provider at your expense or exhaust your allowance so the box stops answering; it reaches no customer data (AI-04). The usage log shows only traffic that looks like the app’s: reassuring, not proof (AI-08).

How to fix it. Move the call to server code that holds the key, so the browser only ever sends the question; then replace the key: deploy the server change, create a new key, put it in Vercel, delete the old one, and read the usage log for unfamiliar calls. Leave the repository history alone unless you ever share it: replacing the key is what makes it safe. The limits in 12 belong in the same change.

Containment today. Delete the old key at the provider. The box stops answering until the server change is live; nothing else is affected.

Effort. Small.

How to verify it is fixed. grep -r VITE_AI src api returns nothing; the bundle contains no key prefix; the old key gets an authentication error; the box still answers on the live site; Vercel shows AI_API_KEY for Production (and a Preview key if wanted, 08).

For a developer (illustrative). Evidence. src/components/AskBox.tsx reads import.meta.env.VITE_AI_API_KEY and calls the provider with fetch from the browser; Vite embeds every VITE_-prefixed variable in the bundle and says such variables should not contain secrets (env and mode). Reproduced: after vite build the key is in dist/assets/index-*.js; view-source on the live site finds it by the provider’s key prefix; git log -p --all -S '<prefix>' finds it in two early commits. Fix, in shape: api/ask-availability.ts, a Vercel Function that accepts POST { question }, caps the question (12), reads the next 14 days of published sessions itself, calls the provider with process.env.AI_API_KEY, records the question and answer in ai_queries with the secret key (so the browser-side insert and its open policy go, AI-09) and returns { answer }. Set AI_API_KEY in Vercel for Production only, marked Sensitive (sensitive variables), no VITE_ prefix; then rotate: new key, update Vercel, redeploy, delete the old key. GitHub’s guidance: treat a pushed secret as compromised and change it; rewriting history does not make it safe (removing sensitive data). No Supabase or Stripe secret is in the bundle (KEY-06, KEY-07).

03 · Next · Stability & code · No tests on sign-up or payment

What it is. Nothing checks automatically that the important journeys still work after a change: no test script, no test file, no workflow, no gate between the builder’s commit and production (STA-02, STA-03, HOS-08). The payment code is right today (05); nothing keeps it right through the next change, these fixes included.

What could happen. A change from the builder, a developer or these fixes could quietly stop payment confirmation or sign-up working and be live within minutes; the first sign would be a customer who has paid and has no booking, and nobody would be told (09).

How to fix it. Add a test runner and cover the journeys that would hurt most if they broke: the webhook accepts a real event and refuses a forged one, a duplicate delivery does not double-book, the price cannot be set from the browser, and, after 01, one customer cannot read another’s bookings. Run them on every push and make a failing run block the release: protect main so changes arrive through pull requests that must pass, or treat a red run as an immediate rollback.

Effort. Medium.

How to verify it is fixed. On a branch, comment out the webhook’s signature check: the workflow fails and the change cannot reach production (or is rolled back); the Actions tab shows a green run for every commit on main.

For a developer (illustrative). Evidence. package.json has no test script; no *.test.* or *.spec.* files; no .github/workflows/; no branch protection on main; auto-deploy on (git). Fix, in shape: Vitest. Minimum set: (1) a correctly signed checkout.session.completed with payment_status: 'paid' marks the matching booking paid; (2) an invalid Stripe-Signature returns 400 and changes nothing; (3) the same event twice leaves one paid booking and one stripe_events row; (4) amount_pence and the Checkout Session amount both equal sessions.price_pence × party_size whatever the request body carries; (5) create-checkout-session with no token returns 401; (6) after 01, customer B’s select on bookings returns none of A’s rows, and B’s insert and update ... set status = 'paid' are refused; (7) a staff insert into bookings succeeds and a customer’s, for any customer_id, is refused; both run with the publishable key against the test project. For (1) to (3) use the Stripe CLI locally and, in the workflow, signed fixture payloads (webhooks; fulfilment). Workflow: .github/workflows/ci.yml on push and pull_request, running npm ci, npm run build and npm test with test keys as repository secrets, gated by branch protection on main requiring that check (protected branches), which also stops the builder’s direct pushes, or by treating a red run on main as the trigger for Instant Rollback (instant rollback); npm audit --audit-level=high joins when 16 is done.

04 · Later · Stability & code · The same form logic in three places

What it is. The rules for a valid booking (party size within limits, session in the future, places left) are written out three times, in the customer booking form, the customer edit form and the staff walk-up form, plus a partial fourth in the server function, and have drifted: the edit form has no minimum, so a customer can set a party size of 0 and keep the place; the staff form skips the places-left check, so staff can overbook; and because the status column accepts any text it holds several spellings (pending, paid, confirmed, older Paid), so the day’s list, which looks for paid only, misses walk-ups saved as confirmed (STA-04, STA-05, PAY-10).

What could happen. Overbooked sessions from the staff form; bookings for nobody that still hold a place; walk-ups missing from the list staff work from. Each new booking feature has to be written three times and will drift again.

How to fix it. Put the rules in one shared module used by all three forms and the server function, so the server enforces what the forms display; make the database refuse a misspelt status; tidy the existing rows once; have the day’s list show every status that means “coming today”. Before 07, which needs one set of rules, and after 03, whose test runner makes the module cheap to test.

Effort. Medium.

How to verify it is fixed. Unit tests on the shared module pass; select distinct status from bookings returns only the allowed values; a staff booking beyond capacity is refused, a party size of 0 cannot be saved, and a walk-up appears on the day’s list.

For a developer (illustrative). Evidence. src/pages/BookSession.tsx (A), src/pages/EditBooking.tsx (B, no lower bound on party_size), src/pages/admin/CreateBooking.tsx (C, no places-left check) and api/create-checkout-session.ts (partial). Live select distinct status from bookings returns pending, paid, Paid, confirmed and cancelled; src/pages/admin/Today.tsx filters on status = 'paid'. Fix, in shape: src/lib/booking-rules.ts exporting pure functions such as validatePartySize(n, { min: 1, max }), isBookable(session, now) and placesLeft(session, bookings), imported by the three pages and the function. Migration: agree the allowed set with the owner, keeping walk-ups distinguishable (PAY-10), for example pending, paid, walk_up and cancelled; then update public.bookings set status = 'paid' where status = 'Paid'; update public.bookings set status = 'walk_up' where status = 'confirmed'; and alter table public.bookings add constraint bookings_status_check check (status in ('pending','paid','walk_up','cancelled')); (constraints). Today.tsx shows paid and walk_up; unit tests for each rule and the drift cases join the suite from 03.

05 · Keep · Payments · The booking journey works well

What it is, and why it is good. The part of the app that moves money makes its decisions in the right place: your server sets the price from the database; card details are typed on Stripe’s hosted page and never pass through your app; a booking is marked paid only when Stripe tells your server so, through a message your server checks is genuinely from Stripe, so a forged “payment complete” is refused and visiting the success page marks nothing; the same message twice cannot create a second paid booking; the journey completes on both screen sizes (PAY-01 to PAY-06, WHO-08, KEY-05, STA-12). Each is a decision a builder could easily have got wrong. It also means every online payment can be matched to a booking and to Stripe’s record, so reconciliation, or recovery after a restore (18), is possible.

Effort. n/a.

How to verify it stays that way. The tests in 03 keep this behaviour in place; until then, repeat the Stripe CLI checks on the test copy.

For a developer (illustrative). What is right, and why. api/create-checkout-session.ts: verifies the caller with auth.getUser on the bearer token and takes customer_id from that, never the body; reads sessions.price_pence with the secret key and computes amount_pence = price_pence × party_size, ignoring any amount in the request; creates the Checkout Session and stores stripe_checkout_session_id on a pending booking. api/stripe-webhook.ts: body parsing off and constructEvent on the raw body with STRIPE_WEBHOOK_SECRET; handles checkout.session.completed and checks payment_status === 'paid'; inserts the event ID into stripe_events (primary key) before the update, so a duplicate delivery fails the insert and is ignored; marks the booking paid by stripe_checkout_session_id, so the update is keyed on the Checkout Session ID and repeating it changes nothing; then returns 200, nothing slow in the handler. The success page reads the booking’s status only. This is Stripe’s guidance: fulfil from the webhook, not only the landing page; check the payment status; verify the signature on the raw body; key fulfilment on the Checkout Session ID and record the event ID as well (fulfilment, webhooks). What would break it: fulfilment on the success page alone; body parsing on the webhook route; dropping the stripe_events insert or updating by anything other than stripe_checkout_session_id; reading amount_pence from the request; a VITE_ prefix on either Stripe secret; a rewrite, or an AI tool asked to “tidy up”, touching either file before the tests in 03 exist. 07 and 14 add around this code and are recorded separately; the two log lines in these files are 09.

7. Further findings and observations

The rest of the register, numbered as in section 1, with the same parts kept shorter; Appendix A holds the evidence, section 2 defines the labels and effort bands, and section 10 gives the order.

06 · Next · Who can see what · The public timetable exposes staff notes

What it is. The sessions select policy is using (true): right for a public timetable, but it returns every column, staff_notes included, and unpublished rows. On the test copy, select('*') with no session returned the seeded padlock code (WHO-05).

What could happen. Anyone can read staff notes and see sessions before they are announced.

How to fix it. Move the notes to session_staff_notes under the staff policies from 01; change the public policy to using (published = true); add a staff select policy on sessions keyed on user_roles. Column privileges would also work, but restricted roles then cannot select *, which this app does everywhere; Supabase recommends RLS with a roles table (column-level security).

Effort. Small.

How to verify it is fixed. Unauthenticated select('*') returns no staff_notes and no unpublished rows; staff still see both.

For a developer (illustrative). The advisor’s 0023_sensitive_columns_exposed lint cannot know a notes column holds a padlock code (lint list). Land it in 01’s migration.

07 · Next · Payments · Two customers can pay for the last place

What it is. create-checkout-session.ts counts paid bookings, then inserts a pending one: two requests, no lock, pending rows not counted, so two customers reaching checkout together for the last place both pass; nothing handles checkout.session.expired, so abandoned checkouts leave pending rows forever (PAY-07, PAY-08). Reproduced: two paid bookings, capacity one.

What could happen. Overbooking on the sessions that sell out; a refund, an apology, or a family turned away.

How to fix it. Make the database the referee: a Postgres function reserve_place(session_id, party_size) that locks the session row, counts paid, walk_up and unexpired pending (a hold_expires_at column on bookings, set from the Checkout Session’s expiry), and inserts the hold or raises “no places left”. Give the Checkout Session a short expires_at and release the hold on checkout.session.expired, Stripe’s guidance for limited inventory (managing limited inventory). After 04.

Effort. Medium.

How to verify it is fixed. Repeat the two-checkout test: the second is refused before Stripe is involved; an abandoned checkout frees its place when the window closes.

For a developer (illustrative). Called from api/create-checkout-session.ts in place of its count and insert, with the verified customer_id passed in; select ... for update on the session row (database functions). Revoke execute from anon and authenticated (revoke execute on function public.reserve_place(uuid, int) from anon, authenticated), or any signed-in user could call it through /rest/v1/rpc/; only the server function should reach it. Count paid, walk_up and pending rows whose hold_expires_at is in the future; the webhook clears hold_expires_at on completed, the expired handler cancels the row, and the webhook can flag an over-capacity booking for staff.

08 · Next · Hosting & ownership · Preview deployments use the live database and live Stripe key

What it is. Every Vercel variable, secrets included, is set for all three environments (KEY-09), although each can carry its own values (environments, environment variables); so any branch becomes a public URL running against the live database and live Stripe key, with Deployment Protection off (HOS-09, HOS-10).

What could happen. A test booking on a preview is a real booking and charge; a shared preview link is a second door into production.

How to fix it. Point Preview at the test Supabase project and Stripe test keys, with a test-mode webhook endpoint and signing secret; keep Production values Production-only. Turn on Deployment Protection with Vercel Authentication, available on Hobby (deployment protection, Hobby plan).

Effort. Small.

How to verify it is fixed. Push a branch: its requests go to the test project; the URL asks for a Vercel sign-in.

09 · Next · Stability & code · Nothing tells you when something fails, and the logs that exist contain customer emails

What it is. No error tracking, uptime check or webhook alert (STA-06, PAY-09): a failing webhook shows only in Stripe’s deliveries tab and in Vercel’s runtime logs, which Hobby keeps for one hour (runtime logs), and Stripe stops retrying after three days (webhooks). Meanwhile the webhook logs the whole Checkout Session, customer_details included, and create-checkout-session.ts the request body (DAT-12). Whether the webhook has ever failed I could not tell; the logs do not go back far enough.

What could happen. A deploy breaks the webhook, customers pay, bookings stay pending, and the first you hear is a complaint; logs are one more place personal data lives.

How to fix it. Error tracking on the functions and the front end, alerting an inbox someone reads; an uptime check on the timetable page; until then, check the deliveries tab weekly. Log IDs only; on Pro (11) logs are kept for a day, so the clean-up matters more (A09:2025).

Effort. Small.

How to verify it is fixed. A deliberate 500 in the webhook on the test copy raises an alert; grep -n console.log api/ shows no payload logging.

10 · Next · Hosting & ownership · The domain could lapse

What it is. Registered in your name, which is right; but auto-renew is off, it expires on 14 February 2027, the contact email is an old ISP address, there is no second contact and the card on file expired (HOS-04). DNS is there too, so that account also decides where the site resolves.

What could happen. The reminders go unread and the website, the staff page and every booking-email link stop working in pre-season; a lapsed domain can be slow to recover, or lost.

How to fix it. Auto-renew on; update the email and the card; add a second contact; put the expiry in the business calendar a month early.

Effort. Small.

How to verify it is fixed. The registrar shows auto-renew on, a current card, two contacts and an expiry a year further out.

11 · Next · Hosting & ownership · Everything sits on one person’s personal accounts

What it is. Every account is yours alone, with no second owner or recovery contact (HOS-05). Two-factor is on for GitHub, which requires it (mandatory 2FA), and Stripe; off for Vercel, Supabase and the AI provider (HOS-06, AI-10). Vercel is on Hobby, which Vercel restricts to non-commercial, personal use with no team features (Hobby plan) and whose Instant Rollback reaches only the previous deployment (instant rollback) (HOS-07). The builder’s app has write access to main (HOS-11).

What could happen. Lose a phone or an email account, or be unavailable in season, and nobody can get into the systems running the business; the Supabase account, holding every customer’s details, is protected by a password alone. Whether this app fits Hobby’s terms is between you and Vercel.

How to fix it. Two-factor on for Vercel, Supabase and the AI provider today; a Pro team on Vercel and a second owner on the Supabase organisation with MFA enforced, which an organisation owner on Pro can do (MFA); recovery codes in a business-owned password manager; a decision on the builder’s write access once 03 is in place.

Effort. Small.

How to verify it is fixed. Each dashboard shows two owners and MFA on (Supabase: enforced); the second person can sign in to each.

12 · Next · AI features · The availability box has no limits

What it is. Once 02 moves the call server-side, api/ask-availability.ts is a public endpoint that spends your money per call, so this belongs in the same change. No cap on the question or the answer, no rate limit, no spending limit or alert at the provider (AI-07, AI-08); every question and answer goes into ai_queries, kept forever (AI-09); and on the test copy the box invented a session for a date outside its list (AI-05, AI-06): a wrong answer, not a leak, but still a customer turning up for a session that does not exist.

What could happen. A script runs up the bill overnight; customers are told sessions exist that don’t; ai_queries accumulates whatever people type, phone numbers included.

How to fix it. In the new function: cap the question (500 characters is plenty) and answer; rate-limit by address and user; set a spending limit or alert at the provider; have the system prompt say only the listed sessions exist and answer dates outside the list with “I can only see the next two weeks”: the controls OWASP names for unbounded consumption (LLM10:2025). Give ai_queries the retention rule in 15.

Effort. Small, done with 02.

How to verify it is fixed. A loop from one address is refused after the limit; a question outside the window gets the “next two weeks” answer; old ai_queries rows go.

For a developer (illustrative). Move the ai_queries insert into the function and drop the open with check (true) policy; nothing in the browser then writes to it.

13 · Next · Who can see what · Password reset emails don’t reach customers

What it is. No custom SMTP, so Auth email goes through Supabase’s default service, best-effort and limited to addresses on the project’s team (custom SMTP); sign-up works only because “Confirm email” is off, and a reset for an invented customer never arrived (WHO-11). Minimum password length is 6 against Supabase’s recommended 8, and leaked-password protection is off (password security); the redirect list holds https://**.vercel.app/**, wider than Supabase’s preview pattern (redirect URLs) (WHO-12). The default rate limits are in place and worked (rate limits) (WHO-13).

What could happen. A customer who forgets their password cannot get back in; in season, that is phone calls to you. Short passwords are the only lock on accounts holding names and phone numbers; a reset link could be pointed at any Vercel-hosted site.

How to fix it. Connect a transactional email provider through custom SMTP (live and test), then turn “Confirm email” back on; minimum length 8; leaked-password protection on; narrow the redirect list to the production URL plus the preview pattern.

Effort. Small.

How to verify it is fixed. A reset for a fresh test address arrives; a sign-up gets a confirmation email; a 6-character password is refused; the redirect list shows the production URL and one preview pattern.

14 · Later · Keys & secrets · Stripe uses the account’s full secret key, never rotated

What it is. STRIPE_SECRET_KEY is the default unrestricted sk_live_ key, never rotated (KEY-04), where Stripe recommends restricted keys and rotation when people with access move on (API keys). It has been seen by the builder (KEY-03) and is not marked Sensitive in Vercel; the webhook signing secret has never been rolled. It is read only in api/, which is why this is Later.

What could happen. If it leaks, an unrestricted key can do anything the account can, refunds and payouts included; a restricted key could only create Checkout Sessions.

How to fix it. Create a restricted key with what the two functions use, set it for Production as Sensitive, deploy, then rotate the old key (Dashboard rotation gives a grace period); roll the signing secret too, as Stripe recommends periodically (webhooks). After 08, so previews are on test keys.

Effort. Small.

How to verify it is fixed. The API keys page shows the old key expired and a restricted key in use; a test booking completes.

15 · Later · Data & backups · Nothing is ever deleted, and a customer can’t be

What it is. No retention rule for bookings, profiles or ai_queries; the oldest rows date from the first week (DAT-10). Deleting a user from the dashboard fails: bookings.customer_id references profiles with Postgres’s default NO ACTION, so the delete errors on the first booking (constraints) (DAT-11); one deletion request could not be actioned.

What could happen. A deletion request cannot be honoured without a developer, and the personal data held grows every season. How long to keep it is your decision, with whoever advises you on records; the ICO publishes general guidance (storage limitation). Today there is no decision and no way to act on one.

How to fix it. First your decision, with whoever advises you on records: how long to keep bookings. Then on delete set null on bookings.customer_id; an anonymise_customer(uuid) function that blanks the profile and auth record; a Supabase Cron job (cron) to delete old ai_queries and apply the bookings rule; a handover note on running it for a request.

Effort. Medium.

How to verify it is fixed. Deleting a test customer succeeds and their bookings remain anonymised; select min(created_at) from ai_queries is within the window.

For a developer (illustrative). anonymise_customer as security definer with search_path set (database functions); revoke execute from anon and authenticated, so it runs only from the dashboard or the job.

16 · Later · Stability & code · Packages: one advisory, two unused, no alerts

What it is. npm audit reports one high-severity advisory via the date picker, fixable without a major version change (npm audit) (STA-07). Two packages are never imported (STA-09). Every package name resolves to the expected package and nothing looked made-up (STA-08), which matters because language models are known to recommend packages that do not exist (Spracklen et al., USENIX Security 2025); supply chain failures are OWASP A03:2025 (A03). Dependabot alerts are off (Dependabot alerts) (STA-10).

What could happen. Known weaknesses stay unpatched until someone happens to look; unused packages are extra surface for the next advisory.

How to fix it. npm audit fix; remove the two packages; enable Dependabot alerts; npm audit --audit-level=high in the workflow from 03.

Effort. Small.

How to verify it is fixed. npm audit reports no high advisories; npm ls no longer lists the two; Dependabot alerts show as enabled.

17 · Observation · Stability & code · Three small performance items

What it is. The Performance Advisor flags bookings.session_id and bookings.customer_id as unindexed and the bookings policies for running auth.uid() per row (lint list); Supabase’s guidance is (select auth.uid()) and an index on every column a policy filters (RLS). The functions run in Vercel’s default region, Washington DC (iad1), with the database in London, and no vercel.json sets a region (function regions) (STA-11). Nothing felt yet.

What could happen. Nothing today; the staff “today” list and “My bookings” slow first, and the region gap adds a fraction of a second to every checkout start and webhook.

How to fix it. When 01 rewrites the policies, use (select auth.uid()) = customer_id and add the indexes in the same migration; add vercel.json with "regions": ["lhr1"].

Effort. Small.

How to verify it is fixed. The Performance Advisor shows no warnings for bookings; the deployment summary shows lhr1.

18 · Observation · Data & backups · Backups exist; a restore has never been rehearsed

What it is. Seven days of daily backups; Point-in-Time Recovery, an add-on, not enabled; Storage objects not in database backups, so the session photos are in none; a restore takes the project offline while it runs (backups) (DAT-05 to DAT-08), which is why I did not run one on live. You have never restored anything; Stripe holds every payment independently, and the timetable is in the paper diary.

What could happen. A bad migration or a mistaken delete can be undone to the last daily backup; whatever happened since is lost unless PITR is on. In peak season, that is a day of bookings.

How to fix it. Rehearse a restore on the test project, following Supabase’s steps; time it; write it down where a second person can find it. Decide whether losing a day is acceptable; if not, enable PITR for the season. The photos are re-uploadable from your phone.

Effort. Small.

How to verify it is fixed. A written procedure exists and has been followed once; the PITR decision is recorded; the second owner (11) knows where it is.

19 · Keep · Data & backups · Live and test are apart, and the keys are where they should be

What it is. Two projects, both in London; test is seeded from supabase/seed.sql, and I read profiles and bookings there in full: no live data (DAT-01, DAT-02). Local development points at test and Stripe test mode; .env* is ignored and .env.example lists every variable, valueless (KEY-08). The publishable key in the browser is by design; the server secrets exist only in Vercel, read only in api/ (API keys) (KEY-06, KEY-07). stripe_events has no policies. The migrations match live except bookings.updated_at, added by hand (DAT-09); add it to 01’s migration.

How to fix it. Nothing: keep seeding from seed.sql, never a live export; keep .env* ignored. After 08, previews join this list.

Effort. n/a.

How to verify it stays that way. If anyone proposes copying live data into test “to reproduce a bug”, this is the finding to point at.

What is working well

Beyond 05 and 19, these passed:

  • RLS on all five tables; auth.users unreachable (WHO-01); profiles policies name the owner (WHO-06).
  • Both functions verify the caller (WHO-08); handle_new_user() pins its search_path (WHO-09).
  • Server secrets server-side only and absent from the history (KEY-05, KEY-07); no connection string anywhere (KEY-10); two connected apps, both known (KEY-11).
  • All testing in test mode; no live charges (PAY-11).
  • Personal data limited to what a booking needs, and written down with its recipients (DAT-03, DAT-04).
  • The AI feature: one call, nothing personal sent, no tools (AI-01, AI-02, AI-04).
  • Clean build, strict on (STA-01); every package is what it claims to be (STA-08); journeys complete on both screen sizes (STA-12); code, hosting and domain in your name, over HTTPS (HOS-01 to HOS-03).

8. Personal data and the AI feature

Everything in this section is a record from the code and the dashboards; the limits in section 4 apply.

What the app holds, and where. Section 3’s inventory lists every place data lives, who can reach it and how long it is kept. The personal data: email and password hash (auth.users); name and phone (profiles); each customer’s bookings (bookings, readable today by any signed-in user, 01); name and email with each payment, in Stripe; free-text availability questions, with customer_id when signed in (ai_queries); name and email in webhook payloads in Vercel’s runtime logs (09); daily backups of all of the above at Supabase (18). Card details are held by Stripe only (PAY-02).

Where it flows. Supabase in London stores it. Stripe receives the name, email and payment. The two Vercel Functions run in Vercel’s default US region (17), so each checkout start (with the customer’s token) and each webhook (with their name and email) runs and is logged there (09). GitHub holds code, and (per KEY-02) a key that was once committed; it holds no personal data. The builder’s platform holds the code, the chat history and two secrets, not customer records (HOS-12).

What reaches the AI provider. Three things, on every question: a fixed system prompt, the question exactly as typed, and the next fourteen days’ published sessions (start time, craft, places left). Nothing from bookings or profiles is sent, and the feature has no way to read them, so steering it produces a wrong answer rather than a leak (AI-02, AI-04; OWASP LLM01). The gap is the question itself: free text, so a phone number typed into it reaches the provider and ai_queries, kept indefinitely (12, 15). The provider’s handling is outside this review (AI-11).

What I established, and what I did not. Established: the inventory in section 3 (DAT-03, DAT-04); live and test apart (19); card data never touching the app; the AI feature unable to reach data. Not established: whether anyone has already used 01 to read other customers’ details, which the available logs cannot show (09); what the builder’s platform retains; how long this data should be kept, a decision not yet made (15).

Finding 01 and the ICO. Any signed-in customer could have listed every other customer’s name, phone number and booking times, and unauthorised access to personal data is within the ICO’s definition of a personal data breach (personal data breaches: a guide). Whether anything needs reporting, and to whom, is your decision, made with that guide or with legal advice; I have told you what I found so that you can make it, and will not advise on it. The ICO’s storage-limitation guidance (storage limitation) is the background to the retention decision in 15, on the same footing: general information, not legal advice.

9. Repair, rebuild a part, or start again

A health check ends with one of three recommendations. These are the tests applied here, written down so you can see why this app lands where it does and a developer can disagree with the reasoning, not the conclusion.

What I look atRepairRebuild a partStart again
The serious findingsNarrow: a policy, a file, a settingConcentrated in one area whose design cannot holdSpread across most areas, or unfixable in isolation
The data modelExpresses the business; lives in migrationsMostly sound; one part cannot express the businessDoes not express the business, or schema and code disagree
PaymentsServer-set amount, hosted card entry, verified webhook, idempotent fulfilmentHandled in the browser or unverifiable; the rest is fineMoney is already wrong and the record untrustworthy
Build and deployBuilds from a clean clone; deploys the same way every timeBuilds, but one area needs its own rebuildCannot be built from a clean clone
ReadabilityA developer can follow it; duplication is local and namedOne module nobody dares touchThe whole thing is that module
Cost logicEach fix costs less than what it protectsReplacing one part costs less than patching it every seasonPatching costs more than a scoped rebuild

Why this app is a repair.

  1. The two Fix now findings are narrow: 01 is one migration’s policies plus a staff table; 02 is one component moved into one function and a key rotated; both can be contained from a dashboard today.
  2. The expensive parts are right: the data model expresses the business and lives in migrations (19); payments are correct where it matters (05), and 07 is a capacity rule around checkout, not a change to how money moves; the app builds from a clean clone and deploys consistently.
  3. The rest is housekeeping: 06, 08, 09, 10, 11, 12 and 13 are Small and batch by platform (section 10); the Medium items (01, 03, 04, 07, 15) are work a rebuilt app would need anyway.
  4. The duplication is local: 04 is three forms and one function needing one shared module.
  5. A rebuild would spend money re-creating 05 and 19 to get back to fixing 01.

The alternative I considered, and rejected: rebuild authorisation. Staff access is a list of emails in the browser and the database cannot tell staff from customers, which is behind 01 and 06; that looked like an area to replace. It is not: bookings.customer_id already exists, a user_roles table is additive, and Supabase’s own pattern for roles, a table plus a claim in the token (RBAC), slots in without changing a page.

What would change my mind. If, once 01 is in place, the builder keeps regenerating open policies: a workflow problem, not a code problem, and why we agree where future work happens before any fix starts (section 13). If a real restore (18) showed the backups cannot be relied on: 18 moves to the top of the list.

10. A sequenced plan

You means a dashboard action needing no code; Developer means code, a migration or a deployment; Both means your decision followed by developer work. Effort bands are the register’s; no dates are attached, since each batch is quoted and approved before it starts, on the terms in section 13.

Before anything else is built

StepWhatWhoDepends on
1Agree where future work happens: the builder, the repository, or both with a rule for which wins (section 13); until then, any sync can undo any fix.BothNothing; everything else depends on it
2The three dashboard actions in section 1: contain 01, contain 02, two-factor on (11).YouNothing
3Fix 01 in one migration: the owner-keyed select policy and two customer functions on bookings, user_roles and the staff policies (staff insert included), the staff-notes table (06), (select auth.uid()) throughout, the indexes (17), bookings.updated_at (19). Medium.DeveloperStep 1
4Fix 02 and 12 in one change: api/ask-availability.ts holding a new key, set for Production (a Preview key once 08 is done), with question, answer and rate limits; a spending limit or alert at the provider. Small.Developer; you, for the limitStep 1
503: a test runner and the minimum tests, run on every push, so steps 3 and 4 stay proved. Medium.DeveloperSteps 3 and 4

After the first batch, in order.

  • 10 · You · Small. Auto-renew on, a current card, a second contact, the expiry in the business calendar.
  • 13 · You, or a developer · Small. Custom SMTP for live and test; “Confirm email” back on; password minimum raised; leaked-password protection on; redirect list narrowed.
  • 11 (Supabase) · You · Small. A second owner on the Supabase organisation, with MFA enforced.
  • 08, 09, 11, 17 (Vercel) · Developer and you · Small. Preview values on the test project and Stripe test keys with a test-mode webhook endpoint, and vercel.json with "regions": ["lhr1"] (Developer); Deployment Protection on, a Pro team with a second owner, Stripe’s deliveries tab read weekly until alerting exists (You); the log lines changed to IDs only and error tracking added (Developer); the builder’s write access decided once step 5 is in place (Both).
  • 18 · You, or with a developer · Small. A restore rehearsed on the test project, timed and written down; then the PITR decision.
  • 07 · Developer · Medium. Depends on 04; to protect the last place before next season, 04 comes forward with it.

When convenient

  • 04 · Developer · Medium. One booking-rules.ts, a constraint on status, a one-off clean-up of existing rows. Before 07.
  • 14 · You and a developer · Small. After 08: a restricted Stripe key, the old one expired, the webhook secret rolled.
  • 15 · Both · Medium. Your retention decision first; then the foreign key change, the anonymise function and the scheduled job, which also covers 12.
  • 16 · You and a developer · Small. Dependabot alerts on; npm audit fix; the two unused packages removed; npm audit in the workflow from 03.

11. Ownership and handover checklist

What a second person would need to run this app if you were unavailable. Everything not listed was in place: accounts in your name, two-factor on GitHub and Stripe, DNS, the Supabase secret key and database password, .env.example.

ItemTodayState
Hosting planPersonal Vercel account, Hobby planReview (11)
A second owner or recovery contact per accountNoneMissing (11)
Two-factor on Vercel, Supabase and the AI providerOff; not enforced on the Supabase organisationMissing (11)
Recovery codes in a business-owned password managerIn your personal managerMissing (11)
Plans and limitsPITR off; no AI spending limitLimit (12); PITR decision (18)
RenewalsAuto-renew off; card expired; domain expires 14 February 2027Missing (10)
Registrar contact detailsOld ISP address; no second contactMissing (10)
Stripe secret key and webhook secretUnrestricted, never rotated, seen by the builder, not SensitiveRotate (14)
AI provider keyIn the bundle, the history and the builderExposed (02)
Preview deployments on test keysLive keys everywhereMissing (08)
Third parties with write access to the codeThe builder’s GitHub appDecision (11)
Migrations match the live databaseExcept bookings.updated_atNearly (19)
A written restore procedureNoneMissing (18)
A written deletion-request procedureNone; one request could not be actionedMissing (15)
Notes for a second developer, and for AI tools, on what not to changeNone; 05 and 19 are the startMissing

12. What you can do with this report

If a customer asks. Security questionnaires ask what protects customer data, who can reach it and how problems get found. Appendix A records what was checked, the register what was found and what remains, section 3 where data lives, and section 8 what that means. You write the answers, and they stay yours.

For your own ISO 27001 work. The findings give written technical detail about this one app to feed your own information security work; it is not an audit against the standard, says nothing about whether you meet it, and does not replace your certification body’s audit.

If your app uses AI. Section 8 and the AI checks in Appendix A record which provider receives data, what leaves your systems, and what happened when I tried to steer the feature (see the limits in section 4).

What this is not. The limits in section 4 apply to every use above. Whether a customer, an insurer or a certification body is satisfied is their decision, not this report’s. Anyone you share it with should read section 4 first; the findings describe the app at one commit on one date.

13. Next steps

  1. Decide the containment steps for 01 and 02. Both are dashboard actions you can take today, set out in each finding and in section 10. Tell me what you decide, so the fixes are scoped against the app as it then stands.
  2. Fixes are quoted from these findings in a written proposal. You choose what to tackle and when, and you approve each batch before I start. If you book fixes within 30 days of this report, the full £495 is deducted from them. Larger apps, hosting, third-party subscriptions and any ongoing care are quoted separately.
  3. Before any fix starts, we agree where future work happens, so that the builder and the repository do not undo each other. The handover includes notes for your AI tools, so new work is less likely to undo the fixes.
  4. Rehearse a restore of the database backup on the test project, and write the steps down where a second person can find them (18).
  5. Ongoing care, if you want it, is scoped separately: reviewing larger changes before release, keeping packages up to date, watching for errors and investigating when something breaks.

Nothing on the live app was changed during this review, and the access agreed for it has been removed.

14. Glossary

One line each, for the terms an owner meets in this report.

  • Row Level Security (RLS). Rules on a table that decide which rows each request may read or change.
  • Policy. One such rule. auth.uid() is not null means “anyone signed in”; auth.uid() = customer_id means “only this row’s owner”.
  • Publishable key and secret key. Supabase’s two keys: the publishable key is for the browser, bounded by RLS; the secret key bypasses RLS and stays on the server.
  • Client bundle. The JavaScript the browser downloads; anything in it, anyone can read, including every VITE_-prefixed variable.
  • Migration. A file of database changes kept in the repository.
  • Webhook. A message Stripe sends the app when something happens, such as a payment completing; the signing secret proves it came from Stripe.
  • Point-in-Time Recovery (PITR). A Supabase add-on allowing a restore to a chosen moment rather than the last daily backup.
  • Containment. The immediate step that closes a door while the fix is built, such as disabling a policy or deleting a key.

Appendix A — Checklist results

Every check, with its result and the finding it feeds. “Pass” means what was looked for was right; “Finding” points to the register; “Not applicable” means the app has nothing of that kind; “Not checked” records what I did not do, and why.

Who can see what

IDCheckResultNote / finding
WHO-01Row Level Security is enabled on every table the Data API can reach, and the auth schema is not exposed.PassAll five tables; auth.users unreachable. Enabled is not correct.
WHO-02Each bookings policy identifies the customer, not just a signed-in user.Finding01 · All three test only auth.uid() is not null; B also set B’s own status to paid.
WHO-03Writes to the timetable and uploads to the photo bucket are limited to staff.Finding01 · All four auth.uid() is not null; a customer changed a session’s deposit and uploaded a photo.
WHO-04The database can tell staff from customers.Finding01 · STAFF_EMAILS in src/lib/staff.ts, checked in the browser.
WHO-05The public timetable exposes only the columns the public should see, and only published sessions.Finding06 · using (true), no published condition; the padlock code came back unauthenticated.
WHO-06Each customer can read and change only their own profile.Passauth.uid() = id; B saw only B’s row and could not change A’s.
WHO-07The Stripe event log cannot be reached through the API.Pass19 · RLS on, no policies; every request was refused.
WHO-08The server functions check who is calling before acting.Pass05 · No token and an expired token both got 401.
WHO-09Database functions that run with elevated rights are pinned to a schema.Passsecurity definer set search_path = '', Supabase’s form (managing user data).
WHO-10The Security Advisor was run, and its results were read against the policies themselves.PassNo warnings; one informational note (WHO-07). 01 and 06 were found by reading.
WHO-11Sign-up confirmation and password reset emails reach customers.Finding13 · No custom SMTP; “Confirm email” off; the invented address’s reset never arrived.
WHO-12Password rules, leaked-password protection and the redirect allow-list are set for a live business.Finding13 · Minimum length 6; leaked-password protection off; redirect list contains https://**.vercel.app/**.
WHO-13Sign-in and sign-up are throttled against guessing.PassDefaults in place; the test copy refused within the documented window.
WHO-14Third-party sign-in (Google, Apple and similar) is set up safely.Not applicableOnly email and password; a later provider joins WHO-12.

Keys & secrets

IDCheckResultNote / finding
KEY-01No secret value reaches the browser bundle.Finding02 · VITE_AI_API_KEY in the built bundle and on the live site.
KEY-02No secret is in the repository’s history.Finding02 · Two early commits track .env with the AI key; no Stripe or Supabase secret in history.
KEY-03No secret has been pasted into an AI chat or a builder.Finding02 · VITE_AI_API_KEY and STRIPE_SECRET_KEY both pasted into the builder’s chat (14).
KEY-04The Stripe secret key is scoped to what the app does, has been rotated, and is readable by as few people as possible.Finding14 · Default unrestricted sk_live_ key, never rotated, not Sensitive, seen by the builder.
KEY-05The webhook signing secret is held server-side only.Pass05 · No VITE_ prefix; read only in the webhook; never rolled (14).
KEY-06The Supabase key in the browser is the publishable key, not a secret key.Pass19 · Publishable key only; the secret key exists once, in Vercel.
KEY-07The Supabase secret key is used only by server code, and only for what needs it.Pass19 · Two uses, both in api/; nothing in src/.
KEY-08Local secrets are ignored by git, documented without values, and point at test.Pass19 · .env* ignored; .env.example has no values; .env.local points at test.
KEY-09Production secrets are not shared with the preview and development environments.Finding08 · Every variable, server secrets included, in all three environments.
KEY-10The database password is held by you only, not by the app.PassNo connection string in the code or Vercel; the password is in your personal manager (11).
KEY-11Access tokens and connected apps on the code account are known and needed.PassTwo apps, the builder and Vercel; no tokens or deploy keys (11).

Payments

IDCheckResultNote / finding
PAY-01The amount charged is set by the server from the database, never by the browser.Pass05 · Tampered amount and price fields ignored; the database price was charged.
PAY-02Card details never touch the app.Pass05 · No card fields in the code; nothing card-shaped in logs or tables.
PAY-03The webhook verifies Stripe’s signature on the raw request body.Pass05 · Forged signature and altered body: 400, no change.
PAY-04A booking is marked paid from the webhook, after checking the payment status, never from the success page.Pass05 · Opening the success URL early showed pending and changed nothing.
PAY-05The same event delivered twice leaves one paid booking.Pass05 · One paid booking after three deliveries.
PAY-06Every payment can be matched to a booking and to Stripe’s record.Pass05 · Every sampled paid booking matched a Checkout Session.
PAY-07The capacity rule is enforced at the moment a place is taken, not only before checkout starts.Finding07 · Two paid bookings against a capacity of one; no lock between count and insert.
PAY-08Abandoned checkouts release their hold.Finding07 · Subscribed to completed only; nothing handles expired.
PAY-09A failed webhook delivery would be noticed.Finding09 · No alerting; nobody looks at the deliveries tab.
PAY-10Bookings taken without payment (walk-ups) are distinguishable from paid ones.Finding04 · confirmed, paid and Paid all in use; Today.tsx filters on paid (STA-05).
PAY-11All testing, mine included, ran in Stripe test mode against the test project.Pass19 · No live charges; previews are the exception (08).
PAY-12Refunds and disputes are handled somewhere known.Not checkedRefunds by hand in the Stripe Dashboard; no dispute raised. Outside the app.
PAY-13Subscriptions, saved cards and recurring charges are set up safely.Not applicableOne-off deposits only; nothing saved or recurring.

Data & backups

IDCheckResultNote / finding
DAT-01Live and test are separate projects with separate keys.Pass19 · Both projects in London; the review used test; previews are the gap (08).
DAT-02The test project holds invented data only.Pass19 · Every row on test comes from the seed file.
DAT-03The personal data held is recorded and limited to what a booking needs.PassListed in section 8; no card data, addresses or dates of birth.
DAT-04Everyone who receives personal data from the app, and where it is processed, is listed.PassSupabase (London); Stripe; Vercel (US region, 17); the AI provider; GitHub (code).
DAT-05Daily backups are running and retained.PassSeven daily backups; PITR not enabled, so up to a day could be lost (18).
DAT-06A restore has been rehearsed and the steps are written down.Finding18 · Never restored; nothing written down; Stripe and the paper diary are the fallback.
DAT-07A restore of the live project was run during the review.Not checkedRestoring takes the project offline: a live change not made without agreement (backups).
DAT-08Uploaded files are covered by a backup.Finding18 · Session photos in no backup; re-uploadable marketing photos.
DAT-09Database changes are captured in migrations and match the live schema.Pass19 · One difference, bookings.updated_at, added by hand (01’s migration).
DAT-10A retention rule exists and is applied.Finding15 · No rule, no job; the oldest rows are from the first week.
DAT-11A customer’s deletion request can be actioned.Finding15 · The delete failed on the first booking; profiles cascades, bookings blocks.
DAT-12Personal data does not end up in logs.Finding09 · The webhook logs the whole Checkout Session; create-checkout-session.ts logs the request body.

AI features

IDCheckResultNote / finding
AI-01Every AI call in the code is located, with its provider and endpoint recorded.PassOne call, AskBox.tsx to the chat endpoint, plain fetch.
AI-02What is sent to the provider is recorded and contains nothing personal by design.PassSystem prompt, the question as typed, and each published session’s start, craft and places left.
AI-03The provider’s key is held server-side.Finding02 · In the bundle, the live site, two early commits and the builder’s chat.
AI-04What the feature can do if steered: it has no tools and no access to data.PassNo tools, no follow-up calls, no writes; the worst answer is a wrong one.
AI-05A steering attempt was tried and the result recorded.Finding12 · The padlock question got “I don’t have that information”; the bookings request got a made-up list.
AI-06Answers are limited to sessions that exist, and the customer is told what the box can see.Finding12 · A date three weeks out got an invented session time.
AI-07The question and the answer are capped in size, and callers are rate-limited.Finding12 · No length cap, large maximum answer, no rate limit; the loop ran until I stopped it.
AI-08A spending limit or usage alert is set at the provider.Finding12 · Pay-as-you-go, no limit, no alert; the usage log looks normal, but the key was public (02).
AI-09What the app records about each question: who can read it, who can write it, and how long it is kept.Finding12 · Every question and answer, with customer_id when signed in; you did not know it existed.
AI-10The provider account is owned by the business and protected.Finding11 · Your personal account, no second member, two-factor off.
AI-11How the provider handles what it receives.Not checkedThe provider’s own system; its terms are yours to read against AI-02.

Stability & code

IDCheckResultNote / finding
STA-01The app builds from a clean clone and type-checks.PassClean build, no type errors, strict on; one bundle-size warning (STA-09).
STA-02Automated tests cover the journeys that would hurt most if they broke.Finding03 · No test script, no test files, no runner.
STA-03Tests run automatically before a change reaches customers, and a red run blocks it.Finding03 · No workflow, no branch protection; the builder pushes to main.
STA-04The booking rules exist in one place.Finding04 · Three copies plus a partial fourth, drifted: no minimum in EditBooking; no places-left check in admin/CreateBooking.
STA-05bookings.status can only hold the values the app expects.Finding04 · Free text; live values pending, paid, Paid, confirmed, cancelled (PAY-10).
STA-06Errors are tracked, the site is watched, and someone is told.Finding09 · No error tracking, no uptime check, no alert on the webhook.
STA-07Packages with known weaknesses are updated.Finding16 · One high-severity advisory via the date picker; npm audit fix resolves it.
STA-08Every package is what it claims to be.PassEvery name resolves to the expected package (16).
STA-09Every package is used.Finding16 · Two dependencies never imported: a charting library and a second date library.
STA-10Dependency alerts are on.Finding16 · Off.
STA-11The queries customers wait on are indexed, policies run once per query, and the server code runs near the database.Finding17 · session_id and customer_id unindexed; per-row auth.uid(); functions in Vercel’s default US region, no vercel.json.
STA-12The customer journey completes on a phone and on a desktop.Pass05 · Every customer and staff step completed on both sizes (walk-up status: 04).

Hosting & ownership

IDCheckResultNote / finding
HOS-01The code is in a repository the business controls.PassPrivate repository under your personal GitHub account; the builder’s app has write access (HOS-11).
HOS-02The hosting project is in your account and the site is served over HTTPS.PassYour personal Vercel account; valid certificate; http redirects to https.
HOS-03The domain is registered in your name and its DNS is in an account you control.PassUK registrar, your name, DNS at the registrar pointing at Vercel; kept badly (HOS-04).
HOS-04The domain will renew, and the reminders reach someone.Finding10 · Auto-renew off; expires 14 February 2027; old ISP contact address; card expired.
HOS-05Someone other than you can get into each account that runs the business.Finding11 · Every account yours alone; no second owner or recovery contact.
HOS-06Two-factor authentication is on for every account.Finding11 · On: GitHub, Stripe. Off: Vercel, Supabase, the AI provider; not enforced on the Supabase organisation.
HOS-07The hosting plan suits a business, and the rollback it offers is enough.Finding11 · A Hobby project running a business, where Vercel’s docs say Hobby is for non-commercial, personal use; rollback one step back.
HOS-08How a change reaches customers is understood and has a gate.Finding03 · Builder commits to main, Vercel deploys within minutes; the last five deployments were all the builder’s (DAT-09).
HOS-09Preview deployments cannot touch live data or live money.Finding08 · Same values as production, live database and Stripe key included.
HOS-10Preview URLs are not open to anyone who has the link.Finding08 · Off; the preview opened without a sign-in.
HOS-11The builder’s access to the code is known and decided.Finding11 · Write access, direct pushes to main, still linked; decided once 03 is in place.
HOS-12What the builder’s platform retains about the app.Not checkedThe builder holds the code, the chat history and at least two secrets; the rest is for its terms.

Appendix B — Sources

Every page cited, grouped by publisher; each is the vendor’s or body’s own documentation, checked in September 2026. If a page has changed since, the page wins.

Supabase

Stripe

Vercel

Vite

GitHub

npm

PostgreSQL

OWASP

Research

Information Commissioner’s Office (see the limits in section 4)


Illustrative example — not a client project. Prices in GBP; any applicable VAT will be set out in your proposal before you commit. Tool and platform names describe how apps are built; I’m not affiliated with any of them.