PS7 — Identity, Account & Consent
Status: Archived Draws from: B8-auth-identity-backend.md (findings #3, #4, #5, #6/#7 as deferred cross-refs — #1/#2/#6 are PS1's mechanism, not re-planned here) and U7-auth-profile-dm.md (findings #1, #2, #3, #4→#7 folded, #5, #10, #15 as a companion fix — #6/#8/#9/#12/#13/#16 are messaging/DM-domain or informational, out of this doc's charter; #14 is roadmap-adjacent). Cites, does not repeat:wiring-plans-2026-07-backend.md §AUTH/IDENTITY (A1–A5, cross-ref C1) and design-specs-2026-07-ui.md §U7 + Wireframe C. Networking/notifications canon: docs/canon/notifications.md, docs/canon/korean-market.md.
Boundary with PS1 (Access control). profiles_select's no-op RLS, profiles_update's column-forge gap, and can_view_profile_field's caller-supplied-viewer leak (B8 #1/#2/#6, wiring-plan A1/A2/A3) are PS1's mechanism, fully designed in ps1-access-control.md §3.1–3.2 (bulk-RPC masking + RLS narrowing + protect_profiles_computed column-list extension, including kakao_id and nationality_code's neighbors). Not redesigned here — PS7 only depends on it insofar as §3.1 below extends delete_account_atomic, a function PS1 does not touch.
Boundary with PS6 (Clubs & membership) on account deletion. delete_account_atomic (00186) is one RPC; PS6 already claimed the club-membership half — ps6-clubs-membership.md §3.1 (M1+M2): a new AFTER UPDATE OF deleted_at ON public.profiles trigger, deliberately not an edit to delete_account_atomic's own body, precisely so this document could edit that body in the same review cycle without a merge conflict. This document owns the other half: the function body of delete_account_atomic itself (consent withdrawal, pi_access_log, and the two additions §3.1 below makes — the auth.users lockout and the session-domain completeness backstop), plus everything downstream of "which tables hold user data and what happens to them." The two halves fire in the same transaction (PS6's trigger fires synchronously off the UPDATE profiles SET deleted_at = now() statement PS7's function body issues) and never touch each other's code.
Boundary with PS8 (Notification & signal surfacing) — not yet authored. PS8's own PS table entry claims "push-tap dead-ends, no preferences UI, admin-count drift" from U1, U7. The seam, made explicit here since PS8 doesn't exist yet to state it from its own side: PS8 owns how a signal gets produced and routed once emitted (push-tap deep-linking, in-app notification-center rendering, the admin-attention-count drift). PS7 owns the settings screen that edits signal_preferences — the per-category in-app/push/severity/ quiet-hours row values a user sets, which is an identity/account-settings surface (lives at /settings/signal-preferences, same navigational family as change-password/consent-history/ delete-account), not a notification-surfacing concern. §3.4 below designs that screen; when PS8 is authored it should cite this section rather than re-plan the settings screen.
1. Problem statement
Five defects, one shared root theme: the identity/account surface was built in disconnected slices — the deletion RPC, the consent ledger, the OAuth adapter, and the notification-preference schema each shipped without their client-facing or cross-table completion, so cutting a "delete my account" or "turn off push" action across the app either silently does less than it claims (deletion, consent-labeling) or does nothing at all (OAuth buttons, preference screens).
| # | Defect | Source | Severity |
|---|---|---|---|
| I1 | delete_account_atomic never revokes the underlying Supabase Auth credential (new finding, this doc) and — independent of PS6's club-membership-scoped cleanup — never closes RSVPs held with no club_members row at all (guest-application RSVPs). Session-domain tables rsvps/dues/session_payments need this; elo_history/manner_tags/attendance_records/DM/signals correctly need nothing (verified, not assumed) | B8 #3 (extends B1); new finding (auth-session lockout) | CRITICAL |
| I2 | Generic "푸시 알림" toggle silently grants/revokes the marketing_push PIPA purpose with no marketing labeling; the §50(8) 2-year re-confirm cron is already running against a ledger that may contain grants no user knowingly made | U7 #3, B8 #4 | HIGH (dormant landmine, pre-shipping hygiene) |
| I3 | Login is email/password-only; Kakao is code-complete but buttonless; Google/Apple throw-stubs; OAuth cancel is silently swallowed (fine, if intentional) but OAuth failure produces zero user feedback (Sentry-only) — an asymmetry with the email path's deliberate re-throw; 3 divergent hand-styled password/text inputs across login/change-password/delete-account | U7 #1, #4, #7, #15 (port-doc drift, same fix) | CRITICAL (launch gate) |
| I4 | signal_preferences (13-category in-app/push/min-severity/quiet-hours) has zero settings UI despite a dead route + dead i18n scaffolding; 6 of 9 user_preferences notify booleans have no toggle anywhere — two parallel half-built preference systems, neither complete | U7 #2, #5 | CRITICAL (feature gap, deferred by design — now precisely scoped) |
| I5 | change-password "현재 비밀번호" field — audit claim does not hold under live-code verification: auth.supabase.ts:250–283 (commit e7347df6) already re-authenticates via signInWithPassword before updateUser and surfaces INVALID_CURRENT_PASSWORD to the screen's existing currentPasswordError state. Only the screen's own header comment (change-password-screen.tsx:5) is stale/wrong | U7 #10 (corrected by this doc) | LOW (doc-only) |
Explicitly out of scope, with reasons (mirrors PS6's own exclusion discipline): B8 #5 (record_pi_access dead code) / #7 (nationality_code no write-protection) — both match PS1's own A5 deferral verbatim (roadmap-adjacent, fold into whichever migration first ships verified-IdP onboarding; not re-planned here, see §3.6). U7 #6 (DM inbox poll-comment drift), #8 (empty DM inbox CTA), #9 (deleted-DM-partner header) — messaging/DM-domain UX, not identity/account/consent; no PS in the current 11-space table claims DM specifically, so these are flagged here for the roadmap to route (likely PS8 alongside its notification- surfacing charter, or a future dedicated messaging space) rather than silently absorbed into PS7's scope creep. U7 #12 (profile-completeness nudge) — no design-spec entry exists for it (checked design-specs-2026-07-ui.md in full); low severity, no owner-flagged urgency, correctly left unspecced. U7 #13 (phone capture UI) / #14 (verified-identity signup) — informational/roadmap reality checks per U7's own severity ranking, matches the existing docs/architecture/foundational-schema-decisions.md "flat nationality now" framing; no action here.
2. Research
Method: every schema claim below was verified live against the local Postgres (docker exec supabase_db_twomore-v2 psql -U postgres -d postgres, HEAD 00387, confirmed via npx supabase migration list --local), every probe wrapped in BEGIN...ROLLBACK (no writes persisted), plus direct reads of the exact function bodies (\sf) and trigger definitions (pg_trigger/pg_get_triggerdef) rather than trusting migration-file prose — per this doc's own rule (multi-modal verification, the dead-theme-file lesson) and matching B8's own live-adversarial-probe method.
2.1 Account-deletion completeness — the full table inventory, live-enumerated
The enumeration B8 didn't run: FKs to auth.users(id), not just profiles(id). B8's own completeness sweep (probe #6 in its adversarial-probes list) greped for REFERENCES public.profiles(id) ... ON DELETE CASCADE and found rsvps, dues, elo_history, manner_tags, attendance_records, member_reports (plus asserted-but-not- FK-swept session_payments, DM tables, signals). Running the FK enumeration live against information_schema for both profiles(id) and auth.users(id) targets surfaces a detail B8's grep missed because it only searched for the profiles-referencing shape:
-- both queries run inside BEGIN...ROLLBACK against local HEAD 00387
SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table, rc.delete_rule
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu USING (constraint_name, table_schema)
JOIN information_schema.constraint_column_usage ccu USING (constraint_name, table_schema)
JOIN information_schema.referential_constraints rc
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
AND ccu.table_name = 'profiles'; -- 54 rows: attendance_records, club_members, rsvps,
-- dues, elo_history, manner_tags, member_reports,
-- content_reports, dm_*, user_blocks, user_consent,
-- user_preferences, matches (4 player-slot FKs), etc.A second sweep, scoping ccu.table_name/ccu.table_schema to auth.users, returns zero rows against profiles as the target but a separate, non-overlapping set when the join target is auth.users directly:
session_payments.user_id / .recorded_by → auth.users(id) ON DELETE CASCADE / (no cascade)
guest_applications.applicant_id → auth.users(id) ON DELETE CASCADE
club_join_request.user_id / .decided_by → auth.users(id) ON DELETE CASCADE / (no cascade)
signals.recipient_user_id → auth.users(id) ON DELETE CASCADE
signal_preferences.user_id → auth.users(id) ON DELETE CASCADE
signal_consent_log.user_id → auth.users(id) ON DELETE CASCADE
venue_corrections.submitted_by → auth.users(id) ON DELETE CASCADEWhy this matters, cross-validated against the actual deletion code path: these 7 tables (session_payments, guest_applications, club_join_request, signals, signal_preferences, signal_consent_log, venue_corrections) reference auth.users(id), not public.profiles(id) — a schema-design inconsistency (most of the codebase FKs to profiles) that turns out to be functionally inert for the same reason B8's own finding is inert: auth.users rows are never deleted either. Grepped every migration for DELETE FROM auth.users / DROP ... auth.users — zero hits outside a trigger DROP TRIGGER statement. Grepped supabase/functions and packages/app/src for admin.deleteUser — the only two call sites are simulate-activity and seed-scenario, both dev/test data-generation edge functions, never the product's real account-deletion path (delete_account_atomic, read live and verbatim-matches 00186 — writes only profiles/user_consent/pi_access_log). So the ON DELETE CASCADE clauses on both the profiles-referencing and the auth.users-referencing FK sets are dormant for the identical reason — this is the same "cascades never fire because the parent row is permanent by design" pattern B8 named for profiles, just present in a second, previously-unswept FK set. pi_access_log itself carries no FK at all on actor_user_id/target_user_id (verified: pg_constraint sweep returns zero rows) — a deliberate, correct choice (an audit log must survive independent of the subject's account lifecycle; no completeness action needed there).
A gap B8 didn't test: the auth.users row itself is never locked out. Reading delete_account_atomic and zero_pii_on_soft_delete verbatim confirms neither writes anything to auth.users — no banned_until, no deleted_at (yes, auth.users has its own native deleted_at/banned_until columns, confirmed via information_schema.columns; this is Supabase Auth's own soft-delete/ban mechanism, orthogonal to public.profiles.deleted_at and never touched by this codebase). A repo-wide grep for deleted_at across packages/app/src/presentation and packages/features turns up exactly two hits, both comments in use-delete-account.ts and delete-account-screen.tsx — no code path anywhere reads profiles.deleted_at to gate a re-login or block a self-row write. The client does call auth.signOut() immediately after the RPC (use-delete-account.ts:29), which clears the local session — but since the underlying Supabase Auth identity (email/password hash, or the linked OAuth identity) is untouched, the same credentials can sign back in at any time, on the same device or a fresh install, during the 30-day grace window or even after anonymize_withdrawn_profiles runs. Worse: profiles_update's RLS (auth.uid() = id, no deleted_at predicate — confirmed, PS1 §2.5/2.6) lets a re-authenticated "deleted" account run UPDATE profiles SET deleted_at = NULL WHERE id = self, silently undoing the deletion server-side with no reactivation flow ever having been designed for it. This is the actual completeness gap underneath "account deletion" — not just downstream tables, but whether deletion is durable at all. Supabase's own documented mechanism for a permanent SQL-level ban is writing auth.users.banned_until directly (GoTrue checks this column at sign-in and rejects while now() < banned_until) — no Admin-API/HTTP call needed from inside a Postgres function, and the live grant sweep confirms postgres (the owner of delete_account_atomic, confirmed via pg_proc/pg_roles join) already holds UPDATE on auth.users (table owner supabase_auth_admin, but postgres has full INSERT/SELECT/UPDATE/DELETE grants — this codebase already has a live precedent for SECDEF functions reading auth.users, e.g. 00117_find_auth_user_by_email.sql).
A gap in PS6's own loop, independent of the auth.users issue: guest RSVPs.private.deactivate_departed_member_state (PS6 §3.1) loops SELECT club_id, role FROM club_members WHERE user_id = p_user_id AND is_active = true — by construction it can only ever touch RSVPs belonging to a club the deleted user was an active member of. But decide_guest_application (00225) inserts a confirmed/ waitlisted rsvps row for a non-member applicant — guest_applications.applicant_id has no corresponding club_members row at all; the applicant is explicitly someone who isn't a member. A deleted user who had an approved guest RSVP on a future session is therefore invisible to PS6's loop entirely, not just an edge case of a stale membership — this is the mainline shape of a "guest" by definition. This is a genuine, load-bearing completeness gap this document must close directly against rsvps, not delegate to PS6.
session_payments verified auto-resolving, with one accepted residual. Reading private.sync_session_payment_on_rsvp (AFTER INSERT OR UPDATE OF status ON rsvps, confirmed via pg_get_triggerdef) live: when an RSVP's status flips away from confirmed (exactly what both PS6's release loop and this doc's own backstop, §3.1, do), the trigger DELETEs the matching session_payments row only if status = 'pending' AND submitted_at IS NULL — an idle, unclaimed hold. A hold where the user already tapped 송금 완료 (submitted_at set — they claim to have transferred real money) deliberately survives per the trigger's own comment ("deleting it would destroy the payment trail with no refund path"). So the wiring plan's claim ("session_payments … indirectly, via RSVP release cancelling unpaid holds") is verified correct for the common case; the residual —a deleted user's already-submitted-but-unconfirmed hold sits stranded with no RSVP behind it — is a real but narrow edge (paid, then immediately deleted the account, before the host confirmed) and is explicitly PS2's territory (hold-lifecycle redesign), not re-solved here; flagged for the roadmap rather than silently dropped.
Verified — deliberately NOT touched, and why. elo_history (schema: user_id, match_id, rating_before/after, delta, rating_pool, rd_before/after, reason, tier_before — no free text, no additional PII beyond the FK) is a pure historical competitive ledger; 00157's own architecture decision ("profiles rows are permanent — deleting would cascade into matches/RSVPs/scores") extends identically here: other players' match history and rating computations partially depend on these rows staying put. Same reasoning for manner_tags (enum tag_type only, B8 already confirmed no free text) and attendance_records (status enums only). content_reports.detail/member_reports.reasonare free text, but they're moderation/safety records authored about (or by) the user for dispute-resolution purposes — the standard "legitimate interest / legal retention" carve-out under PIPA §21 (mirrors GDPR Art. 17(3)(b)) applies: moderation history must survive account churn to catch repeat-abuse/ban-evasion patterns. DM message content (dm_messages.body) is the one place a deleted user's own authored free text persists in another user's inbox — also deliberately correct (Slack/WhatsApp/Discord precedent: the other party keeps their copy of the conversation; only the sender's identity anonymizes) and already the exact shape anonymize_withdrawn_profiles produces (display_name → '탈퇴한 회원') once the 30-day window elapses — the UI-side "show 알 수 없음 + a 비활성 계정 badge" polish (U7 #9) is a messaging-feature fix layered on top of already-correct data handling, not a PS7 gap. dues for an active club membership is already closed by PS6's loop ('waived'); dues for a club the user had already left before deleting is explicitly the general "immortal ex-member dues" problem PS5 owns (D3/U8 #4) — not duplicated here.
2.2 Consent integrity — marketing-consent conflation, KR §50 grounding
docs/canon/notifications.md:10 already states the correct target state precisely: service pushes (RSVP, match, dues — all 13 signal_category values) are §50-exempt; marketing pushes require (a) an active marketing_push grant, (b) a (광고) title prefix, (c) a 1-tap unsubscribe link, (d) night-time (21:00–08:00 KST) sends additionally require the separate marketing_push_night purpose (§50-8). This matches 정통망법 §50's real-world shape — Korean law requires itemized, purpose-specific, opt-in consent for advertising communications, distinct from service notifications a user cannot opt out of while remaining a functioning account holder (Toss/KakaoPay/Baemin all ship this exact split: a single "서비스 알림" toggle plus a separately-labeled, separately-timestamped "마케팅/광고 알림" row).
Live-verified: settings-screen.tsx:273–301's handleTogglePush is the only writer that conflates the two. grantConsent/revokeConsent (purposeKey: 'marketing_push') fire unconditionally inside the same handler bound to the generically-labeled "푸시 알림" row (ko/settings.ts's rowPushNotifications, no marketing/광고 framing anywhere in that string). settings-consent-management-screen.tsx writes the identical purpose key correctly — explicit "마케팅 푸시 알림" label, confirm dialog, PIPA §37 revocation flow — so the fix is purely: stop the generic toggle from touching this purpose at all; add a second, honestly- labeled row. Fully spec'd already, cited not repeated: design-specs-2026-07-ui.md:709-726 (U7 §3).
The backend half B8 flagged (A4) has no owner-claimed slice anywhere yet — this document claims it. private.expire_stale_marketing_consents (live-read, 00171) runs daily against a 2-year window (uc.created_at < now() - INTERVAL '2 years' — confirms B8's number; docs/canon/korean-market.md:22's "annual" phrasing is a minor, separate doc-drift not fixed here, flagged for the DOC phase) — meaning any marketing_push grant the mislabeled toggle already wrote today sits in the ledger looking like informed consent for up to 2 years. purpose_catalog (live-read) confirms marketing_push/marketing_push_night are both required = false, jurisdiction = 'KR' — correctly optional, which is exactly why the ledger's honesty matters (an optional purpose is the one PIPA §22 requires be freely and specifically granted). §3.2 designs the one-time backfill; per wiring-plan A4, it must sequence strictly after the client toggle fix ships and bakes, or new mislabeled grants keep accumulating past the remediation date.
2.3 OAuth UX — Supabase signInWithIdToken vs the port's stale PKCE claim
What's actually implemented, read live, not assumed: auth.supabase.ts dispatches all three providers through native-SDK-obtained ID tokens → supabase.auth.signInWithIdToken({ provider, token }) (Kakao: 50-77, fully wired against @react-native-seoul/kakao-login, blocked only on EXT-11 production credentials; Google: 87-100, Apple: 110-132, both TODO-commented throw-stubs with the exact SDK call shape already sketched). This is Supabase's documented native/OIDC token-exchange flow — the correct choice for a React Native app (no in-app browser hop, matches every KR super-app's native Kakao/Google/Apple SDK integration) — not the PKCE browser-redirect flow auth.service.port.ts:20-21's docstring claims ("Universal OAuth sign-in via browser redirect. Works for ALL providers."). PKCE (flowType on the Supabase client, client.ts:81/client.web.ts:57) governs the email/magic-link/web paths only — a stale, aspirational port comment (U7 #15), one-line fix bundled into this slice since it's the same file family and costs nothing extra to correct alongside the real button work.
The cancel/failure asymmetry, traced end-to-end. signInWithOAuth (adapter, auth.supabase.ts:150-173) catches code === 'CANCELLED' || 'AuthFailure' → returns null (cancel). session-provider.tsx's signInWithOAuth callback: on null, bare return — no state change (fine, matches native OS-cancel convention, and the design spec makes this explicit rather than accidental). On any other thrown error (a real failure — no idToken, network error, SDK error), the callback's catch block does setError(e) + captureCriticalError(...) but does not re-throw — contrast signInWithEmail's catch block one function up (session-provider.tsx:430-439), which explicitly re-throws with an inline comment: "so LoginScreen's catch can surface the error to the user." useAuth() does expose error: ctx.error (use-auth.ts:20,35), but no consumer reads it anywhere in the auth scope (grep-confirmed) — LoginScreen only tracks its own local errorMessage state populated from its own try/catch around signInWithEmail. Since OAuth has no buttons yet, this is dead code today — which is exactly why it's dangerous: the day a Kakao button ships, a real failure (e.g., OIDC unchecked in Kakao Console) becomes invisible to the user, Sentry- only. The fix must land in the same slice as the buttons, not after.
The "blessed RNTextInput wrapper" the design spec asks for creating already exists.design-specs-2026-07-ui.md:670-673 says to migrate login-screen's two raw hand-styled TextInputs to "a shared blessed RNTextInput wrapper (the pattern delete-account-screen.tsx/change-password-screen.tsx already use correctly)" — but tracing what those two screens actually do (not the design spec's characterization of them) shows each hand-rolls its own separate RNTextInput + StyleSheet.create (change-password-screen.tsx:13,242-249; delete-account-screen.tsx:14,168-178) — U7's own "Canon violations" summary already names this precisely: "three divergent hand-styled password/text inputs." Meanwhile packages/ui/src/labeled-field.tsx:79-124 already exports StyledInput — a themed RNTextInput wrapper "extracted from create-session-screen + create-club-screen, where identical copies lived" — for exactly this purpose, missing only secureTextEntry/textContentType/autoCapitalize/autoCorrect props for the auth-screen use case. The correct fix is smaller than "create a new wrapper": extend StyledInput, then converge all three files onto it — true single-source-of-truth, not a fourth implementation.
2.4 signal_preferences + notify-toggle UX — the 13-category model, live-verified
signal_preferences (00107:213-311, live-read) is genuinely complete end-to-end on the schema/adapter side: 13 categories (session, matchup, rsvp, dues, club, social, progression, rating, trust, payment, interclub, chat, system — confirmed by reading signal_preferences_seed_defaults() verbatim, not just counting the audit's claim), each with in_app_enabled/push_enabled/min_severity/quiet_hours (JSONB {start,end})/allow_critical_bypass, RLS user_id = auth.uid() self-only, sane KR-market defaults (payment category has quiet_hours = NULL — 24/7 fraud-alert convention matching Toss/KakaoPay, confirmed live). docs/canon/notifications.md:10 already states this entire 13-category enum is all 정보성 (service) — none of them are the marketing_push purpose, confirming §2.2's finding that this screen and the marketing-consent row are two genuinely separate systems, never to be merged. Adapter methods findPreferences/updatePreference (signal.supabase.ts:167-186) exist and work; updatePreference has zero feature consumers (grep-confirmed across packages/features) — pure absence of a write-path UI, not a broken one. Route routes.signalPreferences (navigation/routes.ts) and quiet-hours i18n keys are live but unreferenced by any appRouter.push call anywhere.
The second, parallel system: user_preferences.notify* (7 booleans: notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifySessionReminders, notifyBoardPosts, notifyAchievements, per preferences.entity.ts:46-52) plus notificationsEnabled/dmPushPreviewEnabled (2 more, 9 total). settings-screen.tsx wires exactly 3 Switch rows (notificationsEnabled at 505-519, notifySessionReminders at 521-531, dmPushPreviewEnabled at 533-543 — dmPushPreviewEnabled verified fully live end-to-end into send-push's preview-truncation gate, not dead). The other 6 booleans have i18n labels and DB columns but zero Switch rows — a user who wants dues reminders but not board-post pushes has no lever anywhere, and there's no code path today that reads these 6 booleans to gate an actual send (worth flagging: they may already be as dead on the read side as signal_preferences is on the write side — out of this document's live-probe budget to fully trace send-push's full gating logic, but the design decision below doesn't depend on resolving that).
Design direction is already made — cite, don't redesign. design-specs-2026-07-ui.md:680-707 (U7 §2) + Wireframe C (:983-1010) already decide the canonical shape: build ONE screen at the existing dead route, grouped by category-cluster (일정/회비/경기/소셜/시스템), two Switches per row (인앱/푸시) + a min_severity DropdownChip where severity filtering is meaningful, a quiet-hours row opening a ModalPanel time picker — and explicitly retires the 6 orphaned user_preferences booleans rather than surfacing two parallel systems. §3.4 below turns this into slices (screen + retirement migration), not a redesign.
2.5 change-password — the audit finding does not survive live verification
U7 #10's claim ("collected and required for the submit gate but … not validated against current — false-assurance pattern") is grounded in the screen's own header comment (change-password-screen.tsx:5: "현재 비밀번호 (shown for user reassurance; not validated against current)"). Reading the adapter, not just the screen, contradicts it: auth.supabase.ts:250-283's changePassword(currentPassword, newPassword) calls supabase.auth.getSession() to resolve the current email, then supabase.auth.signInWithPassword({ email, password: currentPassword }) — a genuine re-authentication — and on failure throws DomainError('현재 비밀번호가 일치하지 않아요', 'INVALID_CURRENT_PASSWORD'), which change-password-screen.tsx:122-125's onError handler already catches and routes into the existing currentPasswordError state, rendered under the field via FieldError. git log on auth.supabase.ts shows this landed in e7347df6 feat(auth): password re-auth + soft-delete account + notifications audit — a real, working re-auth mechanism. The header comment was never updated after that commit; the audit read the comment (or an earlier code state) rather than the live adapter — exactly the class of error this project's own "cross-validate single-source claims" research rule exists to catch, now caught. This is not a security gap; it's a one-line stale-comment correction, closing at a fraction of the audit's implied cost.
3. Solution design
3.1 I1 — delete_account_atomic extension: auth-identity lockout + session-domain backstop
Two additions to the PS7-owned half of delete_account_atomic's body (no signature change — no ACL reissue needed), landing in the same transaction as PS6's deleted_at trigger:
CREATE OR REPLACE FUNCTION public.delete_account_atomic()
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_user UUID := auth.uid();
v_consent_to_revoke RECORD;
BEGIN
IF v_user IS NULL THEN
RAISE EXCEPTION 'auth required' USING ERRCODE = '42501';
END IF;
-- 1. Mark profile soft-deleted + clear push_token (unchanged, 00186).
-- PS6's trg_on_profile_deleted_deactivate_memberships fires
-- synchronously off this UPDATE, before this function continues.
UPDATE public.profiles
SET deleted_at = now(), push_token = NULL, push_token_updated_at = NULL
WHERE id = v_user AND deleted_at IS NULL;
-- 1b. NEW — auth-identity lockout. delete_account_atomic never touched
-- auth.users; the credential stayed live, so a "deleted" account
-- could re-authenticate at any time and self-clear its own
-- deleted_at via the unrestricted profiles_update self-row policy,
-- silently undoing the deletion server-side. banned_until is
-- GoTrue's own documented SQL-level ban mechanism (checked at
-- sign-in); this function's owner (`postgres`) already holds
-- UPDATE on auth.users — no Admin-API/HTTP call needed.
UPDATE auth.users SET banned_until = 'infinity'::timestamptz WHERE id = v_user;
-- 1c. NEW — session-domain completeness backstop. PS6's loop is scoped
-- to club_members WHERE is_active = true and therefore cannot see
-- guest-application RSVPs (no club_members row exists for a guest
-- by definition) or a stray RSVP in a club already left before
-- deletion. Idempotent / usually a no-op: PS6's trigger already
-- closed every RSVP it can see by the time this statement runs.
PERFORM private.release_departed_user_open_rsvps(v_user);
-- 2. Consent withdrawal rows (unchanged, 00186).
FOR v_consent_to_revoke IN
SELECT uc.id, uc.purpose_id, uc.jurisdiction_at_collection
FROM public.user_consent uc JOIN public.purpose_catalog pc ON pc.id = uc.purpose_id
WHERE uc.user_id = v_user AND uc.state = 'granted'
AND pc.purpose_key IN ('terms_of_service','privacy_policy','cross_border_transfer',
'marketing_push','marketing_push_night','location_information')
LOOP
INSERT INTO public.user_consent (user_id, purpose_id, state, jurisdiction_at_collection,
collection_method, revokes_consent_id)
VALUES (v_user, v_consent_to_revoke.purpose_id, 'withdrawn',
v_consent_to_revoke.jurisdiction_at_collection, 'admin_action', v_consent_to_revoke.id);
END LOOP;
-- 3. PIPA §30 audit row (unchanged, 00186).
INSERT INTO public.pi_access_log (actor_user_id, target_user_id, access_type, scope, purpose)
VALUES (v_user, v_user, 'delete', 'account', 'delete_account_atomic RPC');
END;
$$;
-- New helper — session-domain completeness backstop, reuses PS6's
-- private.release_member_future_seats(club_id, user_id) verbatim rather
-- than duplicating its pre-seed-excused/cancel/cascade-skip body.
CREATE OR REPLACE FUNCTION private.release_departed_user_open_rsvps(p_user_id UUID)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
AS $$
DECLARE v_club RECORD;
BEGIN
FOR v_club IN
SELECT DISTINCT s.club_id
FROM public.rsvps r JOIN public.sessions s ON s.id = r.session_id
WHERE r.user_id = p_user_id AND r.status IN ('confirmed', 'waitlisted')
AND (s.date + s.start_time) AT TIME ZONE COALESCE(s.iana_timezone, 'Asia/Seoul') > now()
LOOP
PERFORM private.release_member_future_seats(v_club.club_id, p_user_id);
END LOOP;
END;
$$;
REVOKE ALL ON FUNCTION private.release_departed_user_open_rsvps(UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION private.release_departed_user_open_rsvps(UUID) TO postgres;session_payments needs no direct write here — trg_sync_session_payment_on_rsvp (AFTER UPDATE OF status ON rsvps) already deletes the matching idle hold when release_member_future_seats cancels the RSVP (§2.1). dues/club_members need no PS7 write — PS6's own trigger closes both for active memberships; ex-member dues are PS5's territory. elo_history/manner_tags/attendance_records/DM tables/signals are deliberately untouched (§2.1's "verified — do not touch" reasoning). No change to nationality_code or kakao_id here — those are PS1's protect_profiles_computed extension (§2.1 boundary).
Sequencing dependency, hard constraint, not a preference: this migration must ship after PS6's 00394 (the AFTER UPDATE OF deleted_at trigger + release_member_future_seats extraction) since it calls that same helper function by name. If shipped first, the CREATE OR REPLACE FUNCTION public.delete_account_atomic() body above would reference a function (private.release_member_future_seats(uuid,uuid)) that doesn't exist yet — either sequence strictly after 00394, or land both delete_account_atomic's edit and the new private.release_departed_user_open_rsvps helper as a single migration numbered immediately after PS6's 00396 (this doc picks 00397, continuing PS1's 00388-00393 → PS6's 00394-00396 numbering chain).
3.2 I2 — Consent hygiene: client split (cite, don't repeat) + one-time ledger backfill
Client (already fully spec'd, design-specs-2026-07-ui.md:709-726): settings-screen.tsx's handleTogglePush (:273-301) stops calling grantConsent/revokeConsent with purposeKey: 'marketing_push' — it becomes a pure notificationsEnabled write + the existing logConsent (category: 'system') audit-trail call, nothing else. A new row is added directly below it in the same SectionCard: label "마케팅 알림 (광고)", its own Switch, wired to the exact grantConsent/revokeConsent calls the old handler used to fire, collectionMethod: 'settings_toggle' unchanged (the ledger's collection_method column records the mechanism, not whether the label was honest — no schema change needed for the split itself).
Backend — the ledger remediation wiring-plan A4 named but never sliced (this document claims it):
-- Provisional 00398 — MUST ship strictly AFTER the client toggle-fix OTA
-- has shipped and baked. Sequencing this before the client fix means new
-- mislabeled grants keep landing past the remediation date. The cutoff
-- timestamp is filled in at ship time to the exact OTA-publish moment
-- (do not use `now()` at migration-authoring time — this file is written
-- before the client fix ships).
DO $$
DECLARE
v_cutoff TIMESTAMPTZ := '<client-toggle-fix-ota-publish-timestamp>'::timestamptz;
v_row RECORD;
BEGIN
FOR v_row IN
SELECT uc.id, uc.user_id, uc.purpose_id, uc.jurisdiction_at_collection
FROM public.user_consent uc
JOIN public.purpose_catalog pc ON pc.id = uc.purpose_id
WHERE uc.state = 'granted'
AND pc.purpose_key IN ('marketing_push', 'marketing_push_night')
AND uc.collection_method = 'settings_toggle'
AND uc.created_at < v_cutoff
LOOP
-- Append-only ledger — insert a withdrawal row, never mutate the
-- existing grant (matches the ledger's own append-only design and
-- protect_consent_timestamps's no-backdating guarantee).
INSERT INTO public.user_consent (
user_id, purpose_id, state, jurisdiction_at_collection,
collection_method, revokes_consent_id
) VALUES (
v_row.user_id, v_row.purpose_id, 'withdrawn',
v_row.jurisdiction_at_collection, 'admin_action', v_row.id
);
END LOOP;
END $$;Post-migration, any affected user's next visit to the (now honestly-labeled) 마케팅 알림 row reads false — they were never informed, so the ledger now reflects that, and they can freely re-grant knowingly if they want marketing pushes. No re-consent campaign (an active outbound prompt) is designed here — matches wiring-plan A4's own scope ("this audit's backend scope covers the one-time ledger remediation, not the client toggle fix" — and, by the same logic, not a re-marketing campaign); flagged as an optional product follow-up, not a required slice, since B8 already confirmed zero live marketing-send path exists today — there is no user-facing consequence to the ledger correction happening quietly.
3.3 I3 — OAuth login screen + cancel/failure fix + StyledInput convergence
Screen (already fully spec'd, design-specs-2026-07-ui.md:648-678): below the existing email/password Card, a Divider labeled "또는", then a vertical ActionButton/Button stack in provider order Kakao → Apple → Google with the design system's locked-in brand colors (카카오 #FEE500/#191919, Apple black/white per HIG, Google white/gray-border). The oauthComingSoon disclaimer text is removed once the buttons render. Each button calls the existing useAuth().signInWithOAuth(provider) — already correctly wired end-to-end for Kakao (EXT-11-blocked on production credentials only, not a code gap) and will throw the existing AUTH_GOOGLE_NOT_IMPLEMENTED/AUTH_APPLE_NOT_IMPLEMENTED DomainErrors for the other two until their native SDKs land — which is exactly why the failure-surfacing fix below must ship in the same slice: today those throws are silent.
Cancel/failure fix — one line closes the asymmetry with the email path:
const signInWithOAuth = useCallback(
async (provider: OAuthProvider): Promise<void> => {
...
} catch (err: unknown) {
const e = err instanceof Error ? err : new Error(String(err));
setError(e);
logger.error(`SessionProvider: signInWithOAuth(${provider}) failed`, e);
captureCriticalError(e, { context: { source: 'session-provider.sign-in-oauth' } });
+ // Re-throw so LoginScreen's catch can surface the error — mirrors
+ // signInWithEmail's identical re-throw one function up. Was a
+ // silent Sentry-only failure; dangerous the moment a real button
+ // exists (U7 #4).
+ throw e;
} finally {
operationInProgress.current = false;
}
},
[deriveAuthState]
);login-screen.tsx's new provider-button onPress handlers catch this the same way the existing email submit handler does (setErrorMessage(formatError(err))), rendered into the same existing error Card (no new error component — design spec's explicit instruction). Cancel stays a silent no-op (unchanged — the design spec makes this intentional, not an oversight).
StyledInput extension, not a new component (§2.3's finding):
// packages/ui/src/labeled-field.tsx — additive props only, no breaking change
export interface StyledInputProps {
// ...existing fields unchanged...
secureTextEntry?: boolean;
textContentType?: 'password' | 'newPassword' | 'oneTimeCode';
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
autoCorrect?: boolean;
}login-screen.tsx's two raw TextInputs (email/password), change-password-screen.tsx's three (current/new/confirm), and delete-account-screen.tsx's one all converge onto StyledInput, deleting their individual StyleSheet.create blocks — the design-system's "third-party locked-in" color rule and Tamagui-only canon now have exactly one native-input escape hatch instead of three independently-drifting ones.
Port-doc fix (bundled, U7 #15): auth.service.port.ts:20-21's docstring corrected from "Universal OAuth sign-in via browser redirect. Works for ALL providers." to "Native-SDK OIDC token exchange per provider (Kakao/Google/Apple) via signInWithIdToken — not the PKCE browser-redirect flow (that governs email/magic-link only, see client.ts flowType)."
Session-expired banner (bundled, U7 #6, same screen, already spec'd):design-specs-2026-07-ui.md:774-785 — LoginScreen accepts an optional reason param; when the redirect reason is 'expired', render one Text role="cardMeta" line above the form ("세션이 만료됐어요, 다시 로그인해주세요"). Bundled into this slice purely because it's the same file and the same review pass, not because it's related in substance.
3.4 I4 — signal_preferences settings screen (Wireframe C) + retirement of 6 orphaned booleans
Screen (already fully spec'd, cite not repeat): design-specs-2026-07-ui.md:680-707 + Wireframe C (:983-1010). Build at the existing dead route /settings/signal-preferences. DetailShell + GroupedFeedList clustered into ~5 user-facing groups (일정/회비/경기/소셜/ 시스템 mapping the 13 raw signal_category values), each row a category label + 인앱/푸시 Switch pair + a min_severity DropdownChip where meaningful (dues, matches — not system announcements), a bottom 방해 금지 시간 row opening a ModalPanel time picker writing the existing quiet_hours JSONB shape unchanged. Backed by the already-live findPreferences/updatePreference adapter methods (signal.supabase.ts:167-186) — no migration, pure client-OTA.
Retirement of the 6 orphaned user_preferences booleans — the decision this document makes explicit per the design spec's own instruction ("decide signal_preferences as the model and migrate/retire … rather than surfacing both"): notifySessionCreated, notifyRsvpUpdates, notifyDuesReminder, notifyMatchResults, notifyBoardPosts, notifyAchievements map 1:1 onto existing signal_category values (session, rsvp, dues, matchup, club or social for board posts, progression for achievements) — no new category needed. Two choices: (a) delete the 6 columns outright, or (b) leave them in place, unread, until a follow-up migration formally drops them. Recommend (b) for this slice — deleting live schema columns is a separate, higher-blast-radius migration than "ship a settings screen," and nothing currently reads these 6 booleans to gate a send (§2.4's flagged-but-unresolved question) — confirming that before dropping columns is the safer order of operations. notificationsEnabled/notifySessionReminders/dmPushPreviewEnabled (the 3 wired booleans) are not retired — they gate concerns signal_preferences doesn't model (dmPushPreviewEnabled is a content-preview toggle, not a category on/off) and stay exactly as they are.
Seam with PS8, restated: this screen only writes signal_preferences/reads its own values back. It does not touch how a signals row gets produced, routed, or deep-linked — that's PS8's charter once authored. If PS8 later needs the retirement decision above (e.g. to decide what send-push should read), it should cite this section rather than re-litigate the model choice.
3.5 I5 — change-password: correct the stale comment, no functional change
- * - 현재 비밀번호 (shown for user reassurance; not validated against current)
+ * - 현재 비밀번호 (re-authenticated via signInWithPassword before the update —
+ * see auth.supabase.ts changePassword(); INVALID_CURRENT_PASSWORD surfaces
+ * inline under this field)One line, change-password-screen.tsx:5. No RPC/adapter change — §2.5 confirmed the mechanism is already correct and already wired to the UI's currentPasswordError state. Pure client-OTA, bundled into whichever slice next touches this file rather than shipped alone (not worth its own review cycle).
3.6 Deferred, not a slice — matches PS1's own A5 exactly
record_pi_access (PIPA §30 access-audit RPC, zero call sites beyond delete_account_atomic's one hardcoded insert) and nationality_code's missing write- protection trigger (B8 #5/#7) are not planned as active migrations here, mirroring wiring-plans-2026-07-backend.md A5 and ps1-access-control.md:327-329's own explicit deferral verbatim. nationality_code's "first-set-wins" immutability trigger (mirroring birth_year's reject_under14_birth_year shape) belongs in whichever migration first implements verified-IdP onboarding — building it speculatively now, with no real writer, would be the exact dead-code anti-pattern this whole audit series exists to close elsewhere. record_pi_access wiring is a product decision (which PI-viewing flows need §30 coverage beyond account deletion) for a dedicated future task, not bundled here.
4. Slices
Every slice below is E2E-verified per the directory-level verification discipline (not just yarn check) — a seeded scenario or a live psql probe exercising the real RPC+RLS+client path, recorded at slice-close time.
| # | Migration/scope | Item | Type | Blast radius | Verification | Depends on |
|---|---|---|---|---|---|---|
| 1 | 00397 | I1 — delete_account_atomic extension (auth.users.banned_until lockout + release_departed_user_open_rsvps backstop) | migration | auth.users (1 column), rsvps, session_payments (indirect via existing trigger) | Local probe (BEGIN...ROLLBACK): create a user with (a) a guest-application-approved confirmed RSVP on a future session with a paid fee, no club membership; (b) call delete_account_atomic() as that user; assert the RSVP is cancelled, the idle session_payments hold is gone, and SELECT banned_until FROM auth.users WHERE id = <user> returns a future timestamp. Second probe: attempt supabase.auth.signInWithPassword (or the local GoTrue equivalent) as the now-banned user — expect rejection. Third: as the (still-locally-authenticated-within-the-same-transaction) user, attempt UPDATE profiles SET deleted_at = NULL WHERE id = self — this specific probe only proves the RLS path is unchanged (still permitted at the DB layer, since the real fix is the auth-layer ban, not an RLS narrowing) — document this explicitly as the residual the ban addresses, not RLS. Extend PS6's proposed account_deletion_lifecycle scenario chain (scripts/run-scenario.mjs) with this slice's assertions rather than authoring a second chain — ONE canonical deletion scenario, matching the ONE canonical deletion path. | PS6's 00394 (calls private.release_member_future_seats, extracted there) |
| 2 | client-OTA | I2a — split marketing consent off the generic push toggle; add labeled 마케팅 알림 (광고) row | client | settings-screen.tsx | Manual: toggle 푸시 알림 on/off, confirm user_consent gains no marketing_push row (query via dev panel or direct probe); toggle the new row, confirm it does. yarn check. | none |
| 3 | 00398 | I2b — one-time marketing_push/marketing_push_night ledger backfill (withdrawal rows for pre-fix settings_toggle grants) | migration | user_consent (INSERT-only, append-only ledger) | Local probe: seed a granted row with collection_method='settings_toggle' dated before the cutoff; run the migration; assert a withdrawn row now exists with revokes_consent_id pointing at it and the original row untouched (never mutated). Re-run SELECT * FROM user_consent WHERE purpose_id LIKE 'marketing_push%' AND state='granted' — only post-cutoff genuine grants remain. | Slice 2 shipped + baked (hard sequencing constraint, §3.2) |
| 4 | client-OTA | I3 — OAuth button stack, cancel/failure fix (session-provider.tsx re-throw), StyledInput prop extension + 4-file convergence, port-doc correction, session-expired banner | client | login-screen.tsx, session-provider.tsx, packages/ui/src/labeled-field.tsx, change-password-screen.tsx, delete-account-screen.tsx, auth.service.port.ts | Manual (device/OTA, since this is the highest-traffic screen): Kakao button reachable but expected to fail with AUTH_KAKAO_NO_IDTOKEN or credential error until EXT-11 lands — assert the error now renders inline (not silent); Google/Apple buttons show their existing throw-stub messages inline instead of silently; email login still works unchanged; visually confirm all 6 converged inputs render identically to today (no regression from the StyledInput prop extension). yarn check. Kakao's actual successful sign-in is EXT-11-blocked — cannot be device-verified until production credentials land; note this explicitly rather than claim full E2E closure. | none |
| 5 | client-OTA | I4 — signal_preferences settings screen at /settings/signal-preferences (Wireframe C) | client | new screen file, route mount, settings-screen.tsx (nav row into it if none exists) | Manual: open the screen, toggle a category's push off, background/reopen the app, confirm the value persisted (re-fetch from signal_preferences); trigger a signal in that category, confirm it's suppressed from push but still appears in-app per in_app_enabled. yarn check. | none |
| 6 | client-OTA | I5 — change-password header-comment correction | client | change-password-screen.tsx (comment only) | yarn check (no functional change to verify beyond compilation) | none |
Ordering rationale: Slice 1 is CRITICAL and has exactly one hard dependency (PS6's 00394) — sequence directly after it, before PS6's own 00395/00396 if convenient, since this slice doesn't touch club-domain objects. Slice 3 has a timing, not an ordering, dependency on Slice 2 (must bake in production first) — do not schedule Slice 3's migration until Slice 2's OTA has been live for a deliberate window (matches this project's own OTA- bake-before-next-step discipline). Slices 4/5/6 are fully independent client-OTA work, batchable in any order; Slice 4 is the highest-review-cost slice (touches the most-trafficked screen) and should get a dedicated device pass rather than being bundled silently into a larger OTA.
Not in scope for this document: the profiles_select/profiles_update/ can_view_profile_field RLS+column-protection mechanism (PS1, ps1-access-control.md §3.1-3.2, migrations 00388-00393); the club-membership half of account deletion (PS6, ps6-clubs-membership.md §3.1, migrations 00394-00396); ex-member dues cleanup outside the account-deletion path (PS5); session-payment hold-lifecycle redesign for the submitted-but-stranded residual noted in §2.1 (PS2); record_pi_access/nationality_code write-protection (§3.6, roadmap-adjacent); DM-domain UX findings (U7 #6/#8/#9 — unclaimed by any current PS, flagged for the roadmap to route); docs/canon/korean-market.md:22's "annual" vs the verified 2-year cron-interval doc drift (one-line DOC-phase fix, not a slice).