From 8c1522a2b4f6383b193fd7f7f04d68bfaf23a4fd Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Wed, 12 Aug 2026 09:59:33 +0200 Subject: [PATCH] chore: deploy Phase 2A backend to live Supabase project (Task 9) Substitutes the real admin UID everywhere the placeholder appeared, points the Edge Functions base URL at the deployed project, and adds production JWT verification: trigger functions now send the anon key as a bearer token so verify_jwt can stay enabled instead of leaving the functions open to unauthenticated calls from the public internet. Also adds scripts/create-admin.ts (one-off helper used to create Mira's admin auth user), documents the seed script's non-VITE env vars in .env.example, and closes a gitignore gap where plain .env wasn't excluded alongside .env.local. Claude-Session: https://claude.ai/code/session_01NhxA4npAXAxmCDEJt6kxSH --- .env.example | 5 +++ .gitignore | 1 + scripts/create-admin.ts | 36 +++++++++++++++++++ supabase/config.toml | 15 ++++---- .../20260808000001_initial_schema.sql | 28 +++++++-------- .../20260808000003_notify_triggers.sql | 28 +++++++++++++-- supabase/tests/database/rls.test.sql | 2 +- 7 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 scripts/create-admin.ts diff --git a/.env.example b/.env.example index abfdc28..5bafbe8 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ +# Frontend (exposed to the browser) VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY= + +# scripts/seed.ts (server-side only, never expose the service role key to the browser) +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= diff --git a/.gitignore b/.gitignore index 0e81d02..d0bdbe6 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local # Environment variables +.env .env.local .env.*.local diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts new file mode 100644 index 0000000..a063197 --- /dev/null +++ b/scripts/create-admin.ts @@ -0,0 +1,36 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = process.env.SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY +const adminEmail = process.env.ADMIN_EMAIL +const adminPassword = process.env.ADMIN_PASSWORD + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY environment variables before running this script.') + process.exit(1) +} + +if (!adminEmail || !adminPassword) { + console.error('Set ADMIN_EMAIL and ADMIN_PASSWORD environment variables before running this script.') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) + +async function createAdmin() { + const { data, error } = await supabase.auth.admin.createUser({ + email: adminEmail, + password: adminPassword, + email_confirm: true, + }) + + if (error) { + console.error('Failed to create admin user:', error.message) + process.exit(1) + } + + console.log('Admin user created.') + console.log('UID:', data.user.id) +} + +createAdmin() diff --git a/supabase/config.toml b/supabase/config.toml index b399984..de7965a 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -387,17 +387,16 @@ deno_version = 2 # These two functions are invoked exclusively by database triggers via # pg_net (see supabase/migrations/20260808000003_notify_triggers.sql), not -# by end-user clients carrying a Supabase JWT. Local `functions serve` -# enforces JWT verification by default; without disabling it here, pg_net's -# request (which sends no Authorization header) is rejected with 401 -# "Missing authorization header" before the function code ever runs — -# confirmed empirically via `select * from net._http_response` showing that -# exact error after a trigger fired. +# by end-user clients carrying a Supabase JWT. They're reachable over the +# public internet once deployed, so JWT verification stays enabled to stop +# anyone from POSTing directly to the URL and triggering an outbound email +# send; the trigger functions supply the anon key as a bearer token (see +# public.edge_functions_anon_key() in that migration) to satisfy it. [functions.on-booking-created] -verify_jwt = false +verify_jwt = true [functions.on-inquiry-created] -verify_jwt = false +verify_jwt = true [analytics] enabled = true diff --git a/supabase/migrations/20260808000001_initial_schema.sql b/supabase/migrations/20260808000001_initial_schema.sql index 38ae53b..8688820 100644 --- a/supabase/migrations/20260808000001_initial_schema.sql +++ b/supabase/migrations/20260808000001_initial_schema.sql @@ -81,48 +81,48 @@ alter table public.inquiries enable row level security; create policy "products_public_read" on public.products for select using (true); create policy "products_admin_write" on public.products - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- services: public read, admin write create policy "services_public_read" on public.services for select using (true); create policy "services_admin_write" on public.services - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- schedule_template: public read, admin write create policy "schedule_template_public_read" on public.schedule_template for select using (true); create policy "schedule_template_admin_write" on public.schedule_template - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- schedule_exceptions: public read, admin write create policy "schedule_exceptions_public_read" on public.schedule_exceptions for select using (true); create policy "schedule_exceptions_admin_write" on public.schedule_exceptions - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- bookings: anyone can insert (submit a booking); only admin can read/update/delete create policy "bookings_public_insert" on public.bookings for insert with check (true); create policy "bookings_admin_all" on public.bookings - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- booked_slots: public read; only admin policy exists for writes (in practice only -- the security-definer trigger in the next migration ever writes here) create policy "booked_slots_public_read" on public.booked_slots for select using (true); create policy "booked_slots_admin_write" on public.booked_slots - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); -- inquiries: anyone can insert; only admin can read/update/delete create policy "inquiries_public_insert" on public.inquiries for insert with check (true); create policy "inquiries_admin_all" on public.inquiries - for all using ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid) - with check ((select auth.uid()) = '00000000-0000-0000-0000-000000000001'::uuid); + for all using ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid) + with check ((select auth.uid()) = '21c90224-e0b2-412e-a431-8e7f097c1cef'::uuid); diff --git a/supabase/migrations/20260808000003_notify_triggers.sql b/supabase/migrations/20260808000003_notify_triggers.sql index 3dcfd09..0449459 100644 --- a/supabase/migrations/20260808000003_notify_triggers.sql +++ b/supabase/migrations/20260808000003_notify_triggers.sql @@ -59,7 +59,23 @@ returns text language sql immutable as $$ - select 'http://host.docker.internal:54321/functions/v1' + select 'https://ylxppnclfpnnolnjkbwv.supabase.co/functions/v1' +$$; + +-- The Edge Functions gateway enforces JWT verification (verify_jwt = true, +-- see supabase/config.toml) in production, since these functions are +-- reachable over the public internet and would otherwise let anyone trigger +-- an outbound email send by POSTing directly to the URL. pg_net's request +-- carries no Authorization header by default, so the trigger must supply +-- one. The anon key is not a secret (it already ships in the frontend +-- bundle), so embedding it here as a literal, single-source-of-truth +-- constant is safe — the same pattern as edge_functions_base_url() above. +create or replace function public.edge_functions_anon_key() +returns text +language sql +immutable +as $$ + select 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlseHBwbmNsZnBubm9sbmprYnd2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODY0NTkyNzYsImV4cCI6MjEwMjAzNTI3Nn0.j0UaxlBvDctsDOXxPuWj43XvbObtX68-mjNf3AAefX8' $$; create or replace function public.notify_booking_created() @@ -71,7 +87,10 @@ as $$ begin perform net.http_post( url := public.edge_functions_base_url() || '/on-booking-created', - headers := jsonb_build_object('Content-Type', 'application/json'), + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || public.edge_functions_anon_key() + ), body := jsonb_build_object('record', to_jsonb(new)) ); return new; @@ -92,7 +111,10 @@ as $$ begin perform net.http_post( url := public.edge_functions_base_url() || '/on-inquiry-created', - headers := jsonb_build_object('Content-Type', 'application/json'), + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', 'Bearer ' || public.edge_functions_anon_key() + ), body := jsonb_build_object('record', to_jsonb(new)) ); return new; diff --git a/supabase/tests/database/rls.test.sql b/supabase/tests/database/rls.test.sql index e17b45a..6a5436d 100644 --- a/supabase/tests/database/rls.test.sql +++ b/supabase/tests/database/rls.test.sql @@ -88,7 +88,7 @@ select throws_ok( -- The admin (matching UID) can read bookings set local role authenticated; -set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001'; +set local request.jwt.claim.sub = '21c90224-e0b2-412e-a431-8e7f097c1cef'; select isnt_empty( $$ select * from public.bookings $$, 'admin can read bookings'