mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-08-12 20:51:37 +00:00
e18ba679be
The migrations did not describe this database, and could not be made to.
Three findings, in the order they surfaced:
* 25 functions declared across six migrations — all recorded as applied — did
not exist. Their tables did. Two were reached by the app, so merchant tier
upgrades and post-trade reputation updates had been quietly dead. This only
came to light because a user hit "Could not find the function
public.upsert_user_profile(...)" while toggling a notification setting.
* admin_roles has three conflicting definitions across the set and production
matches none of them. 001 says (id, user_id, role, granted_by, granted_at),
COMBINED says (user_id, role, created_at), production has
(id, user_id, role, permissions, created_at, updated_at).
* Applied to an empty database, five migrations fail. The legacy 0NN filenames
sort before the 14-digit timestamps they depend on — "013" < "20241117054600"
— so 013 runs before the migration creating the table it alters. The set
could never have been replayed from scratch.
So it could not be tested, could not rebuild the database, and did not match what
was running. Widening or patching it would have been dressing up a history that
was already fiction.
The baseline is a pg_dump of the live public schema, which matches production by
construction. Privileges are included deliberately: the REVOKEs on
lock_escrow_internal, release_escrow_internal, refund_escrow_internal and
request_withdraw are the 20260725030000 hardening, and dropping them would hand
fund movement back to anon.
Verified step by step against production before committing:
- applies to an empty database cleanly, after three real obstacles were fixed
(extensions live in the `extensions` schema, supabase_admin membership,
platform-level ALTER DEFAULT PRIVILEGES that cannot apply outside Supabase)
- produces 83 tables / 39 functions / 222 indexes / 360 policies — identical
counts to production
- recorded as applied in supabase_migrations without touching the 37 existing
rows, then dry-run confirmed "up to date — no pending migrations", so the
next deploy will not try to replay it over live tables
- drift check now reports "every declared function is present"; the known-gaps
list drops from 22 entries to zero
The old files move to migrations/archive/ rather than being deleted — they are
the only record of why parts of this look the way they do. Their README says
plainly not to run them, and why.
CI now applies migrations to an empty Postgres on every PR. That check was
impossible while the old set was the starting point; it is the thing that stops
this class of drift from being discovered by a user again.
98 lines
2.9 KiB
PL/PgSQL
98 lines
2.9 KiB
PL/PgSQL
-- ========================================
|
|
-- Create Secure Upsert Function for Profiles
|
|
-- ========================================
|
|
-- Uses SECURITY DEFINER to bypass RLS for authenticated users
|
|
|
|
-- First, ensure username is nullable
|
|
ALTER TABLE public.profiles
|
|
ALTER COLUMN username DROP NOT NULL;
|
|
|
|
ALTER TABLE public.profiles
|
|
ALTER COLUMN username SET DEFAULT '';
|
|
|
|
-- Create secure upsert function
|
|
CREATE OR REPLACE FUNCTION public.upsert_user_profile(
|
|
p_username TEXT DEFAULT '',
|
|
p_full_name TEXT DEFAULT NULL,
|
|
p_bio TEXT DEFAULT NULL,
|
|
p_phone_number TEXT DEFAULT NULL,
|
|
p_location TEXT DEFAULT NULL,
|
|
p_website TEXT DEFAULT NULL,
|
|
p_language TEXT DEFAULT 'en',
|
|
p_theme TEXT DEFAULT 'dark',
|
|
p_notifications_email BOOLEAN DEFAULT true,
|
|
p_notifications_push BOOLEAN DEFAULT false,
|
|
p_notifications_sms BOOLEAN DEFAULT false
|
|
)
|
|
RETURNS public.profiles AS $$
|
|
DECLARE
|
|
result public.profiles;
|
|
BEGIN
|
|
-- Use auth.uid() to ensure user can only upsert their own profile
|
|
INSERT INTO public.profiles (
|
|
id,
|
|
username,
|
|
full_name,
|
|
bio,
|
|
phone_number,
|
|
location,
|
|
website,
|
|
language,
|
|
theme,
|
|
notifications_email,
|
|
notifications_push,
|
|
notifications_sms,
|
|
updated_at
|
|
)
|
|
VALUES (
|
|
auth.uid(),
|
|
p_username,
|
|
p_full_name,
|
|
p_bio,
|
|
p_phone_number,
|
|
p_location,
|
|
p_website,
|
|
p_language,
|
|
p_theme,
|
|
p_notifications_email,
|
|
p_notifications_push,
|
|
p_notifications_sms,
|
|
NOW()
|
|
)
|
|
ON CONFLICT (id)
|
|
DO UPDATE SET
|
|
username = COALESCE(NULLIF(EXCLUDED.username, ''), profiles.username, ''),
|
|
full_name = COALESCE(EXCLUDED.full_name, profiles.full_name),
|
|
bio = COALESCE(EXCLUDED.bio, profiles.bio),
|
|
phone_number = COALESCE(EXCLUDED.phone_number, profiles.phone_number),
|
|
location = COALESCE(EXCLUDED.location, profiles.location),
|
|
website = COALESCE(EXCLUDED.website, profiles.website),
|
|
language = COALESCE(EXCLUDED.language, profiles.language),
|
|
theme = COALESCE(EXCLUDED.theme, profiles.theme),
|
|
notifications_email = COALESCE(EXCLUDED.notifications_email, profiles.notifications_email),
|
|
notifications_push = COALESCE(EXCLUDED.notifications_push, profiles.notifications_push),
|
|
notifications_sms = COALESCE(EXCLUDED.notifications_sms, profiles.notifications_sms),
|
|
updated_at = NOW()
|
|
RETURNING *
|
|
INTO result;
|
|
|
|
RETURN result;
|
|
END;
|
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
|
|
|
-- Grant execute permission to authenticated users only
|
|
GRANT EXECUTE ON FUNCTION public.upsert_user_profile TO authenticated;
|
|
|
|
-- Revoke from public for security
|
|
REVOKE EXECUTE ON FUNCTION public.upsert_user_profile FROM PUBLIC;
|
|
|
|
-- Success message
|
|
DO $$
|
|
BEGIN
|
|
RAISE NOTICE '========================================';
|
|
RAISE NOTICE 'Profile upsert function created successfully!';
|
|
RAISE NOTICE 'Function: upsert_user_profile()';
|
|
RAISE NOTICE 'This bypasses RLS using SECURITY DEFINER';
|
|
RAISE NOTICE '========================================';
|
|
END $$;
|