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'