apps/team (package @excelrewind/team) is a standalone React + TypeScript single-page app, built with Vite, that serves two audiences under one shell: every signed-in user's personal Account screen (plan, devices, referrals, coupon redemption), and — for users who belong to an organization — the Team console (dashboard, members, invites, domain verification, cohort bulk-licensing, exam/kiosk proctoring, and org-level encryption-key management). It has no backend of its own: every read and write goes straight to Supabase (Postgres RPCs, RLS-gated direct table reads, and two purpose-built Edge Functions) from the browser.
The app is deployed as a static bundle to Cloudflare Pages, split across two custom domains that route to the same build: team.excelrewind.com (org managers) and account.excelrewind.com (personal accounts) — routing between the two "modes" is entirely client-side (the /enterprise path switches the sign-in screen into company-email-only mode and skips straight to the Team flow). Build/deploy: npm run build -w @excelrewind/team then wrangler pages deploy apps/team/dist --project-name excelrewind-team (see root package.json — deploy:team is not part of the composite npm run deploy that ships addin/viewer/website, so it is deployed independently). apps/team/public/_headers sets a strict per-origin CSP (connect-src limited to the project's own Supabase origin), X-Robots-Tag: noindex, and Cache-Control: no-store on index.html so a redeploy is picked up immediately.
Local dev runs at :5174 (vite.config.ts, envDir: '../..' — it shares the monorepo's root .env with the viewer/add-in). Appending ?demo=1 to any URL short-circuits every Supabase call to fixture data from src/demo.ts so the whole console can be explored/screenshotted without a backend; the same fallback also fires automatically, screen-by-screen, whenever an RPC 404s (not-yet-deployed function), so the console degrades to "honest demo" rather than a blank error page.
flowchart TB
subgraph entry["Entry / bootstrap"]
main["main.tsx"] --> App["App.tsx (top-level orchestration)"]
end
subgraph state["Auth + data hooks"]
useAuth["useAuth.ts (session state, OTP send/verify, signOut)"]
supabaseClient["supabase.ts (supabase-js client + api.* RPC wrappers + types)"]
end
subgraph routing["Routing (no router dep)"]
App -- "path === '/accept'" --> Accept["screens/Accept.tsx"]
App -- "path === '/enterprise'" --> SignInEnt["screens/SignIn.tsx (enterprise=true)"]
App -- "signed out" --> SignIn["screens/SignIn.tsx"]
App -- "no org, wants team" --> CreateOrg["screens/CreateOrg.tsx"]
end
subgraph shell["Shell + nav"]
Shell["Shell.tsx (sidebar nav + topbar)"]
Modal["Modal.tsx (shared overlay)"]
ui["ui.tsx (Icons, Avatar, Toast, badges)"]
end
subgraph screens["Team / Account screens"]
Account["screens/Account.tsx"]
Dashboard["screens/Dashboard.tsx"]
Members["screens/Members.tsx"]
Invites["screens/Invites.tsx"]
InviteBanner["screens/InviteBanner.tsx"]
Domain["screens/Domain.tsx"]
Cohort["screens/Cohort.tsx"]
Exams["screens/Exams.tsx"]
Encryption["screens/Encryption.tsx"]
Skeleton["screens/Skeleton.tsx"]
end
subgraph shared["Shared logic"]
util["util.ts (fmtDate, titleCase, hueFromString, initials)"]
freeEmail["freeEmail.ts (isFreeEmail — client-side mirror of SQL guard)"]
demo["demo.ts (fixture data, ?demo=1 + RPC-404 fallback)"]
end
subgraph fmt["@excelrewind/format (shared package)"]
keyring["keyring.ts (createKeyRow, unlockKeyRow, rewrapPass, newRecovery,\nwrapUnderMaster, unwrapUnderMaster, checkPassphrase)"]
end
App --> useAuth
App --> supabaseClient
App --> Shell
App --> InviteBanner
Shell --> screens
Account --> freeEmail
SignIn --> freeEmail
screens --> supabaseClient
screens --> Modal
screens --> ui
screens --> util
screens --> demo
Encryption --> keyring
supabaseClient -.->|"CONFIG_OK / isDemo flags"| demo
Two distinct data paths converge on the same UI shell: read/write via Postgres RPC (the majority of actions) and direct RLS-gated table access (personal profile, devices, and the org encryption-key rows — chosen because these are simple owner-scoped reads/writes that don't need server-side business logic).
flowchart LR
U["Org manager / member\n(browser)"]
U -->|"email OTP: supabase.auth.signInWithOtp / verifyOtp"| SBAuth["Supabase Auth\n(GoTrue)"]
SBAuth -->|"JWT session, persisted to localStorage"| U
U -->|"supabase.rpc('org_dashboard' / 'org_invite' / 'org_accept_invite' / ...)"| RPC["Postgres RPCs\n(SECURITY DEFINER functions)"]
RPC -->|"JSON: Dashboard{org, seats_used, members[], invites[], my_role}"| U
U -->|"select/insert/update org_keys, org_keys_retired\n(ciphertext + salts + pubkeys only)"| PG["Postgres tables\n(RLS: owner/admin write, member read)"]
U -->|"select profiles/devices (RLS: read-own)"| PG
PG -->|"rows"| U
U -->|"POST /functions/v1/send-invite\n{email, org_name, role, accept_url, inviter}"| SendInvite["Edge Fn: send-invite"]
SendInvite -->|"transactional email"| Invitee["Invitee's inbox"]
U -->|"POST /functions/v1/verify-domain\n(Bearer JWT)"| VerifyDomain["Edge Fn: verify-domain"]
VerifyDomain -->|"DNS TXT lookup"| DNS["Public DNS"]
VerifyDomain -->|"{verified, expected, found, hint}"| U
U -->|"POST /functions/v1/exam-storage\n{action:'review_url', submission_id}"| ExamStorage["Edge Fn: exam-storage"]
ExamStorage -->|"short-lived signed GET URL"| U
U -->|"GET signed URL"| Storage["Supabase Storage\n(.rewind object)"]
Storage -->|".rewind file (download)"| U
U -->|"window.open"| Viewer["viewer.excelrewind.com\n(replay, out of scope)"]
subgraph crypto["Client-side only — never sent to server"]
Secret["org passphrase / recovery code"]
Master["raw org master key (OMK)"]
end
U -.->|"PBKDF2 unwrap (in-memory only)"| crypto
crypto -.->|"AES-wrapped ciphertext + salts + RSA pubkey"| PG
sequenceDiagram
participant User
participant SignIn as "SignIn.tsx"
participant useAuth as "useAuth.ts"
participant Supa as "Supabase Auth"
participant App as "App.tsx"
participant API as "supabase.ts (api.*)"
User->>SignIn: enter email, submit
SignIn->>SignIn: isFreeEmail(email)? (enterprise mode only)
alt enterprise + free-provider email
SignIn-->>User: inline error, block send
else ok
SignIn->>Supa: auth.signInWithOtp({email, shouldCreateUser:true})
Supa-->>SignIn: code emailed
User->>SignIn: enter 6-digit code
SignIn->>Supa: auth.verifyOtp({email, token, type:'email'})
Supa-->>useAuth: onAuthStateChange(session)
useAuth-->>App: {loading:false, email, userId}
App->>API: api.dashboard() [rpc org_dashboard]
alt has org
API-->>App: Dashboard{org, members, invites, my_role}
App->>App: setLoad({s:'ready', dash})
else no_org error
API-->>App: error "no_org"
App->>App: setLoad({s:'no-org'})
else RPC missing (404 / PGRST202)
API-->>App: isMissingRpc(e) = true
App->>App: setLoad({s:'ready', dash: demoDashboard()})
end
App->>API: api.me(userId) + api.devices()
API-->>App: Profile, Device[]
App-->>User: render Shell (Account tab, + Team tabs if hasOrg)
end
sequenceDiagram
participant Manager
participant Invites as "Invites.tsx"
participant API as "supabase.ts (api.*)"
participant PG as "Postgres (org_invite RPC)"
participant EdgeFn as "Edge Fn: send-invite"
participant Invitee
participant Accept as "Accept.tsx"
Manager->>Invites: enter email + role, submit
Invites->>API: api.invite(email, role) [rpc org_invite]
API->>PG: org_invite(p_email, p_role)
PG-->>API: invite token
API-->>Invites: token
Invites->>Invites: build acceptLink(token) = origin/accept?token=...
Invites->>API: api.sendInviteEmail({email, org_name, role, accept_url, inviter})
API->>EdgeFn: POST /functions/v1/send-invite (Bearer JWT)
Note over API,EdgeFn: fail-soft — never throws,\ncopy-link UI is the fallback
EdgeFn-->>Invitee: email with accept link
Invites-->>Manager: show copyable accept link + toast "Invite sent"
Invitee->>Accept: open /accept?token=...
alt not signed in
Accept->>Accept: render SignIn (intro: "Sign in to accept your team invite")
Note over Accept: OTP flow (see 4.1), re-renders with signedIn=true
end
Accept->>API: api.acceptInvite(token) [rpc org_accept_invite]
API->>PG: org_accept_invite(p_token)
alt token valid
PG-->>API: ok
Accept-->>Invitee: success animation, then onDone()
Accept->>Accept: history.replaceState('/'), fetchDash()
else expired/invalid
PG-->>API: error
API-->>Accept: toFriendly(e) → "invite link is invalid or has expired"
Accept-->>Invitee: error state + "Continue to console"
end
sequenceDiagram
participant Owner
participant Domain as "Domain.tsx"
participant API as "supabase.ts (api.*)"
participant PG as "Postgres (org_request_domain_verification, org_set_auto_join)"
participant EdgeFn as "Edge Fn: verify-domain"
participant DNS as "Public DNS"
Owner->>Domain: click "Show DNS record"
Domain->>API: api.requestDomainVerification() [rpc]
API->>PG: org_request_domain_verification()
PG-->>Domain: {domain, record_name: _excelrewind.[domain], record_type:TXT, record_value:excelrewind-verify=[token]}
Domain-->>Owner: render TXT record to publish
Owner->>Domain: click "Verify via DNS" (after publishing the record)
Domain->>API: api.verifyDomainDns()
API->>EdgeFn: POST /functions/v1/verify-domain (Bearer JWT, apikey)
EdgeFn->>DNS: lookup _excelrewind.[domain] TXT record
alt record found + matches
EdgeFn-->>Domain: {verified:true}
Domain->>Domain: reload() → dashboard refetch, verified badge flips
else not found / mismatch
EdgeFn-->>Domain: {verified:false, expected, found[], hint}
Domain-->>Owner: inline "Not verified yet" + found records
end
opt Owner enables auto-join
Owner->>Domain: toggle Automatic join
Domain->>API: api.setAutoJoin(true) [rpc org_set_auto_join]
API->>PG: org_set_auto_join(p_enable)
alt domain not verified
PG-->>API: error domain_not_verified
Domain-->>Owner: revert toggle, toast error
else ok
PG-->>API: ok
Domain-->>Owner: toggle stays on, reload()
end
end
sequenceDiagram
participant Admin
participant Enc as "Encryption.tsx"
participant KR as "@excelrewind/format keyring.ts"
participant API as "supabase.ts (api.orgKeys*)"
participant PG as "Postgres org_keys / org_keys_retired\n(RLS: owner/admin write)"
Note over Admin,Enc: SETUP (no row exists yet)
Admin->>Enc: enter passphrase x2, submit
Enc->>KR: checkPassphrase(p1) — min 12 chars, mixed
Enc->>KR: createKeyRow(p1)
KR->>KR: masterRaw = random 256-bit key, RSA-3072 keypair,\nwrap masterRaw under PBKDF2(passphrase) and under a fresh recovery code
KR-->>Enc: {fields (ciphertext+salts+pub), recoveryCode, masterRaw}
Enc->>API: api.orgKeysInsert({org_id, ...toCols(fields), enabled:true, seal_member_files:false})
API->>PG: INSERT org_keys (ciphertext + salts + pubkey only — never the passphrase or masterRaw)
Enc-->>Admin: RevealModal — recovery code shown ONCE, must ack "stored safely"
Note over Admin,Enc: ROTATE (heavy op, owner/admin)
Admin->>Enc: click "Rotate org key", enter current secret + new passphrase
Enc->>KR: unlockKeyRow(oldFields, secret, kind) — PBKDF2 unwrap, throws on wrong secret
KR-->>Enc: oldMaster (raw, in-memory only)
Enc->>KR: createKeyRow(newPassphrase) → newFields, newMaster, newRecoveryCode
Enc->>API: api.orgKeysRetiredInsert({org_id, fingerprint:old, wrapped_omk: wrapUnderMaster(oldMaster,newMaster), org_pub:old, wrapped_org_priv:old})
API->>PG: INSERT org_keys_retired (a) — old generation now sealed under the new master
Enc->>API: api.orgKeysRetiredList()
API->>PG: SELECT org_keys_retired
loop each previously-retired generation
Enc->>KR: unwrapUnderMaster(r.wrapped_omk, oldMaster)
Enc->>KR: wrapUnderMaster(raw, newMaster)
Enc->>API: api.orgKeysRetiredUpdate(orgId, fingerprint, {wrapped_omk: re-wrapped})
API->>PG: UPDATE org_keys_retired (b) — re-chain under new master
end
Enc->>API: api.orgKeysUpdate({org_id, ...toCols(newFields)})
API->>PG: UPDATE org_keys (c) — swap in new generation, keep enabled/seal toggles
Enc-->>Admin: RevealModal — new recovery code shown ONCE
Note over Enc,PG: Order is (a)+(b) THEN (c) so a mid-flight failure\nnever strands an old generation unreachable.\nWrong secret at unlockKeyRow aborts before any writes.
sequenceDiagram
participant Sponsor
participant Cohort as "Cohort.tsx"
participant API as "supabase.ts (api.*)"
participant PG as "Postgres cohort_invite_bulk RPC"
Sponsor->>Cohort: paste emails OR upload students.csv
Cohort->>Cohort: parseEmails(text) — split on comma/whitespace/newline,\nfirst @-token per line, de-dupe client-side
Sponsor->>Cohort: click "Invite N"
Cohort->>API: api.cohortInviteBulk(emails[])
API->>PG: cohort_invite_bulk(p_emails)
PG-->>API: {results:[{email,status:'ok'|'skip',reason?,token?}], seats, seats_used}
API-->>Cohort: BulkInviteResponse
Cohort-->>Sponsor: per-row table (Invited / No seat / Already in / Duplicate / Invalid)
Cohort->>Cohort: reload() → dashboard refetch (seat meter updates)
opt seats run out
Sponsor->>Cohort: "Request more seats" (enter total)
Cohort->>API: api.cohortRequestSeats(total) [rpc]
API->>PG: cohort_request_seats(p_n)
PG-->>Cohort: pending_seat_request set on org row (superadmin approves out-of-band, apps/admin)
end
flowchart LR team["apps/team"] team -->|"supabase-js: auth (OTP), rpc(), from().select/insert/update"| Supabase["Supabase project\n(Postgres + Auth + Storage)"] team -->|"HTTPS POST, Bearer JWT + apikey"| SendInvite["Edge Fn: send-invite"] team -->|"HTTPS POST, Bearer JWT + apikey"| VerifyDomain["Edge Fn: verify-domain"] team -->|"HTTPS POST, Bearer JWT + apikey"| ExamStorage["Edge Fn: exam-storage"] team -->|"window.open (manual handoff, no API call)"| Viewer["viewer.excelrewind.com"] team -->|"link only: /?ref=code"| Website["excelrewind website (WEBSITE const)"] team -->|"@excelrewind/format import (client-side crypto lib)"| Format["packages/format keyring.ts"] team -->|"@excelrewind/branding import (PRODUCT_NAME, WEBSITE)"| Branding["packages/branding"] team -.->|"Payment Links (env-gated anchor href, no API call)"| Stripe["Stripe (Payment Links + billing portal)"] team -->|"static hosting"| CFPages["Cloudflare Pages\n(team.excelrewind.com, account.excelrewind.com)"] Admin["apps/admin (superadmin)"] -.->|"approves cohort_request_seats,\nsets feature entitlements consumed via effective_features()"| Supabase
| External surface | Protocol | Purpose | Where in code |
|---|---|---|---|
| Supabase Auth (GoTrue) | supabase-js, email OTP | Sign-in/sign-up (both personal and enterprise entrance); session persisted to localStorage, auto-refreshed | useAuth.ts, supabase.ts |
| Postgres RPCs (SECURITY DEFINER functions) | supabase-js .rpc() over PostgREST | org_dashboard, org_create, org_invite, org_accept_invite, org_decline_invite, org_revoke_invite, org_remove_member, org_set_role, org_request_domain_verification, org_verify_domain_manual, org_set_auto_join, cohort_invite_bulk, cohort_request_seats, org_features, org_feature_set, effective_features, exam_list/create/set/delete/submissions_list, my_pending_invites, my_referral(s), public_promo_config, public_tiers, redeem_coupon | supabase.ts — the api object; rpc() helper |
| Postgres tables (direct, RLS-gated) | supabase-js .from().select/insert/update() | profiles, devices (read-own); org_keys, org_keys_retired (owner/admin write, member read) — ciphertext/salts/pubkeys only, never secrets | supabase.ts — api.me, api.devices, api.orgKeys* |
Edge Fn send-invite | HTTPS POST /functions/v1/send-invite, Bearer JWT + apikey, JSON body | Emails the invite accept-link; fail-soft (never throws — copy-link stays the fallback UX) | supabase.ts — api.sendInviteEmail; called from Invites.tsx |
Edge Fn verify-domain | HTTPS POST /functions/v1/verify-domain, Bearer JWT + apikey | Real DNS TXT lookup to confirm domain ownership; returns {verified, expected, found, hint}, never throws on "not yet verified" | supabase.ts — api.verifyDomainDns; called from Domain.tsx |
Edge Fn exam-storage | HTTPS POST /functions/v1/exam-storage, Bearer JWT + apikey, {action:'review_url', submission_id} | Mints a short-lived signed GET URL for a submitted .rewind so an admin can download and review it | supabase.ts — api.reviewUrl; called from Exams.tsx (AuditView.review) |
| viewer.excelrewind.com | Browser navigation (window.open), no API call | Where a reviewer drops the downloaded exam .rewind to replay it — the team console never embeds a viewer or exposes raw file content itself | Exams.tsx — VIEWER const |
| Stripe (Payment Links + billing portal) | Plain <a href> to an env-configured URL, no SDK/API call from this app | Upgrade CTA (Account tier cards) and cohort seat purchase; buttons render disabled ("soon") when the env var is unset | Account.tsx (STRIPE_LINK, BILLING_PORTAL), Cohort.tsx (SEAT_PURCHASE_LINK) |
| @excelrewind/format | Direct ES import (bundled, not network) | All org-key cryptography (PBKDF2 wrap/unwrap, AES, RSA keypair, passphrase strength check) — the only crypto used anywhere in this module | Encryption.tsx imports from @excelrewind/format |
| @excelrewind/branding | Direct ES import | Product name / website URL strings — never hard-coded | SignIn.tsx, Account.tsx |
| Cloudflare Pages | Static hosting, wrangler deploy | Serves the built SPA on two domains; _headers sets CSP/robots/cache policy | apps/team/public/_headers, root package.json deploy:team |
| File | Function / export | What it does | Why it matters |
|---|---|---|---|
src/App.tsx | App() | Top-level orchestration: auth gate → /accept route → unified Account+Team shell; derives my_role client-side if the RPC omits it; resolves the encryption feature gate fail-open | The one place that decides which tabs a user can even reach (tabAvailable) — misreading my_role or the feature gate here silently over/under-exposes destructive controls |
src/App.tsx | tabAvailable(t) | Gates each nav tab by org kind (cohort vs enterprise) and role; never strands a signed-in user on a tab their org doesn't have | Client-side only gate — the real authorization is RLS/RPC-side; this just prevents a confusing UI state, not a security boundary |
src/useAuth.ts | sendCode, verifyCode, signOut, useAuth() | Email OTP via supabase-js (signInWithOtp/verifyOtp); session state hook backed by onAuthStateChange | The entire auth surface for this app — no password path, no OAuth |
src/supabase.ts | rpc<T>(fn, args) | Thin wrapper around supabase.rpc() that throws the raw Postgres error | Single choke point — every RPC call in the app goes through this |
src/supabase.ts | isMissingRpc(err) | Distinguishes "function not deployed" (PGRST202 / 404 / "does not exist") from a real failure | Powers the graceful demo-data fallback used across Dashboard/Exams/App — without it, an undeployed RPC would white-screen instead of demoing |
src/supabase.ts | toFriendly(err) | Maps raw Postgres/Supabase errors to a RpcError with a stable code (seat_limit, duplicate, forbidden, bad_token, free_email_not_allowed) | UI branches on .code, not the message string — keep this in sync with the SQL error text it pattern-matches |
src/supabase.ts | api.orgKeysGet/Insert/Update, api.orgKeysRetiredList/Insert/Update | Direct org_keys / org_keys_retired table access — no RPC layer; RLS alone enforces owner/admin write | Only ciphertext/salts/pubkeys ever cross this boundary — confirms the zero-knowledge claim at the network layer |
src/supabase.ts | api.reviewUrl, api.verifyDomainDns, api.sendInviteEmail | The three calls that go to Edge Functions instead of RPCs (need server-side secrets/DNS/storage-signing that can't live in Postgres) | All three manually attach the session's access_token as Bearer + the anon key as apikey — study these if adding a new Edge Fn call |
src/freeEmail.ts | isFreeEmail(email), FREE_EMAIL_DOMAINS | Client-side best-effort blocklist of free/public email providers, gating the enterprise sign-in and "Create a team" CTA | Explicitly documented as not authoritative — the real gate is SQL is_free_email_domain() in the org_create migration; must be kept in sync by hand |
src/screens/Encryption.tsx | toFields / toCols | Bidirectional mapping between the org_keys DB row shape and the format package's neutral KeyRowFields shape | Any schema rename in either place breaks encryption silently unless both are updated together |
src/screens/Encryption.tsx | RotateModal.submit | Full org-key rotation: unlock old master → generate new master+keypair → retire old generation sealed under new master → re-wrap the entire retired chain → swap the live row | The highest-stakes write path in the module; ordered so a mid-flight network failure can never strand a retired generation unreadable (re-wrap step is naturally idempotent on retry) |
src/screens/Domain.tsx | manualOverride | Owner-only "Testing" toggle that flips domain_verified without a real DNS check | Explicitly labelled test-only in the UI; an auditor should confirm this RPC (org_verify_domain_manual) is gated/removed before GA — code comment flags it ("Gate before GA") |
src/screens/Cohort.tsx | parseEmails(text) | Parses pasted text or an uploaded CSV into a de-duplicated candidate email list (grabs the first @-containing token per line, so a 3-column students.csv still works) | Purely client-side convenience filter — the RPC (cohort_invite_bulk) re-validates/de-dupes server-side and is the actual source of truth for skip reasons |
src/screens/Exams.tsx | AuditView.review | Downloads a signed .rewind via exam-storage and opens the viewer in a new tab so an instructor can replay it | The console never embeds or streams file content itself — download + external viewer is a deliberate "lightest honest path" per the code comment |
src/screens/Account.tsx | TierCard, STRIPE_LINK | Renders live tiers from public_tiers() with an env-gated Payment Link per paid tier | The client never writes a user's tier directly — upgrade is entirely a redirect to Stripe; tier changes land via webhook elsewhere in the system |
src/Shell.tsx | Shell(), TEAM_NAV / COHORT_NAV | Sidebar navigation; filters the Encryption tab by role AND the superadmin feature gate in one .filter() | Note the code comment: the old self-serve Features screen was removed in v3.18.0 — feature exceptions are now superadmin-owned in apps/admin, not here |
@excelrewind/format/keyring.ts and runs client-side; src/supabase.ts's orgKeys* calls only ever move base64 ciphertext, salts, iteration counts, and public keys. The passphrase, recovery code, and unwrapped master key exist only in React component state and are never sent over the wire or persisted (see the file-header comment in Encryption.tsx). An auditor should verify no new code path logs, caches, or round-trips these values.BOTH_LOST constant, shown in the setup/rotate UI). Support/ops must not promise recovery beyond these two secrets.RotateModal) — don't let product copy imply otherwise.type="text" with CSS masking (not type="password") plus data-1p-ignore / data-lpignore / data-bwignore attributes, because a manager once clobbered a typed org-key passphrase mid-setup (code comment, NO_MANAGER const). Don't "fix" this by switching back to type="password".tabAvailable() in App.tsx and the role checks in Members.tsx/Domain.tsx/Encryption.tsx just prevent confusing states; every destructive RPC is expected to re-check role/ownership server-side via RLS/SECURITY DEFINER logic. Treat any client-only check here as advisory when reasoning about actual access control.freeEmail.ts's comment states the SQL is_free_email_domain() guard in the org_create migration is the definitive gate; this file must be manually kept in sync and a gap here is a UX nuisance, not a security hole (the SQL side is the real enforcement).org_verify_domain_manual lets an owner flip domain_verified without any DNS proof. The UI dashes the card border and badges it "Testing," and the code comment says "Gate before GA" — an auditor should confirm this RPC is disabled or removed in production before auto-join can be trusted as a real domain-ownership signal.isDemo (?demo=1) and isMissingRpc() both route through the same demo.ts fixtures, so a genuinely undeployed backend function silently looks like a demo rather than an error. This is intentional (graceful degradation during incremental backend rollout) but means a developer must check network requests, not just the UI, to know whether they're hitting live data.api.sendInviteEmail swallows all errors — the invite row and copy-link already exist server-side before the email is attempted, so a Resend/SMTP failure never blocks the invite flow; it's logged server-side only. Don't add a throw here without also handling the now-broken assumption in Invites.tsx.CLAUDE.md): exam review flows through a signed download + external viewer handoff, never an in-page preview of decrypted content; the console's own data model (Dashboard, Member, Invite, key rows) contains no cell data at all.seatsLeft/pct calculations in Invites.tsx, Cohort.tsx, and Dashboard.tsx exist purely to disable buttons early and show a meter; org_invite / cohort_invite_bulk are expected to enforce the real seat limit and return a seat_limit-coded error (mapped by toFriendly) if the client under-counts.my_role can be absent from the RPC payload and is derived client-side. App.tsx's fetchDash falls back to matching the signed-in email against dash.members if dash.my_role is missing, defaulting to 'member' (the least-privileged role) if no match is found — a safe fail-closed default, but worth knowing when debugging "why can't I see the manage buttons."user_id vs email). api.removeMember / api.setRole send p_user when a member row has a user_id, else fall back to p_email — flagged in code as a ponytail: stop-gap until every member row is guaranteed a stable id.org_dashboard() is owner/admin-only, so the client's "member view-only" path is currently unreachable. Both definitions of the RPC (supabase/migrations/20260713_org_teams.sql and the byte-for-byte redefinition in 20260715_cohorts.sql) raise forbidden when c.org_role not in ('owner','admin') — yet org_accept_invite happily assigns org_role='member' to invitees, and the client is built assuming members can load it read-only (Members.tsx's "View only" badge, App.tsx's tabAvailable gate treats any signed-in org member as eligible for the Dashboard/Members/Invites/Domain tabs). In practice, a member-role user's api.dashboard() call throws forbidden; App.tsx's fetchDash only special-cases /no[_ ]?org|not a member|no rows/i, so forbidden falls through to the generic {s:'error'} card ("Couldn't load your team") instead of a populated read-only roster. An auditor should confirm whether this is intended (members aren't meant to have team-console access at all) or a real gap between the SQL contract and the UI it was built against.