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
This commit is contained in:
belviskhoremk
2026-08-12 09:59:33 +02:00
parent 1181d9a3be
commit 8c1522a2b4
7 changed files with 89 additions and 26 deletions

View File

@@ -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=

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist-ssr
*.local
# Environment variables
.env
.env.local
.env.*.local

36
scripts/create-admin.ts Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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'