Skip to content

feat(marquee): Phase 3 auth + RBAC + own-only security (both backends) - #53

Merged
2bTwist merged 6 commits into
mainfrom
app/marquee-phase3
Jun 20, 2026
Merged

2bTwist merged 6 commits into
mainfrom
app/marquee-phase3

Conversation

@2bTwist

@2bTwist 2bTwist commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Stacked on #52. Phase 3 delivers real, backend-enforced auth and own-only review security, plus the avg-rating aggregation deferred from Phase 2. Verified green on Supabase AND Convex (data-layer tests + a live browser smoke), and it surfaced one genuine SDK bug (fixed here).

Auth model (decided with the maintainer)

A pluggable IdentityProvider behind a shared OIDC issuer (Supabase default). One sign-in; the active data backend is wired per capability: Supabase seats a session (RLS sees auth.uid()), Convex setToken-verifies the same JWT (managesCredentials:false). Switching backends re-wires from the same session, so it is one login across both. Convex is not Supabase-locked: swap the issuer and Convex verifies that instead. Roles live in a profiles row.

Security (the acceptance bar, green on both)

Review writes are named mutations (not the generic CRUD), identity taken from the verified session never args:

  • Supabase: PostgREST under RLS (reviews_*_own policies; a cross-user edit hits 0 rows -> explicit rejection).
  • Convex: deployed mutations checking ctx.auth.getUserIdentity().

test/security.live.test.ts (8/8 on both): a member cannot edit/delete another member's review (backend rejects, row unchanged), own-review CRUD works, signed-out writes rejected. Proven red-first (removing the Convex check fails the test). Full suite 40/40 (serialized; the live suites share one DB).

Auth handshake finding

Convex's OIDC-discovery provider expects an HTTPS issuer, so the local HTTP Supabase fails discovery (getUserIdentity() is null). The customJwt provider (explicit JWKS URL + ES256) verifies the local issuer. auth.config.ts uses it.

SDK bug found + fixed (the dogfood earning its keep)

@baas/adapter-supabase store.get() returned the raw row without the portable _id (an inconsistency with list() and with Convex). Fixed to map _id like list(); conformance round-trip now asserts it. Patch changeset included.

Browser smoke (both backends, 0 console errors)

Sign in as a member -> post a review -> rating aggregates -> own-review controls appear -> "+ Add movie"/Edit hidden (member). Switch Supabase<->Convex: stays signed in (Convex verifies the Supabase JWT live), review flow works on each. Two robustness fixes the smoke surfaced (a movie._id vs canonical-prop slip, and movie.genres undefined on schemaless Convex) are included.

Remaining (not blocking the phase): server-side catalog write-gating is currently a UI convenience (reviews are the DB-enforced headline); reviewer display names are not shown (reviews carry only the opaque subject). Both noted as follow-ups.

2bTwist added 6 commits June 20, 2026 12:35
The first "SDK gap" phase. Finding: joins and aggregation do NOT need a core
change. They ride the existing named-operation port (`store.run`), whose calling
convention is portable but whose implementation is per-backend, which is exactly
the seam the core reserves for where backends genuinely diverge. The capability
flags (serverSideJoins / aggregations) declare the divergence; the UI surfaces it.

Schema (both backends): people + credits make cast/director a real many-to-many
relation; Supabase adds FK constraints (for PostgREST embeds) and a genre_counts
SQL view.

Named queries, same shapes, divergent implementations:
- movieCredits (the JOIN): Supabase does a server-side PostgREST embed over the
  FKs; Convex (convex/marquee.ts) and memory follow refs by hand.
- genreCounts (the AGGREGATION): Supabase reads a GROUP BY view; Convex and memory
  scan and tally (the cost the `aggregations: false` flag warns about, and which
  Phase 5 scale will exercise).

makeBackend wires the typed MarqueeSchema with per-backend query impls (src/lib/
enrich.ts, kept env-free so tests build typed backends directly). The catalog
functions are generic over the schema so the bare test backends still fit.

UI: MovieDetail shows the real joined cast (director + billed actors with
characters); the catalog shows per-genre counts. Both render an understated
caption declaring HOW the data loaded on the active backend ("Cast joined
server-side" vs "assembled by following references"; "Counted with a SQL view"
vs "by scanning") -- the thin-honest-waist thesis made visible.

Verification: enrich.live.test.ts (12 tests: join order + name resolution, empty
cast, aggregation, capability declaration) green on memory + Supabase + Convex;
catalog gate still 20/20. A Playwright UI smoke confirmed the cast join and the
genre-stats strip with the correct divergence captions on both live backends.
avg-rating aggregation defers to Phase 3 (reviews need auth); Phase 2's
aggregation is genre counts over existing data.
The decision-complete Phase 3 plan (auth + RBAC + own-only security) and the
Convex auth.config.ts that verifies the shared OIDC issuer's JWT.

Topology (decided): a pluggable IdentityProvider behind a shared OIDC issuer
(Supabase default), so identity is consistent across backends (Phase 5 migration
stays coherent) and Convex is not Supabase-locked (swap the issuer, not Supabase
by law). Independent Convex credential auth (@convex-dev/auth) logged as a future
SDK capability, not built now.

