create-bw-app 0.27.4 → 0.27.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,232 @@
1
+ -- auth_verified_admin_acceptance
2
+ -- target: admin
3
+ -- created_at: 2026-10-14T18:31:38.413Z
4
+
5
+ -- One atomic statement protects old CLI/manual continue-on-error application:
6
+ -- failed prerequisites or later DDL leave every existing guard/function intact.
7
+ DO $identity_migration$
8
+ BEGIN
9
+ IF to_regprocedure('public.assert_profile_auth_identity_v1()') IS NULL THEN
10
+ RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply Core 20261014183138_protect_profile_identity before Admin/Orgs identity migrations.' USING ERRCODE = '55000';
11
+ END IF;
12
+ PERFORM public.assert_profile_auth_identity_v1();
13
+ EXECUTE $identity_ddl$
14
+ -- Core now guards browser identity writes for every global role. Its invoker
15
+ -- UPDATE guard also permits the restricted Auth sync when invoked from staff
16
+ -- sessions; the former definer guard incorrectly rejected that trusted sync.
17
+ DROP TRIGGER IF EXISTS guard_privileged_profile_fields ON public.profiles;
18
+ DROP FUNCTION IF EXISTS public.guard_privileged_profile_fields();
19
+
20
+ -- atomic_admin_invitation_acceptance
21
+ -- target: admin
22
+ -- created_at: 2026-09-06T10:16:16.616Z
23
+
24
+ CREATE OR REPLACE FUNCTION public.accept_admin_user_invitation(p_invitation_id uuid, p_profile_id uuid, p_user_email text)
25
+ RETURNS jsonb LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
26
+ DECLARE
27
+ v_invitation public.admin_user_invitations%ROWTYPE;
28
+ v_user_id uuid;
29
+ v_old_role text;
30
+ BEGIN
31
+ v_user_id := public.lock_confirmed_profile_auth_identity(p_profile_id, p_user_email);
32
+ -- Lock the same ordered set as direct assignment, including terminal retries.
33
+ SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id;
34
+ IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
35
+ PERFORM id FROM public.admin_user_invitations
36
+ WHERE lower(btrim(invited_email)) = lower(btrim(v_invitation.invited_email))
37
+ ORDER BY id FOR UPDATE;
38
+ SELECT * INTO v_invitation FROM public.admin_user_invitations WHERE id = p_invitation_id FOR UPDATE;
39
+ IF NOT FOUND THEN RAISE EXCEPTION 'Convite não encontrado.'; END IF;
40
+ IF lower(btrim(p_user_email)) IS DISTINCT FROM lower(btrim(v_invitation.invited_email)) THEN
41
+ RAISE EXCEPTION 'Este convite pertence a outro email.';
42
+ END IF;
43
+ -- Recheck the profile link after Auth and invitations are locked.
44
+ PERFORM p.id FROM public.profiles p
45
+ WHERE p.id = p_profile_id AND p.user_id = v_user_id
46
+ AND lower(btrim(p.email)) = lower(btrim(v_invitation.invited_email))
47
+ FOR UPDATE OF p;
48
+ IF NOT FOUND THEN RAISE EXCEPTION 'Este convite pertence a outro email.' USING ERRCODE = 'BW002'; END IF;
49
+ IF v_invitation.status = 'accepted' AND v_invitation.accepted_by_profile_id = p_profile_id THEN
50
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
51
+ END IF;
52
+ IF v_invitation.status <> 'pending' THEN RAISE EXCEPTION 'Este convite já não está disponível.'; END IF;
53
+ IF v_invitation.expires_at <= clock_timestamp() THEN
54
+ UPDATE public.admin_user_invitations SET status = 'expired' WHERE id = p_invitation_id;
55
+ RETURN jsonb_build_object('status', 'expired');
56
+ END IF;
57
+
58
+ SELECT role_code INTO v_old_role FROM public.user_role_assignments WHERE profile_id = p_profile_id FOR UPDATE;
59
+ INSERT INTO public.user_role_assignments(profile_id, role_code, assigned_by_profile_id, assigned_at, reason)
60
+ VALUES(p_profile_id, v_invitation.role_code, v_invitation.invited_by_profile_id, now(), 'Convite de utilizador do portal aceite.')
61
+ ON CONFLICT(profile_id) DO UPDATE SET role_code = EXCLUDED.role_code,
62
+ assigned_by_profile_id = EXCLUDED.assigned_by_profile_id, assigned_at = EXCLUDED.assigned_at, reason = EXCLUDED.reason;
63
+ IF v_invitation.invited_by_profile_id IS NOT NULL THEN
64
+ INSERT INTO public.role_change_audit(target_profile_id, changed_by_profile_id, old_role_code, new_role_code, reason)
65
+ VALUES(p_profile_id, v_invitation.invited_by_profile_id, v_old_role, v_invitation.role_code, 'Convite de utilizador do portal aceite.');
66
+ END IF;
67
+ UPDATE public.admin_user_invitations SET status = 'accepted', accepted_at = now(), accepted_by_profile_id = p_profile_id
68
+ WHERE id = p_invitation_id;
69
+ PERFORM public.log_app_activity_event('admin', 'admin_user_invitation_accepted', 'admin_user_invitations', p_invitation_id,
70
+ 'Convite de utilizador aceite.', jsonb_build_object('email', v_invitation.invited_email, 'role', v_invitation.role_code,
71
+ 'status', 'accepted', 'accepted_by_profile_id', p_profile_id), p_profile_id);
72
+ RETURN jsonb_build_object('status', 'accepted', 'role', v_invitation.role_code);
73
+ END;
74
+ $$;
75
+ REVOKE ALL ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) FROM PUBLIC, anon, authenticated;
76
+ GRANT EXECUTE ON FUNCTION public.accept_admin_user_invitation(uuid, uuid, text) TO service_role;
77
+
78
+ -- Bootstrap also touches Auth and profiles. Lock Auth before the advisory and
79
+ -- profile locks, so a stale service request cannot overwrite a concurrent email
80
+ -- change and a missing-profile INSERT cannot invert the identity lock order.
81
+ CREATE OR REPLACE FUNCTION public.bootstrap_first_admin(
82
+ p_user_id uuid,
83
+ p_email text
84
+ )
85
+ RETURNS TABLE (
86
+ profile_id uuid,
87
+ previous_role_code text
88
+ )
89
+ LANGUAGE plpgsql
90
+ SECURITY DEFINER
91
+ SET search_path = ''
92
+ AS $$
93
+ DECLARE
94
+ v_email text;
95
+ v_auth_email text;
96
+ v_auth_confirmed_at timestamptz;
97
+ v_profile_id uuid;
98
+ v_previous_role_code text;
99
+ BEGIN
100
+ IF COALESCE(auth.jwt() ->> 'role', '') <> 'service_role' THEN
101
+ RAISE EXCEPTION 'bootstrap_first_admin requires service_role'
102
+ USING ERRCODE = '42501';
103
+ END IF;
104
+
105
+ v_email := NULLIF(lower(trim(COALESCE(p_email, ''))), '');
106
+ IF p_user_id IS NULL OR v_email IS NULL THEN
107
+ RAISE EXCEPTION 'A user id and email are required'
108
+ USING ERRCODE = '22023';
109
+ END IF;
110
+
111
+ SELECT lower(trim(u.email)), u.email_confirmed_at
112
+ INTO v_auth_email, v_auth_confirmed_at
113
+ FROM auth.users u
114
+ WHERE u.id = p_user_id
115
+ FOR NO KEY UPDATE;
116
+
117
+ IF v_auth_email IS NULL OR v_auth_email <> v_email OR v_auth_confirmed_at IS NULL THEN
118
+ RAISE EXCEPTION 'Auth user and confirmed email do not match'
119
+ USING ERRCODE = '22023';
120
+ END IF;
121
+
122
+ IF (SELECT count(*) FROM auth.users WHERE lower(btrim(email)) = v_email) <> 1 THEN
123
+ RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
124
+ END IF;
125
+
126
+ PERFORM pg_advisory_xact_lock(hashtextextended('brightweb:first-admin-bootstrap', 0));
127
+
128
+ IF EXISTS (
129
+ SELECT 1
130
+ FROM public.user_role_assignments ura
131
+ WHERE ura.role_code = 'admin'
132
+ ) THEN
133
+ RAISE EXCEPTION 'A project administrator already exists'
134
+ USING ERRCODE = '42501';
135
+ END IF;
136
+
137
+ SELECT p.id
138
+ INTO v_profile_id
139
+ FROM public.profiles p
140
+ WHERE p.user_id = p_user_id
141
+ FOR UPDATE;
142
+
143
+ IF v_profile_id IS NULL THEN
144
+ IF EXISTS (
145
+ SELECT 1
146
+ FROM public.profiles p
147
+ WHERE lower(p.email) = v_email
148
+ ) THEN
149
+ RAISE EXCEPTION 'A profile already exists for this email'
150
+ USING ERRCODE = '23505';
151
+ END IF;
152
+
153
+ INSERT INTO public.profiles (user_id, email)
154
+ VALUES (p_user_id, v_email)
155
+ RETURNING id INTO v_profile_id;
156
+ ELSE
157
+ UPDATE public.profiles
158
+ SET email = v_email,
159
+ updated_at = now()
160
+ WHERE id = v_profile_id;
161
+ END IF;
162
+
163
+ SELECT ura.role_code
164
+ INTO v_previous_role_code
165
+ FROM public.user_role_assignments ura
166
+ WHERE ura.profile_id = v_profile_id
167
+ FOR UPDATE;
168
+
169
+ INSERT INTO public.user_role_assignments (
170
+ profile_id,
171
+ role_code,
172
+ assigned_by_profile_id,
173
+ assigned_at,
174
+ reason
175
+ )
176
+ VALUES (
177
+ v_profile_id,
178
+ 'admin',
179
+ NULL,
180
+ now(),
181
+ 'bw_admin_create_bootstrap'
182
+ )
183
+ ON CONFLICT ON CONSTRAINT user_role_assignments_profile_id_key
184
+ DO UPDATE SET
185
+ role_code = EXCLUDED.role_code,
186
+ assigned_by_profile_id = NULL,
187
+ assigned_at = EXCLUDED.assigned_at,
188
+ reason = EXCLUDED.reason;
189
+
190
+ RETURN QUERY SELECT v_profile_id, v_previous_role_code;
191
+ END;
192
+ $$;
193
+
194
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM PUBLIC;
195
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM anon;
196
+ REVOKE ALL ON FUNCTION public.bootstrap_first_admin(uuid, text) FROM authenticated;
197
+ GRANT EXECUTE ON FUNCTION public.bootstrap_first_admin(uuid, text) TO service_role;
198
+
199
+ -- A new package must not silently call pre-repair module RPCs.
200
+ CREATE OR REPLACE FUNCTION public.assert_admin_auth_identity_v1()
201
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
202
+ DECLARE
203
+ expected record;
204
+ actual pg_catalog.pg_proc%ROWTYPE;
205
+ BEGIN
206
+ PERFORM public.assert_profile_auth_identity_v1();
207
+ FOR expected IN SELECT * FROM (VALUES
208
+ ('public.accept_admin_user_invitation(uuid,uuid,text)','9a188c42971bb420261b1d61be4cf188',false,'jsonb',true),
209
+ ('public.bootstrap_first_admin(uuid,text)','7492c436da8e3e1721bf2b8a07149f59',true,'record',true)
210
+ ) AS contracts(signature, body_hash, definer, result_type, service_callable)
211
+ LOOP
212
+ SELECT * INTO actual FROM pg_catalog.pg_proc WHERE oid = to_regprocedure(expected.signature);
213
+ IF NOT FOUND OR md5(actual.prosrc) IS DISTINCT FROM expected.body_hash
214
+ OR actual.prosecdef IS DISTINCT FROM expected.definer
215
+ OR actual.prorettype IS DISTINCT FROM to_regtype(expected.result_type)
216
+ OR actual.proconfig IS DISTINCT FROM ARRAY['search_path=""']::text[]
217
+ OR pg_catalog.pg_get_userbyid(actual.proowner) IN ('anon', 'authenticated')
218
+ OR has_function_privilege('anon', actual.oid, 'EXECUTE')
219
+ OR has_function_privilege('authenticated', actual.oid, 'EXECUTE')
220
+ OR (expected.service_callable AND NOT has_function_privilege('service_role', actual.oid, 'EXECUTE')) THEN
221
+ RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply complete Core profile_auth_identity_v1 (%).', expected.signature USING ERRCODE = '55000';
222
+ END IF;
223
+ END LOOP;
224
+ END;
225
+ $$;
226
+ REVOKE ALL ON FUNCTION public.assert_admin_auth_identity_v1() FROM PUBLIC, anon, authenticated;
227
+ GRANT EXECUTE ON FUNCTION public.assert_admin_auth_identity_v1() TO service_role;
228
+
229
+ $identity_ddl$;
230
+ PERFORM public.assert_admin_auth_identity_v1();
231
+ END;
232
+ $identity_migration$;
@@ -0,0 +1,272 @@
1
+ -- protect_profile_identity
2
+ -- target: core
3
+ -- created_at: 2026-10-14T18:31:38.339Z
4
+
5
+ -- One atomic statement protects old CLI/manual continue-on-error application:
6
+ -- failed prerequisites or later DDL leave every existing guard/function intact.
7
+ DO $identity_migration$
8
+ BEGIN
9
+ EXECUTE $identity_ddl$
10
+ -- Browser UPDATE already owns a profile lock. Reject identity changes without
11
+ -- touching Auth, so an Auth email change (Auth -> profile) cannot deadlock it.
12
+ -- This guard is INVOKER: nested trusted Auth sync retains its effective owner;
13
+ -- ordinary browser roles never receive that authority via a definer guard.
14
+ CREATE OR REPLACE FUNCTION public.guard_profile_identity_update()
15
+ RETURNS trigger LANGUAGE plpgsql SECURITY INVOKER SET search_path = '' AS $$
16
+ BEGIN
17
+ IF current_user IN ('anon', 'authenticated') AND (
18
+ NEW.email IS DISTINCT FROM OLD.email OR
19
+ NEW.user_id IS DISTINCT FROM OLD.user_id OR
20
+ NEW.created_at IS DISTINCT FROM OLD.created_at
21
+ ) THEN
22
+ RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
23
+ END IF;
24
+ RETURN NEW;
25
+ END;
26
+ $$;
27
+ REVOKE ALL ON FUNCTION public.guard_profile_identity_update() FROM PUBLIC, anon, authenticated;
28
+ CREATE OR REPLACE TRIGGER guard_profile_identity_update BEFORE UPDATE ON public.profiles
29
+ FOR EACH ROW EXECUTE FUNCTION public.guard_profile_identity_update();
30
+
31
+ -- BEFORE INSERT runs before uniqueness/conflict locks, including on an upsert.
32
+ -- SHARE conflicts with Auth email UPDATE; KEY SHARE would not protect email.
33
+ -- Existing account upserts omit created_at, so preserve its original value.
34
+ CREATE OR REPLACE FUNCTION public.guard_profile_identity_insert()
35
+ RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
36
+ DECLARE
37
+ v_email text;
38
+ v_created_at timestamptz;
39
+ BEGIN
40
+ IF current_setting('role', true) IN ('anon', 'authenticated') AND
41
+ (NEW.user_id IS NULL OR NEW.user_id IS DISTINCT FROM auth.uid()) THEN
42
+ RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
43
+ END IF;
44
+ IF NEW.user_id IS NOT NULL THEN
45
+ SELECT lower(btrim(email)) INTO v_email FROM auth.users
46
+ WHERE id = NEW.user_id FOR SHARE;
47
+ IF NOT FOUND OR v_email IS NULL OR v_email = '' OR
48
+ lower(btrim(NEW.email)) IS DISTINCT FROM v_email THEN
49
+ RAISE EXCEPTION 'Profile identity is managed by Auth.' USING ERRCODE = '42501';
50
+ END IF;
51
+ NEW.email := v_email;
52
+ END IF;
53
+ SELECT created_at INTO v_created_at FROM public.profiles WHERE user_id = NEW.user_id;
54
+ IF current_setting('role', true) IN ('anon', 'authenticated') THEN
55
+ IF NEW.created_at IS DISTINCT FROM now() AND
56
+ NEW.created_at IS DISTINCT FROM v_created_at THEN
57
+ RAISE EXCEPTION 'Profile creation timestamp is managed by the database.' USING ERRCODE = '42501';
58
+ END IF;
59
+ NEW.created_at := COALESCE(v_created_at, now());
60
+ END IF;
61
+ RETURN NEW;
62
+ END;
63
+ $$;
64
+ REVOKE ALL ON FUNCTION public.guard_profile_identity_insert() FROM PUBLIC, anon, authenticated;
65
+ CREATE OR REPLACE TRIGGER guard_profile_identity_insert BEFORE INSERT ON public.profiles
66
+ FOR EACH ROW EXECUTE FUNCTION public.guard_profile_identity_insert();
67
+
68
+ CREATE OR REPLACE FUNCTION public.sync_profile_from_auth_identity(
69
+ p_user_id uuid,
70
+ p_email text,
71
+ p_metadata jsonb DEFAULT '{}'::jsonb
72
+ )
73
+ RETURNS void
74
+ LANGUAGE plpgsql
75
+ SECURITY DEFINER
76
+ SET search_path = ''
77
+ AS $$
78
+ DECLARE
79
+ v_email text;
80
+ v_first_name text;
81
+ v_last_name text;
82
+ v_full_name text;
83
+ v_name_parts text[];
84
+ BEGIN
85
+ -- Keep the legacy argument for callers, but only current Auth state is authority.
86
+ -- Lock Auth before the upsert takes any profile locks; stale registration input
87
+ -- must never restore an email which Auth has already changed.
88
+ SELECT NULLIF(lower(btrim(u.email)), '') INTO v_email
89
+ FROM auth.users u WHERE u.id = p_user_id FOR NO KEY UPDATE;
90
+ IF NOT FOUND THEN
91
+ RAISE EXCEPTION 'Auth identity not found.' USING ERRCODE = '23503';
92
+ END IF;
93
+
94
+ IF v_email IS NULL THEN
95
+ RETURN;
96
+ END IF;
97
+
98
+ v_first_name := NULLIF(trim(COALESCE(p_metadata ->> 'first_name', '')), '');
99
+ v_last_name := NULLIF(trim(COALESCE(p_metadata ->> 'last_name', '')), '');
100
+ v_full_name := NULLIF(
101
+ trim(
102
+ COALESCE(
103
+ p_metadata ->> 'full_name',
104
+ p_metadata ->> 'name',
105
+ p_metadata ->> 'display_name',
106
+ ''
107
+ )
108
+ ),
109
+ ''
110
+ );
111
+
112
+ IF v_first_name IS NULL AND v_full_name IS NOT NULL THEN
113
+ v_name_parts := regexp_split_to_array(v_full_name, '\s+');
114
+ IF array_length(v_name_parts, 1) >= 1 THEN
115
+ v_first_name := v_name_parts[1];
116
+ END IF;
117
+ IF array_length(v_name_parts, 1) >= 2 THEN
118
+ v_last_name := array_to_string(v_name_parts[2:array_length(v_name_parts, 1)], ' ');
119
+ END IF;
120
+ END IF;
121
+
122
+ INSERT INTO public.profiles (
123
+ user_id,
124
+ email,
125
+ first_name,
126
+ last_name,
127
+ created_at,
128
+ updated_at
129
+ )
130
+ VALUES (
131
+ p_user_id,
132
+ v_email,
133
+ v_first_name,
134
+ v_last_name,
135
+ now(),
136
+ now()
137
+ )
138
+ ON CONFLICT (user_id) DO UPDATE
139
+ SET
140
+ email = EXCLUDED.email,
141
+ first_name = COALESCE(NULLIF(trim(public.profiles.first_name), ''), EXCLUDED.first_name),
142
+ last_name = COALESCE(NULLIF(trim(public.profiles.last_name), ''), EXCLUDED.last_name),
143
+ updated_at = now();
144
+ END;
145
+ $$;
146
+
147
+ REVOKE ALL ON FUNCTION public.sync_profile_from_auth_identity(uuid,text,jsonb) FROM PUBLIC,anon,authenticated;
148
+ GRANT EXECUTE ON FUNCTION public.sync_profile_from_auth_identity(uuid,text,jsonb) TO service_role;
149
+
150
+ -- Narrow server boundary: no auth table grants or browser email lookup.
151
+ -- More than one normalized identity is an error, never a silent legacy relink.
152
+ CREATE OR REPLACE FUNCTION public.resolve_auth_identity(p_email text)
153
+ RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
154
+ DECLARE
155
+ v_user_id uuid;
156
+ v_profile_id uuid;
157
+ v_identities jsonb;
158
+ v_confirmed boolean;
159
+ BEGIN
160
+ SELECT jsonb_agg(jsonb_build_object('userId', id,
161
+ 'confirmed', email_confirmed_at IS NOT NULL)) INTO v_identities
162
+ FROM auth.users WHERE lower(btrim(email)) = lower(btrim(p_email));
163
+ IF v_identities IS NULL THEN
164
+ IF EXISTS (SELECT 1 FROM public.profiles WHERE lower(btrim(email)) = lower(btrim(p_email))) THEN
165
+ RAISE EXCEPTION 'IDENTITY_RECONCILIATION_REQUIRED: existing profile has no matching Auth identity.' USING ERRCODE = 'BW002';
166
+ END IF;
167
+ RETURN NULL;
168
+ END IF;
169
+ IF jsonb_array_length(v_identities) <> 1 THEN
170
+ RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
171
+ END IF;
172
+ v_user_id := (v_identities -> 0 ->> 'userId')::uuid;
173
+ v_confirmed := (v_identities -> 0 ->> 'confirmed')::boolean;
174
+ IF EXISTS (SELECT 1 FROM public.profiles
175
+ WHERE (user_id = v_user_id AND lower(btrim(email)) IS DISTINCT FROM lower(btrim(p_email)))
176
+ OR (lower(btrim(email)) = lower(btrim(p_email)) AND user_id IS DISTINCT FROM v_user_id)) THEN
177
+ RAISE EXCEPTION 'IDENTITY_RECONCILIATION_REQUIRED: profile and current Auth identity disagree.' USING ERRCODE = 'BW002';
178
+ END IF;
179
+ SELECT id INTO v_profile_id FROM public.profiles
180
+ WHERE user_id = v_user_id AND v_confirmed AND lower(btrim(email)) = lower(btrim(p_email));
181
+ RETURN jsonb_build_object('userId', v_user_id, 'profileId', v_profile_id,
182
+ 'email', lower(btrim(p_email)), 'confirmed', v_confirmed);
183
+ END;
184
+ $$;
185
+ REVOKE ALL ON FUNCTION public.resolve_auth_identity(text) FROM PUBLIC,anon,authenticated;
186
+ GRANT EXECUTE ON FUNCTION public.resolve_auth_identity(text) TO service_role;
187
+
188
+ -- The profile read is only a candidate snapshot. Callers must recheck user_id
189
+ -- under their later profile lock. This function must precede invitation locks.
190
+ CREATE OR REPLACE FUNCTION public.lock_confirmed_profile_auth_identity(p_profile_id uuid, p_email text)
191
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
192
+ DECLARE
193
+ v_user_id uuid;
194
+ v_email text;
195
+ v_confirmed_at timestamptz;
196
+ BEGIN
197
+ SELECT user_id INTO v_user_id FROM public.profiles WHERE id = p_profile_id;
198
+ IF v_user_id IS NULL THEN
199
+ RAISE EXCEPTION 'O perfil não corresponde ao email confirmado; este convite pertence a outro email.' USING ERRCODE = 'BW002';
200
+ END IF;
201
+ SELECT email, email_confirmed_at INTO v_email, v_confirmed_at
202
+ FROM auth.users WHERE id = v_user_id FOR NO KEY UPDATE;
203
+ IF NOT FOUND OR v_confirmed_at IS NULL OR p_email IS NULL OR btrim(p_email) = '' OR
204
+ lower(btrim(v_email)) IS DISTINCT FROM lower(btrim(p_email)) THEN
205
+ RAISE EXCEPTION 'O perfil não corresponde ao email confirmado; este convite pertence a outro email.';
206
+ END IF;
207
+ IF (SELECT count(*) FROM auth.users WHERE lower(btrim(email)) = lower(btrim(p_email))) <> 1 THEN
208
+ RAISE EXCEPTION 'Ambiguous Auth identity; reconcile before granting access.' USING ERRCODE = '21000';
209
+ END IF;
210
+ RETURN v_user_id;
211
+ END;
212
+ $$;
213
+ REVOKE ALL ON FUNCTION public.lock_confirmed_profile_auth_identity(uuid,text) FROM PUBLIC,anon,authenticated;
214
+ GRANT EXECUTE ON FUNCTION public.lock_confirmed_profile_auth_identity(uuid,text) TO service_role;
215
+
216
+ -- Installed last: dependents validate both complete installation and subsequent
217
+ -- catalog drift. The whole migration is one statement, including this check.
218
+ CREATE OR REPLACE FUNCTION public.assert_profile_auth_identity_v1()
219
+ RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$
220
+ DECLARE
221
+ expected record;
222
+ actual pg_catalog.pg_proc%ROWTYPE;
223
+ BEGIN
224
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_attribute
225
+ WHERE attrelid = 'auth.users'::regclass AND attname = 'email_confirmed_at' AND NOT attisdropped) THEN
226
+ RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: Core Auth confirmation schema is missing.' USING ERRCODE = '55000';
227
+ END IF;
228
+ FOR expected IN SELECT * FROM (VALUES
229
+ ('public.guard_profile_identity_update()','bf0274d7fd4c164296db7363b1678989',false,'trigger',false),
230
+ ('public.guard_profile_identity_insert()','14dc0765f2ab4d343c51a76f29c71909',true,'trigger',false),
231
+ ('public.sync_profile_from_auth_identity(uuid,text,jsonb)','c94aa40d153e255d8108c43c4c5ad21e',true,'void',true),
232
+ ('public.resolve_auth_identity(text)','60cb0cf2f919d738b46bde4fad4fcd61',true,'jsonb',true),
233
+ ('public.lock_confirmed_profile_auth_identity(uuid,text)','a14ba875979007cb0a2d6f9cdda57b19',true,'uuid',true)
234
+ ) AS contracts(signature, body_hash, definer, result_type, service_callable)
235
+ LOOP
236
+ SELECT * INTO actual FROM pg_catalog.pg_proc WHERE oid = to_regprocedure(expected.signature);
237
+ IF NOT FOUND OR md5(actual.prosrc) IS DISTINCT FROM expected.body_hash
238
+ OR actual.prosecdef IS DISTINCT FROM expected.definer
239
+ OR actual.prorettype IS DISTINCT FROM to_regtype(expected.result_type)
240
+ OR actual.proconfig IS DISTINCT FROM ARRAY['search_path=""']::text[]
241
+ OR pg_catalog.pg_get_userbyid(actual.proowner) IN ('anon', 'authenticated')
242
+ OR has_function_privilege('anon', actual.oid, 'EXECUTE')
243
+ OR has_function_privilege('authenticated', actual.oid, 'EXECUTE')
244
+ OR (expected.service_callable AND NOT has_function_privilege('service_role', actual.oid, 'EXECUTE')) THEN
245
+ RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: apply complete Core profile_auth_identity_v1 (%).', expected.signature USING ERRCODE = '55000';
246
+ END IF;
247
+ END LOOP;
248
+ FOR expected IN SELECT * FROM (VALUES
249
+ ('guard_profile_identity_insert', 7), ('guard_profile_identity_update', 19)
250
+ ) AS guards(name, kind)
251
+ LOOP
252
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_trigger
253
+ WHERE tgrelid = 'public.profiles'::regclass AND tgname = expected.name
254
+ AND tgfoid = to_regprocedure('public.' || expected.name || '()')
255
+ AND tgtype = expected.kind AND tgenabled IN ('O', 'A')
256
+ AND NOT tgisinternal AND tgqual IS NULL AND tgnargs = 0 AND tgattr = ''::int2vector) THEN
257
+ RAISE EXCEPTION 'IDENTITY_MIGRATION_REQUIRED: Core identity guard % must be enabled.', expected.name USING ERRCODE = '55000';
258
+ END IF;
259
+ END LOOP;
260
+ END;
261
+ $$;
262
+ REVOKE ALL ON FUNCTION public.assert_profile_auth_identity_v1() FROM PUBLIC, anon, authenticated;
263
+ GRANT EXECUTE ON FUNCTION public.assert_profile_auth_identity_v1() TO service_role;
264
+
265
+ $identity_ddl$;
266
+ PERFORM public.assert_profile_auth_identity_v1();
267
+ -- Retire the superseded Admin guard only after Core protection is proven.
268
+ -- Core must also support scoped Orgs upgrades without advancing Admin SQL.
269
+ DROP TRIGGER IF EXISTS guard_privileged_profile_fields ON public.profiles;
270
+ DROP FUNCTION IF EXISTS public.guard_privileged_profile_fields();
271
+ END;
272
+ $identity_migration$;