From 78abc4ae90cdb800bba43054d64ca8ded862eb10 Mon Sep 17 00:00:00 2001
From: Abdulrahman
Date: Sat, 1 Aug 2026 21:35:50 +0300
Subject: [PATCH 01/10] feat: add profile education schema foundation
Refine the profile and education schema with explicit column grants, RLS, section revision triggers, review and primary-selection RPCs, and forward-only legacy compatibility.
---
docs/DATA_MODEL.md | 18 +-
docs/RLS_POLICY_MATRIX.md | 7 +-
docs/SECURITY_AND_PRIVACY.md | 7 +
docs/TESTING_STRATEGY.md | 6 +
.../20260801213000_profile_core_education.sql | 449 ++++++++++++++++++
.../tests/database/000_structure.test.sql | 58 ++-
.../tests/database/010_profiles_rls.test.sql | 21 +-
.../020_education_entries_rls.test.sql | 31 +-
.../030_profile_section_state.test.sql | 74 +++
9 files changed, 630 insertions(+), 41 deletions(-)
create mode 100644 supabase/migrations/20260801213000_profile_core_education.sql
create mode 100644 supabase/tests/database/030_profile_section_state.test.sql
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
index c489727..9d7c9ab 100644
--- a/docs/DATA_MODEL.md
+++ b/docs/DATA_MODEL.md
@@ -81,9 +81,21 @@ erDiagram
## Profile domain (Phase 1A proposal; user-owned unless marked shared)
-**Implementation status**: only the deliberately minimal Phase 0 `profiles` and
-`education_entries` migration exists. Everything in this section is a Phase 1A logical target,
-not implemented SQL. Phase 1B resume and suggestion records are expressly excluded.
+**Implementation status**: the first Phase 1A vertical slice is implemented: refined
+`profiles`, refined `education_entries`, and section freshness/review rows for `basic_profile`
+and `education`. The remaining profile-domain tables below remain logical targets, not
+implemented SQL. Phase 1B resume and suggestion records are expressly excluded.
+
+### Implemented profile-core migration note
+
+The Phase 0 `profiles.degree_program`, `profiles.degree_year`, and
+`profiles.profile_complete` columns are retained temporarily as deprecated compatibility fields.
+They are not authoritative and the browser does not write them. A forward migration copies a
+legacy degree year only when its profile has exactly one existing education row and that target
+field is null; it never chooses a primary row, infers a status, creates an institution, or maps
+the ambiguous legacy degree-program text into `degree` or `field`. Existing profiles begin with
+`basic_profile` and `education` revisions at zero and no reviews. New profile creation initializes
+those rows transactionally.
**Ownership rule (binding, see [AGENTS.md](../AGENTS.md))**: every user-owned table has direct
`user_id`, including association tables. The root `profiles.user_id` references `auth.users(id)`;
diff --git a/docs/RLS_POLICY_MATRIX.md b/docs/RLS_POLICY_MATRIX.md
index 0a5ab94..471dfb6 100644
--- a/docs/RLS_POLICY_MATRIX.md
+++ b/docs/RLS_POLICY_MATRIX.md
@@ -57,8 +57,9 @@ both this file and its corresponding test.
## Profile domain (user-owned)
-**Status**: `profiles` and `education_entries` are implemented and pgTAP-tested as of Phase 0.
-All other rows in this profile-domain section are proposed Phase 1A policy, not schema. Phase 1B
+**Status**: `profiles`, `education_entries`, `profile_section_revisions`, and
+`profile_section_reviews` are implemented and pgTAP-tested in the first Phase 1A slice. All other
+rows in this profile-domain section remain proposed Phase 1A policy, not schema. Phase 1B
resume/suggestion tables are intentionally not designed here.
Every table's owner column is literally named `user_id` (see [AGENTS.md](../AGENTS.md)) — child
@@ -71,7 +72,7 @@ once per statement rather than once per row — the currently-recommended, non-d
|---|---|---|---|---|---|---|---|---|---|---|
| `profiles` | User-owned root row; direct `user_id` references Auth user | Own only | Own only | Own only | **No direct browser DELETE** | Trusted service/administrative code may have broader maintenance, export, repair, and coordinated-account-deletion access. Root-profile deletion is permitted only in an explicit trusted workflow that coordinates the Auth user, database records, Storage objects, confirmation, and any export requirement — never as a normal browser or routine service-role path. | `(select auth.uid()) = user_id` for SELECT/INSERT/UPDATE; no browser DELETE grant/policy | No | Owner SELECT/INSERT/UPDATE; DELETE fails even for owner; cross-user and forged-owner denial; service deletion is exercised only through the trusted workflow | High |
| `education_entries`, `work_experience`, `projects`, `profile_links`, `user_skills`, `user_languages`, `preferences`, `target_companies` | User-owned; direct `user_id` references `profiles(user_id)` | Own only | Own only | Own only | Own only | Full for maintenance/export/account deletion | `(select auth.uid()) = user_id` for `USING` and `WITH CHECK` | No | Owner CRUD; cross-user SELECT/UPDATE/DELETE denial; forged `user_id` insert; ownership-rewrite denial | High (`profile_links`/targets Medium) |
-| `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | Own only | Own only | Own only | Full | Direct owner predicate; controlled section key and current revision must be recorded | No | Owner CRUD; forged-owner and cross-user denial; cannot write a review for an unknown key or stale/future revision | High |
+| `profile_section_reviews` | User-owned deliberate review; composite key `(user_id, section_key)` | Own only | **No direct browser write**; restricted RPC only | **No direct browser write**; restricted RPC only | **No direct browser write** | Full | Direct owner SELECT; `review_profile_section` derives `auth.uid()` and locks the current revision | No | User cannot forge an owner/key/revision; RPC writes only the locked current revision | High |
| `profile_section_revisions` | User-owned, database-maintained freshness metadata | Own only | No direct browser write | No direct browser write | No direct browser write | Trusted migration/mutation mechanism only | Direct owner SELECT; no browser mutation grant | No | User can read only own revision; browser cannot forge/increment/rewrite revision; relevant insert/update/delete increments it atomically | Medium |
| `preference_locations`, `user_target_engineering_areas`, `user_target_role_types` | User-owned relation; direct `user_id` | Own only | Own only | Own only where applicable | Own only | Full | Direct owner predicate plus composite parent `(user_id, parent_id)` validation | No | Owner CRUD; all cross-user denials; forged owner; cannot attach a valid user A row to B's preferences/profile | Medium |
| `project_skills` | User-owned relation; direct `user_id` | Own only | Own only | N/A or own only | Own only | Full | Direct owner predicate plus `(user_id, project_id)` composite FK | No | User A cannot attach B's project to A's skill or vice versa, even when UUID is known; owner CRUD | High |
diff --git a/docs/SECURITY_AND_PRIVACY.md b/docs/SECURITY_AND_PRIVACY.md
index d084f1a..fe21500 100644
--- a/docs/SECURITY_AND_PRIVACY.md
+++ b/docs/SECURITY_AND_PRIVACY.md
@@ -46,6 +46,13 @@ SELECT/INSERT/UPDATE/DELETE permissions, the expected RLS predicate, anonymous-a
the isolation test each row requires. This section states the *principle*; that document is the
*checklist* a migration must satisfy.
+**Implemented Phase 1A profile-core hardening**: section revisions and reviews are browser
+read-only. The only review mutation is a narrowly granted `SECURITY DEFINER` RPC that derives the
+caller from `auth.uid()`, locks that user's current revision, and records that exact value. Primary
+education selection likewise uses a narrowly granted ownership-checking RPC; browser clients do
+not have column privilege to set `is_primary` directly. Every privileged function fixes its search
+path, revokes default `PUBLIC` execution, and grants only the browser RPCs to `authenticated`.
+
**Row-level data isolation for future public users**: because the schema is already `user_id`-
scoped and RLS-enforced, enabling additional user accounts later is a matter of allowing sign-up —
the isolation mechanism doesn't change. This is the concrete mechanism behind the "private-first,
diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md
index 6892956..24090ec 100644
--- a/docs/TESTING_STRATEGY.md
+++ b/docs/TESTING_STRATEGY.md
@@ -72,6 +72,12 @@ each rule gets explicit positive and negative test cases.
## Phase 1A profile implementation tests (future)
+**Implemented in the first profile-core/education slice**: pgTAP now verifies refined profile and
+education schema/grants, browser denial of root-profile deletion and direct revision/review/primary
+writes, revision freshness after profile and education mutations, locked review RPC behavior,
+primary-selection idempotence, and cascade safety. The real Auth/JWT/PostgREST suite is extended
+in the following commit; later Phase 1A domains remain future work.
+
- **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys,
ownership-safe composite parent references, primary-education partial unique constraint,
typed-skill-evidence exactly-one-source check, bounded note, section-review/revision constraints,
diff --git a/supabase/migrations/20260801213000_profile_core_education.sql b/supabase/migrations/20260801213000_profile_core_education.sql
new file mode 100644
index 0000000..461a96d
--- /dev/null
+++ b/supabase/migrations/20260801213000_profile_core_education.sql
@@ -0,0 +1,449 @@
+-- Phase 1A, first product slice: profile core and primary education.
+--
+-- This is intentionally forward-only. The Phase 0 profile fields remain in
+-- place as deprecated compatibility data until a later, audited cleanup
+-- migration can prove they have been manually resolved.
+
+-- ---------------------------------------------------------------------------
+-- Schema evolution and safe legacy backfill
+-- ---------------------------------------------------------------------------
+
+alter table public.profiles
+ add column preferred_name text,
+ add column created_via text not null default 'manual',
+ add column updated_at timestamptz not null default now(),
+ add column last_confirmed_at timestamptz not null default now(),
+ add constraint profiles_created_via_check check (created_via in ('manual', 'migration'));
+
+alter table public.education_entries
+ add column degree_year smallint,
+ add column expected_graduation_month smallint,
+ add column expected_graduation_year smallint,
+ add column education_status text not null default 'unknown',
+ add column is_primary boolean not null default false,
+ add column created_via text not null default 'manual',
+ add column updated_at timestamptz not null default now(),
+ add column last_confirmed_at timestamptz not null default now(),
+ add constraint education_entries_degree_year_check check (degree_year is null or degree_year between 1 and 10),
+ add constraint education_entries_graduation_month_check check (expected_graduation_month is null or expected_graduation_month between 1 and 12),
+ add constraint education_entries_graduation_year_check check (expected_graduation_year is null or expected_graduation_year between 2000 and 2100),
+ add constraint education_entries_graduation_pair_check check (
+ (expected_graduation_month is null) = (expected_graduation_year is null)
+ ),
+ add constraint education_entries_status_check check (education_status in ('current', 'completed', 'paused', 'withdrawn', 'unknown')),
+ add constraint education_entries_current_end_date_check check (education_status <> 'current' or end_date is null),
+ add constraint education_entries_completed_end_date_check check (education_status <> 'completed' or end_date is not null),
+ add constraint education_entries_graduation_current_check check (
+ expected_graduation_year is null or education_status = 'current'
+ ),
+ add constraint education_entries_graduation_after_start_check check (
+ start_date is null
+ or expected_graduation_year is null
+ or make_date(expected_graduation_year, expected_graduation_month, 1) >= date_trunc('month', start_date)::date
+ ),
+ add constraint education_entries_primary_current_check check (not is_primary or education_status = 'current'),
+ add constraint education_entries_created_via_check check (created_via in ('manual', 'migration'));
+
+-- The Phase 0 migration allowed blank/overlong text. Do not use NOT VALID
+-- text CHECKs: PostgreSQL would then reject unrelated edits to a preserved
+-- legacy row. The validation triggers below instead validate every new value
+-- and every changed text value, while leaving unchanged legacy data editable.
+update public.profiles
+set
+ created_via = 'migration',
+ updated_at = created_at,
+ last_confirmed_at = null;
+
+update public.education_entries
+set
+ created_via = 'migration',
+ updated_at = created_at,
+ last_confirmed_at = null;
+
+-- Only the degree-year mapping has identical semantics in the target model.
+-- A lone legacy education row is not made primary and is not assigned a status.
+with profiles_with_one_education as (
+ select user_id, (array_agg(id order by id))[1] as education_id
+ from public.education_entries
+ group by user_id
+ having count(*) = 1
+)
+update public.education_entries as education
+set degree_year = profile.degree_year
+from public.profiles as profile
+join profiles_with_one_education as one_education on one_education.user_id = profile.user_id
+where education.id = one_education.education_id
+ and profile.degree_year is not null
+ and education.degree_year is null;
+
+comment on column public.profiles.degree_program is
+ 'Deprecated Phase 0 compatibility field. Not authoritative; do not write from the browser or use for completeness.';
+comment on column public.profiles.degree_year is
+ 'Deprecated Phase 0 compatibility field. Safely copied only to a lone existing education row; not authoritative.';
+comment on column public.profiles.profile_complete is
+ 'Deprecated Phase 0 compatibility field. Completeness is derived; this value is not authoritative.';
+comment on column public.profiles.created_via is
+ 'How this current-format profile row entered the system: manual or migration.';
+comment on column public.profiles.last_confirmed_at is
+ 'Last explicit manual profile-content creation or edit; separate from section review.';
+comment on column public.education_entries.last_confirmed_at is
+ 'Last explicit manual education-content creation or edit; separate from section review.';
+
+create unique index education_entries_one_primary_per_user_idx
+ on public.education_entries (user_id)
+ where is_primary;
+
+-- ---------------------------------------------------------------------------
+-- Section state. Existing data starts at a neutral baseline with no review.
+-- ---------------------------------------------------------------------------
+
+create table public.profile_section_revisions (
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ section_key text not null check (section_key in ('basic_profile', 'education')),
+ content_revision bigint not null default 0 check (content_revision >= 0),
+ updated_at timestamptz not null default now(),
+ primary key (user_id, section_key)
+);
+
+create table public.profile_section_reviews (
+ user_id uuid not null references public.profiles (user_id) on delete cascade,
+ section_key text not null check (section_key in ('basic_profile', 'education')),
+ reviewed_content_revision bigint not null check (reviewed_content_revision >= 0),
+ reviewed_at timestamptz not null default now(),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ primary key (user_id, section_key),
+ foreign key (user_id, section_key)
+ references public.profile_section_revisions (user_id, section_key)
+ on delete cascade
+);
+
+insert into public.profile_section_revisions (user_id, section_key, content_revision)
+select profiles.user_id, section_keys.section_key, 0
+from public.profiles
+cross join (values ('basic_profile'::text), ('education'::text)) as section_keys(section_key);
+
+comment on table public.profile_section_revisions is
+ 'Database-maintained, deletion-safe section content mutation counters.';
+comment on table public.profile_section_reviews is
+ 'Deliberate review recorded against an exact profile-section content revision.';
+
+-- ---------------------------------------------------------------------------
+-- Hardened database functions and triggers
+-- ---------------------------------------------------------------------------
+
+create function public.raise_profile_content_validation(
+ value text,
+ field_name text,
+ maximum_length integer
+)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if value is not null and (btrim(value) = '' or char_length(value) > maximum_length) then
+ raise exception using
+ errcode = '23514',
+ message = 'invalid profile content',
+ detail = field_name,
+ constraint = 'profile_content_text_check';
+ end if;
+end;
+$$;
+
+create function public.profiles_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op = 'INSERT' then
+ perform public.raise_profile_content_validation(new.preferred_name, 'preferred_name', 100);
+ perform public.raise_profile_content_validation(new.headline, 'headline', 160);
+ return new;
+ end if;
+
+ if new.preferred_name is distinct from old.preferred_name then
+ perform public.raise_profile_content_validation(new.preferred_name, 'preferred_name', 100);
+ end if;
+ if new.headline is distinct from old.headline then
+ perform public.raise_profile_content_validation(new.headline, 'headline', 160);
+ end if;
+
+ if new.preferred_name is distinct from old.preferred_name
+ or new.headline is distinct from old.headline then
+ new.updated_at := now();
+ new.last_confirmed_at := now();
+ else
+ new.created_at := old.created_at;
+ new.created_via := old.created_via;
+ new.updated_at := old.updated_at;
+ new.last_confirmed_at := old.last_confirmed_at;
+ end if;
+ return new;
+end;
+$$;
+
+create function public.education_entries_before_write()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ content_changed boolean;
+begin
+ if tg_op = 'INSERT' then
+ perform public.raise_profile_content_validation(new.institution, 'institution', 200);
+ perform public.raise_profile_content_validation(new.degree, 'degree', 160);
+ perform public.raise_profile_content_validation(new.field, 'field', 160);
+ return new;
+ end if;
+
+ if new.institution is distinct from old.institution then
+ perform public.raise_profile_content_validation(new.institution, 'institution', 200);
+ end if;
+ if new.degree is distinct from old.degree then
+ perform public.raise_profile_content_validation(new.degree, 'degree', 160);
+ end if;
+ if new.field is distinct from old.field then
+ perform public.raise_profile_content_validation(new.field, 'field', 160);
+ end if;
+
+ content_changed := new.institution is distinct from old.institution
+ or new.degree is distinct from old.degree
+ or new.field is distinct from old.field
+ or new.degree_year is distinct from old.degree_year
+ or new.expected_graduation_month is distinct from old.expected_graduation_month
+ or new.expected_graduation_year is distinct from old.expected_graduation_year
+ or new.education_status is distinct from old.education_status
+ or new.is_primary is distinct from old.is_primary
+ or new.start_date is distinct from old.start_date
+ or new.end_date is distinct from old.end_date;
+
+ if content_changed then
+ new.updated_at := now();
+ new.last_confirmed_at := now();
+ else
+ new.user_id := old.user_id;
+ new.created_at := old.created_at;
+ new.created_via := old.created_via;
+ new.updated_at := old.updated_at;
+ new.last_confirmed_at := old.last_confirmed_at;
+ end if;
+ return new;
+end;
+$$;
+
+create function public.increment_profile_section_revision(target_user_id uuid, target_section_key text)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ -- The profile-existence guard makes a cascading account deletion harmless:
+ -- after the parent disappears there is no state left to recreate.
+ insert into public.profile_section_revisions (user_id, section_key, content_revision, updated_at)
+ select target_user_id, target_section_key, 1, now()
+ where exists (select 1 from public.profiles where user_id = target_user_id)
+ on conflict (user_id, section_key) do update
+ set content_revision = public.profile_section_revisions.content_revision + 1,
+ updated_at = excluded.updated_at;
+end;
+$$;
+
+create function public.profiles_after_content_change()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ if tg_op = 'INSERT' then
+ insert into public.profile_section_revisions (user_id, section_key, content_revision)
+ values (new.user_id, 'basic_profile', 0), (new.user_id, 'education', 0)
+ on conflict (user_id, section_key) do nothing;
+ perform public.increment_profile_section_revision(new.user_id, 'basic_profile');
+ elsif new.preferred_name is distinct from old.preferred_name
+ or new.headline is distinct from old.headline then
+ perform public.increment_profile_section_revision(new.user_id, 'basic_profile');
+ end if;
+ return null;
+end;
+$$;
+
+create function public.education_entries_after_content_change()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ owner_id uuid;
+begin
+ owner_id := case when tg_op = 'DELETE' then old.user_id else new.user_id end;
+ if tg_op <> 'UPDATE'
+ or new.institution is distinct from old.institution
+ or new.degree is distinct from old.degree
+ or new.field is distinct from old.field
+ or new.degree_year is distinct from old.degree_year
+ or new.expected_graduation_month is distinct from old.expected_graduation_month
+ or new.expected_graduation_year is distinct from old.expected_graduation_year
+ or new.education_status is distinct from old.education_status
+ or new.is_primary is distinct from old.is_primary
+ or new.start_date is distinct from old.start_date
+ or new.end_date is distinct from old.end_date then
+ perform public.increment_profile_section_revision(owner_id, 'education');
+ end if;
+ return null;
+end;
+$$;
+
+create trigger profiles_before_write
+before insert or update on public.profiles
+for each row execute function public.profiles_before_write();
+
+create trigger profiles_after_content_change
+after insert or update on public.profiles
+for each row execute function public.profiles_after_content_change();
+
+create trigger education_entries_before_write
+before insert or update on public.education_entries
+for each row execute function public.education_entries_before_write();
+
+create trigger education_entries_after_content_change
+after insert or update or delete on public.education_entries
+for each row execute function public.education_entries_after_content_change();
+
+create function public.review_profile_section(requested_section_key text)
+returns bigint
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ caller_id uuid := auth.uid();
+ current_revision bigint;
+begin
+ if caller_id is null then
+ raise exception using errcode = '28000', message = 'authentication required';
+ end if;
+ if requested_section_key not in ('basic_profile', 'education') then
+ raise exception using errcode = '22023', message = 'unknown profile section';
+ end if;
+
+ select content_revision into current_revision
+ from public.profile_section_revisions
+ where user_id = caller_id and section_key = requested_section_key
+ for update;
+ if not found then
+ raise exception using errcode = 'P0002', message = 'profile section does not exist';
+ end if;
+
+ insert into public.profile_section_reviews (
+ user_id, section_key, reviewed_content_revision, reviewed_at, created_at, updated_at
+ ) values (caller_id, requested_section_key, current_revision, now(), now(), now())
+ on conflict (user_id, section_key) do update
+ set reviewed_content_revision = excluded.reviewed_content_revision,
+ reviewed_at = excluded.reviewed_at,
+ updated_at = excluded.updated_at;
+ return current_revision;
+end;
+$$;
+
+create function public.set_primary_education(education_id uuid)
+returns void
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ caller_id uuid := auth.uid();
+ selected_status text;
+begin
+ if caller_id is null then
+ raise exception using errcode = '28000', message = 'authentication required';
+ end if;
+
+ -- Deterministic locking serializes simultaneous primary-selection requests.
+ perform 1
+ from public.education_entries
+ where user_id = caller_id
+ order by id
+ for update;
+
+ select education_status into selected_status
+ from public.education_entries
+ where id = education_id and user_id = caller_id;
+ if not found then
+ raise exception using errcode = 'P0002', message = 'education entry not found';
+ end if;
+ if selected_status <> 'current' then
+ raise exception using errcode = '23514', message = 'primary education must be current';
+ end if;
+
+ update public.education_entries
+ set is_primary = false
+ where user_id = caller_id and is_primary and id <> education_id;
+
+ update public.education_entries
+ set is_primary = true
+ where user_id = caller_id and id = education_id and not is_primary;
+end;
+$$;
+
+-- No function starts browser-callable merely because it is created in public.
+revoke all on function public.raise_profile_content_validation(text, text, integer) from public, anon, authenticated;
+revoke all on function public.profiles_before_write() from public, anon, authenticated;
+revoke all on function public.education_entries_before_write() from public, anon, authenticated;
+revoke all on function public.increment_profile_section_revision(uuid, text) from public, anon, authenticated;
+revoke all on function public.profiles_after_content_change() from public, anon, authenticated;
+revoke all on function public.education_entries_after_content_change() from public, anon, authenticated;
+revoke all on function public.review_profile_section(text) from public, anon;
+revoke all on function public.set_primary_education(uuid) from public, anon;
+grant execute on function public.review_profile_section(text) to authenticated;
+grant execute on function public.set_primary_education(uuid) to authenticated;
+
+-- ---------------------------------------------------------------------------
+-- Explicit grants and RLS
+-- ---------------------------------------------------------------------------
+
+drop policy if exists profiles_delete_own on public.profiles;
+revoke all on public.profiles from anon, authenticated, service_role;
+grant select on public.profiles to authenticated;
+grant insert (user_id, preferred_name, headline) on public.profiles to authenticated;
+grant update (preferred_name, headline) on public.profiles to authenticated;
+grant select, insert, update, delete on public.profiles to service_role;
+
+revoke all on public.education_entries from anon, authenticated, service_role;
+grant select on public.education_entries to authenticated;
+grant insert (
+ user_id, institution, degree, field, degree_year,
+ expected_graduation_month, expected_graduation_year, education_status,
+ start_date, end_date
+) on public.education_entries to authenticated;
+grant update (
+ institution, degree, field, degree_year,
+ expected_graduation_month, expected_graduation_year, education_status,
+ start_date, end_date
+) on public.education_entries to authenticated;
+grant delete on public.education_entries to authenticated;
+grant select, insert, update, delete on public.education_entries to service_role;
+
+alter table public.profile_section_revisions enable row level security;
+alter table public.profile_section_reviews enable row level security;
+revoke all on public.profile_section_revisions from anon, authenticated, service_role;
+revoke all on public.profile_section_reviews from anon, authenticated, service_role;
+grant select on public.profile_section_revisions to authenticated;
+grant select on public.profile_section_reviews to authenticated;
+grant select, insert, update, delete on public.profile_section_revisions to service_role;
+grant select, insert, update, delete on public.profile_section_reviews to service_role;
+
+create policy profile_section_revisions_select_own on public.profile_section_revisions
+ for select to authenticated using ((select auth.uid()) = user_id);
+create policy profile_section_reviews_select_own on public.profile_section_reviews
+ for select to authenticated using ((select auth.uid()) = user_id);
diff --git a/supabase/tests/database/000_structure.test.sql b/supabase/tests/database/000_structure.test.sql
index 2b79c71..b357470 100644
--- a/supabase/tests/database/000_structure.test.sql
+++ b/supabase/tests/database/000_structure.test.sql
@@ -2,17 +2,21 @@
-- policy existence, and grant existence (including the absence of any
-- grant to anon). No fixture data needed; this only inspects catalogs.
begin;
-select plan(29);
+select plan(58);
-- profiles ------------------------------------------------------------------
select has_table('public', 'profiles', 'profiles table exists');
select has_column('public', 'profiles', 'user_id', 'profiles.user_id exists');
select has_column('public', 'profiles', 'headline', 'profiles.headline exists');
+select has_column('public', 'profiles', 'preferred_name', 'profiles.preferred_name exists');
select has_column('public', 'profiles', 'degree_program', 'profiles.degree_program exists');
select has_column('public', 'profiles', 'degree_year', 'profiles.degree_year exists');
select has_column('public', 'profiles', 'profile_complete', 'profiles.profile_complete exists');
select has_column('public', 'profiles', 'created_at', 'profiles.created_at exists');
+select has_column('public', 'profiles', 'created_via', 'profiles.created_via exists');
+select has_column('public', 'profiles', 'updated_at', 'profiles.updated_at exists');
+select has_column('public', 'profiles', 'last_confirmed_at', 'profiles.last_confirmed_at exists');
select col_is_pk('public', 'profiles', 'user_id', 'profiles.user_id is the primary key');
select fk_ok('public', 'profiles', 'user_id', 'auth', 'users', 'id', 'profiles.user_id references auth.users.id');
select has_check('public', 'profiles', 'profiles has a check constraint (degree_year range)');
@@ -24,15 +28,20 @@ select is(
);
select policies_are(
'public', 'profiles',
- array['profiles_select_own', 'profiles_insert_own', 'profiles_update_own', 'profiles_delete_own'],
- 'profiles has exactly the four expected policies'
+ array['profiles_select_own', 'profiles_insert_own', 'profiles_update_own'],
+ 'profiles has exactly the three expected policies'
);
select policy_cmd_is('public', 'profiles', 'profiles_select_own', 'SELECT', 'profiles_select_own applies to SELECT');
select policy_cmd_is('public', 'profiles', 'profiles_insert_own', 'INSERT', 'profiles_insert_own applies to INSERT');
select policy_cmd_is('public', 'profiles', 'profiles_update_own', 'UPDATE', 'profiles_update_own applies to UPDATE');
-select policy_cmd_is('public', 'profiles', 'profiles_delete_own', 'DELETE', 'profiles_delete_own applies to DELETE');
-
-select table_privs_are('public', 'profiles', 'authenticated', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'authenticated has exactly SELECT/INSERT/UPDATE/DELETE on profiles');
+select is(
+ has_table_privilege('authenticated', 'public.profiles', 'SELECT')
+ and has_column_privilege('authenticated', 'public.profiles', 'user_id', 'INSERT')
+ and has_column_privilege('authenticated', 'public.profiles', 'headline', 'UPDATE')
+ and not has_table_privilege('authenticated', 'public.profiles', 'DELETE'),
+ true,
+ 'authenticated has the intended profiles table and column privileges'
+);
select table_privs_are('public', 'profiles', 'service_role', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'service_role has exactly SELECT/INSERT/UPDATE/DELETE on profiles');
select table_privs_are('public', 'profiles', 'anon', array[]::text[], 'anon has no grants at all on profiles');
@@ -40,6 +49,14 @@ select table_privs_are('public', 'profiles', 'anon', array[]::text[], 'anon has
select has_table('public', 'education_entries', 'education_entries table exists');
select has_column('public', 'education_entries', 'user_id', 'education_entries.user_id exists (not profile_id)');
+select has_column('public', 'education_entries', 'degree_year', 'education_entries.degree_year exists');
+select has_column('public', 'education_entries', 'expected_graduation_month', 'education_entries.expected_graduation_month exists');
+select has_column('public', 'education_entries', 'expected_graduation_year', 'education_entries.expected_graduation_year exists');
+select has_column('public', 'education_entries', 'education_status', 'education_entries.education_status exists');
+select has_column('public', 'education_entries', 'is_primary', 'education_entries.is_primary exists');
+select has_column('public', 'education_entries', 'created_via', 'education_entries.created_via exists');
+select has_column('public', 'education_entries', 'updated_at', 'education_entries.updated_at exists');
+select has_column('public', 'education_entries', 'last_confirmed_at', 'education_entries.last_confirmed_at exists');
select col_is_pk('public', 'education_entries', 'id', 'education_entries.id is the primary key');
select fk_ok('public', 'education_entries', 'user_id', 'public', 'profiles', 'user_id', 'education_entries.user_id references profiles.user_id');
select has_index('public', 'education_entries', 'education_entries_user_id_idx', 'index on education_entries.user_id exists');
@@ -56,8 +73,35 @@ select policies_are(
'education_entries has exactly the four expected policies'
);
-select table_privs_are('public', 'education_entries', 'authenticated', array['SELECT', 'INSERT', 'UPDATE', 'DELETE'], 'authenticated has exactly SELECT/INSERT/UPDATE/DELETE on education_entries');
+select is(
+ has_table_privilege('authenticated', 'public.education_entries', 'SELECT, DELETE')
+ and has_column_privilege('authenticated', 'public.education_entries', 'institution', 'INSERT, UPDATE')
+ and not has_column_privilege('authenticated', 'public.education_entries', 'is_primary', 'UPDATE'),
+ true,
+ 'authenticated has intended editable education columns but cannot change is_primary directly'
+);
select table_privs_are('public', 'education_entries', 'anon', array[]::text[], 'anon has no grants at all on education_entries');
+-- section freshness state ----------------------------------------------------
+
+select has_table('public', 'profile_section_revisions', 'profile_section_revisions table exists');
+select has_table('public', 'profile_section_reviews', 'profile_section_reviews table exists');
+select has_column('public', 'profile_section_revisions', 'user_id', 'revisions own directly via user_id');
+select has_column('public', 'profile_section_revisions', 'content_revision', 'revisions counter exists');
+select has_column('public', 'profile_section_reviews', 'reviewed_content_revision', 'reviews record observed revision');
+select col_is_pk('public', 'profile_section_revisions', array['user_id', 'section_key'], 'revisions have composite identity');
+select col_is_pk('public', 'profile_section_reviews', array['user_id', 'section_key'], 'reviews have composite identity');
+select is((select relrowsecurity from pg_class where oid = 'public.profile_section_revisions'::regclass), true, 'RLS is enabled on revisions');
+select is((select relrowsecurity from pg_class where oid = 'public.profile_section_reviews'::regclass), true, 'RLS is enabled on reviews');
+select policies_are('public', 'profile_section_revisions', array['profile_section_revisions_select_own'], 'revisions have only the select-own policy');
+select policies_are('public', 'profile_section_reviews', array['profile_section_reviews_select_own'], 'reviews have only the select-own policy');
+select table_privs_are('public', 'profile_section_revisions', 'authenticated', array['SELECT'], 'authenticated can only select revisions');
+select table_privs_are('public', 'profile_section_reviews', 'authenticated', array['SELECT'], 'authenticated can only select reviews');
+select table_privs_are('public', 'profile_section_revisions', 'anon', array[]::text[], 'anon has no revisions grants');
+select table_privs_are('public', 'profile_section_reviews', 'anon', array[]::text[], 'anon has no reviews grants');
+select has_index('public', 'education_entries', 'education_entries_one_primary_per_user_idx', 'partial primary education index exists');
+select has_function('public', 'review_profile_section', array['text'], 'review RPC exists');
+select has_function('public', 'set_primary_education', array['uuid'], 'primary selection RPC exists');
+
select * from finish();
rollback;
diff --git a/supabase/tests/database/010_profiles_rls.test.sql b/supabase/tests/database/010_profiles_rls.test.sql
index e769f5f..970148c 100644
--- a/supabase/tests/database/010_profiles_rls.test.sql
+++ b/supabase/tests/database/010_profiles_rls.test.sql
@@ -69,14 +69,11 @@ select is(
'user A cannot update user B''s profile (0 rows affected)'
);
-with del as (
- delete from public.profiles where user_id = '22222222-2222-2222-2222-222222222222'
- returning 1
-)
-select is(
- (select count(*)::int from del),
- 0,
- 'user A cannot delete user B''s profile (0 rows affected)'
+select throws_ok(
+ $$ delete from public.profiles where user_id = '22222222-2222-2222-2222-222222222222' $$,
+ '42501'::char(5),
+ null,
+ 'user A has no profile DELETE privilege'
);
select throws_ok(
@@ -114,14 +111,16 @@ select is(
'service_role can read both users'' profiles (deliberate RLS bypass, not a leak)'
);
--- ---- Back to user A: owner delete (run last; mutates state other tests rely on) --
+-- ---- Back to user A: root profile delete is deliberately unavailable -------
reset role;
select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-1111-1111-111111111111', 'role', 'authenticated')::text, true);
set role authenticated;
-select lives_ok(
+select throws_ok(
$$ delete from public.profiles where user_id = '11111111-1111-1111-1111-111111111111' $$,
- 'user A can delete their own profile'
+ '42501'::char(5),
+ null,
+ 'user A cannot delete their own root profile'
);
reset role;
diff --git a/supabase/tests/database/020_education_entries_rls.test.sql b/supabase/tests/database/020_education_entries_rls.test.sql
index 936119b..f585a64 100644
--- a/supabase/tests/database/020_education_entries_rls.test.sql
+++ b/supabase/tests/database/020_education_entries_rls.test.sql
@@ -23,26 +23,26 @@ select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-
set role authenticated;
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'University of A') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'University of A') $$,
'user A can insert their own education entry'
);
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('aaaaaaaa-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'Second entry, deleted later') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'Second entry, deleted later') $$,
'user A can insert a second education entry'
);
select is(
- (select institution from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000001'),
+ (select institution from public.education_entries where institution = 'University of A'),
'University of A',
'user A can select their own education entry'
);
select lives_ok(
- $$ update public.education_entries set institution = 'University of A (renamed)' where id = 'aaaaaaaa-0000-0000-0000-000000000001' $$,
+ $$ update public.education_entries set institution = 'University of A (renamed)' where institution = 'University of A' $$,
'user A can update their own education entry'
);
select is(
- (select institution from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000001'),
+ (select institution from public.education_entries where institution = 'University of A (renamed)'),
'University of A (renamed)',
'user A''s update to their own education entry was applied'
);
@@ -53,7 +53,7 @@ select set_config('request.jwt.claims', json_build_object('sub', '22222222-2222-
set role authenticated;
select lives_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('bbbbbbbb-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'University of B') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('22222222-2222-2222-2222-222222222222', 'University of B') $$,
'user B can insert their own education entry'
);
@@ -91,7 +91,7 @@ select is(
-- names B as the owner, even though the FK target (profiles.user_id = B)
-- legitimately exists.
select throws_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('cccccccc-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'forged') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('22222222-2222-2222-2222-222222222222', 'forged') $$,
'42501'::char(5),
null,
'user A cannot insert an education entry with user_id = user B (foreign-key-bypass attempt)'
@@ -99,14 +99,11 @@ select throws_ok(
-- The ownership-rewrite test: A must not be able to "claim" B's existing
-- entry by rewriting its user_id to A's own id.
-with upd as (
- update public.education_entries set user_id = '11111111-1111-1111-1111-111111111111' where id = 'bbbbbbbb-0000-0000-0000-000000000001'
- returning 1
-)
-select is(
- (select count(*)::int from upd),
- 0,
- 'user A cannot take ownership of user B''s education entry by rewriting its user_id'
+select throws_ok(
+ $$ update public.education_entries set user_id = '11111111-1111-1111-1111-111111111111' where institution = 'University of B' $$,
+ '42501'::char(5),
+ null,
+ 'user A cannot take ownership of an education entry by rewriting user_id'
);
-- ---- Anonymous: insufficient privilege, not merely empty rows -------------
@@ -121,7 +118,7 @@ select throws_ok(
'anonymous SELECT on education_entries is rejected with insufficient privilege'
);
select throws_ok(
- $$ insert into public.education_entries (id, user_id, institution) values ('dddddddd-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'anon') $$,
+ $$ insert into public.education_entries (user_id, institution) values ('11111111-1111-1111-1111-111111111111', 'anon') $$,
'42501'::char(5),
null,
'anonymous INSERT on education_entries is rejected with insufficient privilege'
@@ -143,7 +140,7 @@ select set_config('request.jwt.claims', json_build_object('sub', '11111111-1111-
set role authenticated;
select lives_ok(
- $$ delete from public.education_entries where id = 'aaaaaaaa-0000-0000-0000-000000000002' $$,
+ $$ delete from public.education_entries where institution = 'Second entry, deleted later' $$,
'user A can delete their own education entry'
);
diff --git a/supabase/tests/database/030_profile_section_state.test.sql b/supabase/tests/database/030_profile_section_state.test.sql
new file mode 100644
index 0000000..e2ad019
--- /dev/null
+++ b/supabase/tests/database/030_profile_section_state.test.sql
@@ -0,0 +1,74 @@
+-- Revision counters, privileged RPCs, direct-write denial, and cascade safety.
+begin;
+select plan(21);
+
+insert into auth.users (id, aud, role, email)
+values
+ ('33333333-3333-3333-3333-333333333333', 'authenticated', 'authenticated', 'section-a@example.test'),
+ ('44444444-4444-4444-4444-444444444444', 'authenticated', 'authenticated', 'section-b@example.test');
+
+reset role;
+select set_config('request.jwt.claims', json_build_object('sub', '33333333-3333-3333-3333-333333333333', 'role', 'authenticated')::text, true);
+set role authenticated;
+
+select lives_ok(
+ $$ insert into public.profiles (user_id, headline) values ('33333333-3333-3333-3333-333333333333', 'Profile A') $$,
+ 'profile insert succeeds and initializes section state'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'basic_profile'), 1::bigint, 'profile creation advances basic-profile revision');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 0::bigint, 'education begins at revision zero');
+
+select lives_ok($$ update public.profiles set headline = 'Profile A updated' $$, 'meaningful profile update succeeds');
+select is((select content_revision from public.profile_section_revisions where section_key = 'basic_profile'), 2::bigint, 'meaningful profile update advances its revision');
+
+select lives_ok(
+ $$ insert into public.education_entries (user_id, institution, education_status) values ('33333333-3333-3333-3333-333333333333', 'Synthetic University', 'current') $$,
+ 'owner can create current education'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 1::bigint, 'education insert advances its revision');
+
+select lives_ok($$ select public.review_profile_section('education') $$, 'owner can record a current education review through RPC');
+select is(
+ (select reviewed_content_revision from public.profile_section_reviews where section_key = 'education'),
+ 1::bigint,
+ 'review stores the locked current revision'
+);
+select lives_ok($$ update public.education_entries set degree = 'BSc' where institution = 'Synthetic University' $$, 'education update succeeds');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 2::bigint, 'education update advances its revision');
+select isnt(
+ (select reviewed_content_revision from public.profile_section_reviews where section_key = 'education'),
+ (select content_revision from public.profile_section_revisions where section_key = 'education'),
+ 'content mutation makes the prior review stale'
+);
+
+select throws_ok(
+ $$ insert into public.profile_section_revisions (user_id, section_key, content_revision) values ('33333333-3333-3333-3333-333333333333', 'education', 99) $$,
+ '42501'::char(5), null, 'browser cannot directly forge revisions'
+);
+select throws_ok(
+ $$ update public.education_entries set is_primary = true where institution = 'Synthetic University' $$,
+ '42501'::char(5), null, 'browser cannot directly set primary education'
+);
+select lives_ok(
+ $$ select public.set_primary_education((select id from public.education_entries where institution = 'Synthetic University')) $$,
+ 'primary selection RPC succeeds for the owner'
+);
+select is((select is_primary from public.education_entries where institution = 'Synthetic University'), true, 'selected row is primary');
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 3::bigint, 'primary selection advances revision');
+select lives_ok(
+ $$ select public.set_primary_education((select id from public.education_entries where institution = 'Synthetic University')) $$,
+ 'reselecting the current primary is idempotent'
+);
+select is((select content_revision from public.profile_section_revisions where section_key = 'education'), 3::bigint, 'idempotent selection does not change revision');
+select throws_ok($$ select public.review_profile_section('projects') $$, '22023'::char(5), null, 'unknown review key is rejected');
+
+reset role;
+set role service_role;
+select lives_ok(
+ $$ delete from public.profiles where user_id = '33333333-3333-3333-3333-333333333333' $$,
+ 'trusted profile deletion with education rows does not break revision triggers'
+);
+
+reset role;
+select * from finish();
+rollback;
From 314bd24cecf546c7d8d6a67cc18485414580c10f Mon Sep 17 00:00:00 2001
From: Abdulrahman
Date: Sat, 1 Aug 2026 21:37:04 +0300
Subject: [PATCH 02/10] test: extend profile education API coverage
Exercise refined profile permissions, education CRUD, constrained primary and review RPCs, review freshness, and cross-user denial through real local Auth and PostgREST.
---
docs/TESTING_STRATEGY.md | 5 +-
supabase/scripts/api-integration-test.mjs | 69 ++++++++++++++++++++++-
2 files changed, 71 insertions(+), 3 deletions(-)
diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md
index 24090ec..4a30397 100644
--- a/docs/TESTING_STRATEGY.md
+++ b/docs/TESTING_STRATEGY.md
@@ -75,8 +75,9 @@ each rule gets explicit positive and negative test cases.
**Implemented in the first profile-core/education slice**: pgTAP now verifies refined profile and
education schema/grants, browser denial of root-profile deletion and direct revision/review/primary
writes, revision freshness after profile and education mutations, locked review RPC behavior,
-primary-selection idempotence, and cascade safety. The real Auth/JWT/PostgREST suite is extended
-in the following commit; later Phase 1A domains remain future work.
+primary-selection idempotence, and cascade safety. The real Auth/JWT/PostgREST suite covers
+column restrictions, education CRUD, both RPCs, review staleness, and cross-user denial; later
+Phase 1A domains remain future work.
- **Migration structure tests**: exact tables, direct `user_id` ownership, required foreign keys,
ownership-safe composite parent references, primary-education partial unique constraint,
diff --git a/supabase/scripts/api-integration-test.mjs b/supabase/scripts/api-integration-test.mjs
index 0c5e982..fae1ac5 100644
--- a/supabase/scripts/api-integration-test.mjs
+++ b/supabase/scripts/api-integration-test.mjs
@@ -178,6 +178,58 @@ async function main() {
});
check('user A can update their own profile', updateOwnA.ok && updateOwnA.data[0].headline === 'Updated by A');
+ const forgeProfileMetadata = await rest(tokenA, `/profiles?user_id=eq.${userA.id}`, {
+ method: 'PATCH',
+ body: { created_via: 'migration' },
+ prefer: 'return=representation',
+ });
+ check('user A cannot forge profile provenance through the REST API', !forgeProfileMetadata.ok && forgeProfileMetadata.status === 403);
+
+ // ---- education CRUD, primary RPC, and review freshness -----------------
+ const createEducationA = await rest(tokenA, '/education_entries', {
+ method: 'POST',
+ body: {
+ user_id: userA.id,
+ institution: 'Synthetic Integration University',
+ degree: 'BSc',
+ degree_year: 2,
+ education_status: 'current',
+ expected_graduation_month: 7,
+ expected_graduation_year: 2028,
+ },
+ prefer: 'return=representation',
+ });
+ const educationA = createEducationA.data?.[0];
+ check('user A can create current education via the REST API', createEducationA.ok && educationA?.institution === 'Synthetic Integration University');
+
+ const directPrimary = await rest(tokenA, `/education_entries?id=eq.${educationA.id}`, {
+ method: 'PATCH', body: { is_primary: true }, prefer: 'return=representation',
+ });
+ check('user A cannot directly set primary education', !directPrimary.ok && directPrimary.status === 403);
+
+ const selectPrimary = await rest(tokenA, '/rpc/set_primary_education', {
+ method: 'POST', body: { education_id: educationA.id },
+ });
+ check('user A can select their current education through the primary RPC', selectPrimary.ok);
+
+ const revisionsBeforeReview = await rest(tokenA, '/profile_section_revisions?select=section_key,content_revision');
+ check('user A can read their own section revisions', revisionsBeforeReview.ok && revisionsBeforeReview.data.length === 2);
+ const reviewEducation = await rest(tokenA, '/rpc/review_profile_section', {
+ method: 'POST', body: { requested_section_key: 'education' },
+ });
+ const reviewBasic = await rest(tokenA, '/rpc/review_profile_section', {
+ method: 'POST', body: { requested_section_key: 'basic_profile' },
+ });
+ check('user A can review both implemented sections through constrained RPCs', reviewEducation.ok && reviewBasic.ok);
+
+ const updateEducationA = await rest(tokenA, `/education_entries?id=eq.${educationA.id}`, {
+ method: 'PATCH', body: { field: 'Electrical Engineering' }, prefer: 'return=representation',
+ });
+ check('user A can update their education via the REST API', updateEducationA.ok && updateEducationA.data[0].field === 'Electrical Engineering');
+ const staleReview = await rest(tokenA, '/profile_section_reviews?select=section_key,reviewed_content_revision');
+ const afterMutationRevision = await rest(tokenA, '/profile_section_revisions?section_key=eq.education&select=content_revision');
+ check('an education mutation makes its recorded review stale', staleReview.ok && afterMutationRevision.ok && staleReview.data.find((row) => row.section_key === 'education').reviewed_content_revision !== afterMutationRevision.data[0].content_revision);
+
// ---- isolation: A cannot read or update B's data ----------------------
const readBAsA = await rest(tokenA, `/profiles?user_id=eq.${userB.id}`, {});
check('user A cannot read user B\'s profile (empty result, not an error)', readBAsA.ok && readBAsA.data.length === 0);
@@ -196,6 +248,21 @@ async function main() {
});
check('user A cannot insert an education entry owned by user B', !forgedInsert.ok && forgedInsert.status === 403);
+ const createEducationB = await rest(tokenB, '/education_entries', {
+ method: 'POST',
+ body: { user_id: userB.id, institution: 'Synthetic B University', education_status: 'current' },
+ prefer: 'return=representation',
+ });
+ const foreignPrimary = await rest(tokenA, '/rpc/set_primary_education', {
+ method: 'POST', body: { education_id: createEducationB.data?.[0]?.id },
+ });
+ check('user A cannot select user B\'s education as primary', !foreignPrimary.ok && foreignPrimary.status >= 400);
+
+ const directRevisionWrite = await rest(tokenA, '/profile_section_revisions', {
+ method: 'POST', body: { user_id: userA.id, section_key: 'education', content_revision: 99 },
+ });
+ check('user A cannot directly forge section revisions', !directRevisionWrite.ok && directRevisionWrite.status === 403);
+
// ---- isolation: B cannot access A's data ------------------------------
const readAAsB = await rest(tokenB, `/profiles?user_id=eq.${userA.id}`, {});
check('user B cannot read user A\'s profile', readAAsB.ok && readAAsB.data.length === 0);
@@ -204,7 +271,7 @@ async function main() {
method: 'DELETE',
prefer: 'return=representation',
});
- check('user B cannot delete user A\'s profile (0 rows affected)', deleteAAsB.ok && deleteAAsB.data.length === 0);
+ check('user B cannot delete user A\'s profile (browser DELETE is not granted)', !deleteAAsB.ok && deleteAAsB.status === 403);
console.log(`\nAll ${checkCount} checks passed.`);
} finally {
From 3a66f2e6862ad6324723389fc9797172b8a2998b Mon Sep 17 00:00:00 2001
From: Abdulrahman
Date: Sat, 1 Aug 2026 21:39:16 +0300
Subject: [PATCH 03/10] feat: add routed profile workspace
Install React Router 8.3.0 and introduce authenticated redirects, a protected profile layout, bookmarkable profile routes, safe route errors, and in-memory router coverage.
---
README.md | 2 +-
app/package-lock.json | 30 ++++++++-
app/package.json | 3 +-
app/src/App.tsx | 105 ++++++++++++++++++++++++++------
app/src/AppRouter.test.tsx | 17 ++++++
app/src/pages/ProfileLayout.tsx | 38 ++++++++++++
docs/ARCHITECTURE.md | 5 +-
7 files changed, 174 insertions(+), 26 deletions(-)
create mode 100644 app/src/AppRouter.test.tsx
create mode 100644 app/src/pages/ProfileLayout.tsx
diff --git a/README.md b/README.md
index e0d029e..6960b2e 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ hackathons, scholarships, and other resume-building opportunities; compares them
detailed saved profile; explains where the student is competitive and where they are not; and
tracks the full lifecycle from "found it" to "applied" to "interviewed."
-Phase 0 (foundation) is implemented: a React/TypeScript/Vite frontend, a local Supabase stack
+Phase 0 is implemented, and the first Phase 1A profile-core/education slice is in progress: a React/TypeScript/Vite frontend, a local Supabase stack
(Postgres/Auth/PostgREST), the initial `profiles`/`education_entries` schema with Row Level
Security, and CI. See [Local development setup](#local-development-setup) below to run it.
diff --git a/app/package-lock.json b/app/package-lock.json
index 640529e..bef495c 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -10,7 +10,8 @@
"dependencies": {
"@supabase/supabase-js": "^2.110.8",
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router": "^8.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -34,7 +35,7 @@
"vitest": "^4.1.10"
},
"engines": {
- "node": ">=22 <23"
+ "node": "22.23.1"
}
},
"node_modules/@adobe/css-tools": {
@@ -1944,6 +1945,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3326,6 +3332,26 @@
"license": "MIT",
"peer": true
},
+ "node_modules/react-router": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz",
+ "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==",
+ "dependencies": {
+ "cookie-es": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.22.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/app/package.json b/app/package.json
index 9942fc1..ae44711 100644
--- a/app/package.json
+++ b/app/package.json
@@ -20,7 +20,8 @@
"dependencies": {
"@supabase/supabase-js": "^2.110.8",
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router": "^8.3.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 5496a3d..ec4257e 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,42 +1,107 @@
+import {
+ createBrowserRouter,
+ Navigate,
+ Outlet,
+ useRouteError,
+} from 'react-router'
+import { RouterProvider } from 'react-router/dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
-import { SignInPage } from './pages/SignInPage'
+import { ProfileLayout } from './pages/ProfileLayout'
import { ProfilePage } from './pages/ProfilePage'
+import { SignInPage } from './pages/SignInPage'
-// This shell decides which screen to render based on whether a session
-// exists. That is a user-experience convenience only -- it runs entirely in
-// the browser and does not authorize anything. The actual authorization
-// boundary is Postgres Row Level Security, enforced server-side on every
-// request regardless of what this component renders (see
-// docs/RLS_POLICY_MATRIX.md and AGENTS.md).
-function AppShell() {
- const { session, loading, initError, retryInit } = useAuth()
-
+function AuthPending() {
+ const { initError, loading, retryInit } = useAuth()
+ if (loading)
+ return (
+
+