Verified this session: the handshake infrastructure lines up (Supabase OIDC
discovery + ES256 JWKS; iss/aud/sub claims; Convex accepted auth.config). The
runtime verify is exercised by the Phase 3 security tests (next sub-steps).
The user-owned tables for auth/RBAC, on both backends:
- Supabase migration 0003: profiles (userId, role, displayName) + reviews
  (movieId, userId, rating 1-5, body), with Row Level Security enforcing
  own-only writes at the DATABASE (reviews_insert/update/delete_own use
  auth.uid()), plus an auth_role() SECURITY DEFINER helper for later catalog
  gating. SELECT is open (guests browse, reviewer names show). A unique
  (userId, movieId) index makes a review one-per-user-per-movie.
- Convex schema: profiles + reviews tables with by_userId / by_movieId indexes.
  userId is the shared-issuer subject (Supabase auth uid), so identity matches
  across backends and survives the Phase 5 migration.

Catalog tables are untouched (their writes get role-gated via named mutations in
sub-step 3), so the Phase 1/2 portable-CRUD tests and the seed keep working.
Verified: both tables resolve on Supabase (anon SELECT under RLS) and Convex
(indexes deployed).
The acceptance bar, green on Supabase AND Convex: a member cannot edit or delete
another member's review, rejected by the BACKEND, with the review left unchanged.
Proven red-first (temporarily removing the Convex ownership check fails the test;
restored).

- Review writes are named MUTATIONS (addReview/editReview/deleteReview), off the
  generic portable CRUD, with identity taken from the verified session, never args:
  - Supabase: thin PostgREST calls on the user's authed client; RLS is the arbiter
    (a cross-user edit matches 0 rows -> explicit rejection).
  - Convex: deployed mutations (convex/reviews.ts) checking ctx.auth.getUserIdentity().
  - Memory: open dev sandbox (no auth), a single synthetic user.
- movieRating(movieId) -> {avg,count}, the aggregation deferred from Phase 2.
- AUTH HANDSHAKE PROVEN on the local stack: Convex verifies the Supabase ES256 JWT.
  Finding: the OIDC-discovery provider expects an HTTPS issuer, so the local HTTP
  Supabase fails discovery; the `customJwt` provider (explicit JWKS URL + ES256)
  works with the local issuer. auth.config.ts switched to customJwt.
- Tests run serially (vitest.config.ts fileParallelism:false): the live suites share
  one DB and each resets+owns it, so parallel files corrupt each other's counts.
  Full suite 40/40 (catalog 20, enrich 12, security 8).

Schema/types: MarqueeSchema gains the review mutations + movieRating; the Phase 2
enrich test now builds complete typed backends.
The Marquee dogfood (Phase 3 detail page) found that `store.get()` on Supabase
returned the raw PostgREST row keyed only by the primary-key column, so a fetched
document had no `_id`, an inconsistency with `store.list()` (which maps it) and
with the Convex adapter (whose docs carry `_id` natively). A doc fetched via
`get()` therefore could not be passed straight to `patch()`/`remove()`.

get() now maps `{ _id: <pk>, ...row }` exactly as list() does. The conformance
round-trip test now asserts the fetched doc carries `_id` (passes on memory; the
supabase/convex live conformance jobs cover the other two). Patch via changeset.
…th backends)

The user-facing half of Phase 3, verified in the browser on both live backends.

- lib/identity.ts: the pluggable IdentityProvider seam (Supabase default issuer),
  so the issuer is swappable and Convex is not Supabase-locked.
- lib/auth.tsx: AuthProvider + useAuth. One issuer signs in; the ACTIVE backend is
  wired per capability (Supabase seats a session for RLS; Convex setToken-verifies;
  memory is open). Switching backends re-wires from the same session, so it is ONE
  login across both. Role comes from a profiles row (default member).
- AuthBar: header sign-in/up/out + email + role badge.
- ReviewSection: avg rating + count, the signed-in user's own add/edit/delete form
  (addReview upserts), other users' reviews read-only. All enforcement is backend.
- Gating (convenience): "+ Add movie" and detail "Edit" only for editor/admin.

Browser smoke (LAN IP, both backends, 0 console errors): sign in as a member ->
post a review -> rating aggregates -> own-review controls appear -> "+ Add movie"
hidden. Switch Supabase<->Convex stays signed in (Convex verifies the Supabase JWT
live) and the review flow works on each.

Two robustness fixes the smoke surfaced: MovieDetail used movie._id (now the
canonical movieId prop) and assumed movie.genres exists (Convex, being schemaless,
omits a never-written field that Supabase fills from a column default; now guarded).
@2bTwist
2bTwist changed the base branch from app/marquee-phase2 to main June 20, 2026 21:35
@2bTwist
2bTwist merged commit 673e5f3 into main Jun 20, 2026
11 checks passed
@2bTwist
2bTwist deleted the app/marquee-phase3 branch June 20, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant