Supabase Edge Functions (Deno) + Supabase Auth (GoTrue) + Stripe/Razorpay billing — the stateless glue that never holds spreadsheet content.
"Backend: services" is every line of server-side code ExcelRewind owns outside of the SQL
schema itself: eight Supabase Edge Functions (Deno runtime, one index.ts each,
under supabase/functions/* — six deployed today, plus razorpay-webhook and
uninstall-feedback, both real code that isn't deployed yet; see §6) plus the client-side auth flows in
apps/addin-excel/src/auth.ts and apps/viewer/src/google-signin.ts that drive
Supabase's built-in Auth server (GoTrue). There is deliberately no Node/Express or FastAPI process
the SDD's target architecture calls for (Milestone 2, not started) — every server-side concern that exists
today is either a Postgres function/trigger (public.* RPCs in supabase/schema.sql and
supabase/migrations/*.sql, covered by the sibling Backend: data module) or one of these
Edge Functions.
Edge Functions run on Supabase's managed Deno Deploy infrastructure — no server to patch, no port to open,
cold-start latency instead of a always-on process. Each function is deployed independently with
supabase functions deploy <name> (see supabase/functions/README.md and
OPS.md §9). Most disable Supabase's platform JWT verification
(--no-verify-jwt) because the caller is either an unauthenticated webhook (Stripe/Razorpay sign
their own payloads) or a caller that the function validates itself via
supabase.auth.getUser(jwt) against the live Auth server — never by decoding the JWT locally. The
service-role key (full RLS bypass) is injected as a platform secret and never leaves Deno's process memory;
it is never shipped in a VITE_-prefixed client bundle.
Billing is the clearest expression of the project's "lazy path" philosophy (see supabase/functions/README.md
header, WS-C): Stripe Payment Links (no checkout UI of our own) take the money, and
stripe-webhook is the only code that reacts — it flips profiles.tier. A parallel,
currently-undeployed razorpay-webhook is the same shape for a future Indian entity. Authentication is
Supabase Auth end to end (email OTP for the common path; Google/Apple OAuth for the rest), with one genuine
platform workaround: Google refuses to run OAuth inside an embedded WebView2 pane, so Google/Apple sign-in is
handed off to the user's system browser and the resulting session is smuggled back into the add-in through a
one-time, nonce-keyed handoff table (public.auth_handoff) rather than any URL-scheme or native
bridge.
flowchart TB
subgraph Clients["Client surfaces"]
Addin["apps/addin-excel
auth.ts"]
ViewerGS["apps/viewer
google-signin.ts"]
Team["team.excelrewind.com
(org console)"]
AdminUI["admin.excelrewind.com
(superadmin console)"]
Installer["Windows installer (NSIS)
(companion, no session)"]
end
subgraph EdgeFns["Supabase Edge Functions (Deno, supabase/functions/*)"]
StripeWH["stripe-webhook/index.ts
--no-verify-jwt"]
RzpWH["razorpay-webhook/index.ts
--no-verify-jwt (NOT YET DEPLOYED)"]
AdminOps["admin-ops/index.ts
--no-verify-jwt, dual-guard"]
Metrics["metrics/index.ts
--no-verify-jwt, owner token"]
ExamStorage["exam-storage/index.ts
--no-verify-jwt, self-checked JWT"]
SendInvite["send-invite/index.ts
default verify-jwt + self-check"]
VerifyDomain["verify-domain/index.ts
default verify-jwt + self-check"]
UninstallFb["uninstall-feedback/index.ts
--no-verify-jwt, anonymous (NOT YET DEPLOYED)"]
end
subgraph AuthLayer["Supabase Auth (GoTrue) + handoff"]
GoTrue["/auth/v1 — email OTP,
Google/Apple OAuth (PKCE)"]
Handoff["public.auth_handoff table
stash_handoff() / claim_handoff()"]
end
subgraph DB["Postgres (RLS on, service role bypasses)"]
Profiles[("profiles
tier, status, stripe/razorpay_customer_id")]
AppConfig[("app_config
global maintenance kill-switch")]
Tiers[("tiers, pricing_config,
promo_config, coupons, referrals")]
Orgs[("orgs, exams,
exam_submissions")]
Feedback[("uninstall_feedback")]
Events[("usage_events, viewer_events,
bug_reports")]
end
subgraph Storage["Supabase Storage"]
Bucket[("exam-submissions bucket
{org}/{exam}/{uid}-{ts}.rewind")]
end
subgraph ExternalBilling["External billing providers"]
Stripe["Stripe
Payment Links + webhooks"]
Razorpay["Razorpay
Payment Links + webhooks"]
end
Resend["Resend
transactional email API"]
Addin -- "signInWithOtp / verifyOtp
signInWithOAuth handoff poll" --> GoTrue
Addin -- "rpc claim_handoff" --> Handoff
Addin -- "checkAccess(): direct select,
no Edge Function involved" --> AppConfig
Addin -- "checkAccess() + rpc register_device/
touch_device/effective_features" --> Profiles
ViewerGS -- "exchangeCodeForSession
rpc stash_handoff" --> GoTrue
ViewerGS --> Handoff
Addin -- "POST action:upload_url/review_url
Authorization: Bearer <jwt>" --> ExamStorage
Team -- "POST send-invite" --> SendInvite
Team -- "POST verify-domain" --> VerifyDomain
AdminUI -- "POST action:*
Bearer OWNER_TOKEN + x-superadmin-jwt" --> AdminOps
AdminUI -- "GET ?days=N
Bearer OWNER_TOKEN" --> Metrics
Installer -- "POST install/uninstall event
(anonymous, best-effort)" --> UninstallFb
Stripe -- "checkout.session.completed,
customer.subscription.deleted,
invoice.payment_failed" --> StripeWH
Razorpay -- "payment_link.paid,
subscription.cancelled, payment.failed" --> RzpWH
StripeWH -- "update tier by email/customer_id" --> Profiles
RzpWH -- "update tier by email/customer_id" --> Profiles
AdminOps -- "update / rpc tier_*, feature_*,
cohort_*, coupon_*, user_*" --> Profiles
AdminOps -- "maintenance_set" --> AppConfig
AdminOps --> Tiers
AdminOps --> Orgs
Metrics -- "rpc metrics_* + direct reads" --> Profiles
Metrics --> Events
Metrics --> Feedback
Metrics --> Tiers
ExamStorage -- "signed PUT/GET via storage adapter" --> Bucket
ExamStorage -- "reads exams/exam_submissions" --> Orgs
ExamStorage -- "isOrgMember/orgRole via profiles.org_id" --> Profiles
SendInvite -- "getUser(jwt) self-check" --> GoTrue
SendInvite -- "email HTML" --> Resend
VerifyDomain -- "getUser(jwt) self-check" --> GoTrue
VerifyDomain -- "Deno.resolveDns TXT lookup" --> DNS["Public DNS
_excelrewind.<domain>"]
VerifyDomain -- "update orgs.domain_verified" --> Orgs
UninstallFb -- "insert (rate-limited by trigger)" --> Feedback
Two views: money flowing in (billing) and everything-else flowing through the owner-gated endpoints.
flowchart LR
Buyer["Payer's browser"] -- "checkout form" --> PL["Stripe/Razorpay
Payment Link (hosted, no code of ours)"]
PL -- "webhook POST, JSON body
+ stripe-signature / x-razorpay-signature header" --> WH["stripe-webhook or
razorpay-webhook (Deno)"]
WH -- "constructEventAsync (Stripe)
or HMAC-SHA256 verify (Razorpay)" --> WH
WH -- "PRICE_TO_TIER / RZP_LINK_TO_TIER
env JSON: price/link id -> tier string" --> WH
WH -- "UPDATE profiles SET tier=..,
stripe_customer_id=.. WHERE email ILIKE (escaped)" --> ProfilesDB[("profiles")]
ProfilesDB -- "SELECT tier
on every checkAccess()" --> Addin2["Excel add-in gate
(apps/addin-excel/src/auth.ts)"]
WH -. "5xx on handler error" .-> PL
PL -. "auto-retries on 5xx" .-> WH
Payload shape in: Stripe/Razorpay's own JSON event envelope (never ExcelRewind's format). Payload shape out:
a single-row UPDATE to public.profiles — no other table is touched, and the function is
idempotent (setting the same tier twice is a no-op-equivalent, and Stripe/Razorpay retries on 5xx are safe
to replay).
flowchart LR
Owner["Owner (browser,
admin.excelrewind.com)"] -- "email OTP sign-in" --> GoTrue2["Supabase Auth"]
GoTrue2 -- "session JWT for SUPERADMIN_EMAIL" --> Owner
Owner -- "POST { action, ...args }
Authorization: Bearer OWNER_METRICS_TOKEN
x-superadmin-jwt: <session JWT>" --> AdminOps2["admin-ops"]
AdminOps2 -- "timingSafeEqual(token)" --> AdminOps2
AdminOps2 -- "supabase.auth.getUser(jwt)
email must == SUPERADMIN_EMAIL" --> GoTrue2
AdminOps2 -- "service-role UPDATE/INSERT
or rpc tier_*/feature_*/cohort_*/user_delete" --> PG[("Postgres
profiles, tiers, orgs,
coupons, promo_config,
pricing_config")]
Owner -- "GET /metrics?days=N
Authorization: Bearer OWNER_METRICS_TOKEN" --> Metrics2["metrics"]
Metrics2 -- "parallel rpc metrics_*
+ direct table reads" --> PG
PG -- "JSON dashboard contract
(KPIs, series, funnels, revenue estimate)" --> Metrics2
Metrics2 -- "200 JSON" --> Owner
sequenceDiagram
participant User
participant Pane as "Add-in pane (auth.ts)"
participant GoTrue as "Supabase Auth"
participant DB as "Postgres"
User->>Pane: enters email
Pane->>GoTrue: signInWithOtp({ email, options.data.ref })
Note right of Pane: pendingRef() attaches a captured
referral code into raw_user_meta_data
GoTrue-->>User: emails 6-digit code
User->>Pane: enters code
Pane->>GoTrue: verifyOtp({ email, token, type:'email' })
GoTrue-->>Pane: session (access_token, refresh_token)
alt on new user insert
GoTrue->>DB: trigger on_auth_user_created -> handle_new_user()
DB->>DB: insert profiles(id, email)
end
Pane->>DB: rpc register_device(device_id, label)
Note right of Pane: evicts oldest device beyond 2 (feature-detected,
missing RPC = limit inactive, never blocks sign-in)
Pane->>DB: checkAccess(): select app_config + profiles
DB-->>Pane: { enabled, message } + { status, tier, telemetry }
Pane->>DB: rpc touch_device(device_id)
DB-->>Pane: true (still registered) | false (evicted elsewhere)
alt evicted or licence blocked
Pane->>Pane: signOut() + clear CACHE_KEY, show sign-in
else allowed
Pane->>Pane: cache { at, email } in localStorage (offline grace)
end
sequenceDiagram
participant Pane as "Add-in pane (WebView2)"
participant Sys as "System browser"
participant Google
participant GS as "viewer/google-signin.html"
participant GoTrue as "Supabase Auth"
participant DB as "public.auth_handoff"
Pane->>Pane: makeNonce() (24 random bytes, hex)
Pane->>Sys: window.open(viewerOrigin + "/google-signin.html#nonce=..&provider=google")
Note over Pane,Sys: viewer origin is deliberately absent from the
manifest AppDomains - this forces the OS to open
the SYSTEM browser instead of an in-pane webview
Sys->>GS: load page, nonce/provider -> sessionStorage
GS->>GoTrue: signInWithOAuth({ provider:'google', redirectTo: self })
GoTrue->>Google: PKCE redirect
Google-->>Sys: consent, redirect back with ?code=
Sys->>GS: reload with ?code=
GS->>GoTrue: exchangeCodeForSession(code)
GoTrue-->>GS: session (access_token, refresh_token)
GS->>DB: rpc stash_handoff(nonce, access, refresh)
Note right of DB: security definer, nonce must be >=32 chars,
rows purge after 10 minutes
GS-->>Sys: "Signed in - return to Excel"
par pane polls concurrently
loop every 2.5s, up to 5 min
Pane->>DB: rpc claim_handoff(nonce)
DB-->>Pane: empty (not yet) or (deletes row) tokens
end
end
Pane->>GoTrue: setSession({ access_token, refresh_token })
Pane->>DB: rpc register_device(device_id, label)
Pane-->>Pane: adopted session, pane now signed in
sequenceDiagram
participant Payer
participant Stripe
participant WH as "stripe-webhook (Deno)"
participant DB as "profiles"
Payer->>Stripe: opens Payment Link ?prefilled_email=user@x.com, pays
Stripe->>WH: POST checkout.session.completed
(stripe-signature header)
WH->>WH: stripe.webhooks.constructEventAsync(body, sig, secret, cryptoProvider)
alt bad signature
WH-->>Stripe: 400 "bad signature"
else verified
WH->>Stripe: subscriptions.retrieve(session.subscription)
Stripe-->>WH: subscription with price id
WH->>WH: tier = PRICE_TO_TIER[priceId]
alt no email or unmapped price
WH-->>Stripe: 200 ok (ignored, logged)
else
WH->>DB: UPDATE profiles SET tier, stripe_customer_id
WHERE email ILIKE escaped(email)
WH-->>Stripe: 200 ok
end
end
Note over Stripe,WH: later — cancellation
Stripe->>WH: POST customer.subscription.deleted
WH->>DB: UPDATE profiles SET tier='free' WHERE stripe_customer_id = ..
alt no row matched (never linked)
WH->>Stripe: customers.retrieve(customerId)
Stripe-->>WH: customer.email
WH->>DB: UPDATE profiles SET tier='free' WHERE email ILIKE ..
end
WH-->>Stripe: 200 ok
Note over Stripe,WH: handler exception anywhere above
WH-->>Stripe: 500 (Stripe retries automatically)
sequenceDiagram
participant Student as "Add-in (student, exam mode)"
participant ES as "exam-storage (Deno)"
participant DB as "Postgres"
participant Bucket as "Supabase Storage
exam-submissions bucket"
participant Owner as "Team console (org owner/admin)"
Student->>ES: POST { action:'upload_url', exam_id }
Authorization: Bearer <student JWT>
ES->>DB: auth.getUser(jwt)
DB-->>ES: uid
ES->>DB: select exams where id=exam_id
DB-->>ES: { org_id }
ES->>DB: isOrgMember(uid, org_id) via profiles.org_id
alt not rostered
ES-->>Student: 403 not_rostered
else rostered
ES->>Bucket: createSignedUploadUrl({org}/{exam}/{uid}-{ts}.rewind)
Bucket-->>ES: signed PUT url
ES-->>Student: { url, storage_path }
Student->>Bucket: PUT raw .rewind bytes directly (client never touches storage keys)
Student->>DB: rpc exam_submit(exam_id, storage_path, duration_s, flags, integrity_hash)
end
Note over Owner,DB: later — review
Owner->>ES: POST { action:'review_url', submission_id }
Authorization: Bearer <owner JWT>
ES->>DB: orgRole(uid, exam.org_id)
alt not owner/admin
ES-->>Owner: 403 not_owner
else authorized
ES->>Bucket: createSignedUrl(storage_path, 300s)
Bucket-->>ES: signed GET url
ES-->>Owner: { url, student_email, exam_name }
Owner->>Bucket: GET .rewind, replay in in-Excel/viewer replay
end
flowchart LR
addin["addin-excel"] -->|"email OTP, OAuth, RPCs, checkAccess()"| supabaseAuth["Supabase Auth (GoTrue)"]
addin -->|"upload_url / review_url"| examstorage["exam-storage"]
viewer["viewer (google-signin.html)"] -->|"exchangeCodeForSession, stash_handoff"| supabaseAuth
team["team console"] -->|"send-invite, verify-domain"| edgefns["Edge Functions"]
admin["admin console"] -->|"admin-ops, metrics"| edgefns
installer["Windows installer (NSIS)"] -->|"uninstall-feedback (anonymous, not yet deployed)"| edgefns
edgefns -->|"service-role reads/writes"| pg["Postgres (backend-data module)"]
stripewh["stripe-webhook"] -->|"tier flip"| pg
Stripe -->|"webhook events, signed"| stripewh
Razorpay -->|"webhook events, HMAC-signed"| razorpaywh["razorpay-webhook (not deployed)"]
razorpaywh --> pg
sendinvite["send-invite"] -->|"transactional email"| Resend
verifydomain["verify-domain"] -->|"DNS TXT lookup"| DNS2["Public DNS"]
| From | To | Protocol / payload | Purpose |
|---|---|---|---|
| apps/addin-excel (auth.ts) | Supabase Auth /auth/v1 | HTTPS, supabase-js (signInWithOtp, verifyOtp, signInWithOAuth via redirect, setSession) | Email OTP + adopting a system-browser-completed OAuth session |
| apps/addin-excel | Postgres RPCs (register_device, touch_device, claim_handoff, effective_features, exam_submit, exam_get, etc.) | HTTPS, supabase-js .rpc() | Device-limit enforcement, OAuth handoff claim, feature entitlement resolution, exam flow |
| apps/addin-excel | exam-storage Edge Function | HTTPS POST, JSON, Authorization: Bearer <user access_token> | Signed PUT URL for exam submission upload |
| apps/viewer (google-signin.ts) | Supabase Auth + stash_handoff RPC | HTTPS, supabase-js (PKCE code exchange, then RPC) | Completes Google/Apple OAuth in the system browser and stashes the session for the pane to claim |
| stripe-webhook | Stripe API | HTTPS, Stripe SDK (fetch client) — stripe.subscriptions.retrieve, stripe.customers.retrieve | Resolve price id → tier; resolve customer email on downgrade fallback |
| Stripe | stripe-webhook | HTTPS POST, JSON event + stripe-signature header | checkout.session.completed, customer.subscription.deleted, invoice.payment_failed |
| Razorpay | razorpay-webhook not deployed | HTTPS POST, JSON event + x-razorpay-signature (raw HMAC-SHA256 hex) | payment_link.paid, subscription.cancelled, payment.failed |
| admin console | admin-ops | HTTPS POST, JSON {action,...}, Authorization: Bearer OWNER_METRICS_TOKEN + x-superadmin-jwt | All superadmin mutations: tiers, features, coupons, cohorts, promo/pricing config, user/org ops, destructive data reset |
| admin console | metrics | HTTPS GET ?days=N, Authorization: Bearer OWNER_METRICS_TOKEN | Read-only telemetry/analytics/revenue-estimate dashboard payload |
| team console | send-invite | HTTPS POST, JSON, forwarded user JWT | Sends the team-invite email via Resend after org_invite() succeeds in SQL |
| send-invite | Resend API | HTTPS POST api.resend.com/emails, Authorization: Bearer RESEND_API_KEY | Transactional invite email; fail-soft (200 with sent:false on error) |
| team console | verify-domain | HTTPS POST, JSON, forwarded user JWT | Owner-only enterprise domain verification trigger |
| verify-domain | Public DNS | Deno.resolveDns(name, 'TXT') | Looks up _excelrewind.<domain> TXT record to confirm domain ownership |
Windows installer (apps/installer/ExcelRewind-Setup.nsi) | uninstall-feedback not deployed | HTTPS POST, JSON, no auth (anonymous, best-effort, wrapped in try/catch) | Install/uninstall telemetry + optional exit survey; DB trigger rate-limits per IP (20/hr). Source comment: "NOT YET DEPLOYED — until the deploy above is run, the installer's POST 404s" (harmless, try/catch-wrapped). Mac installer (apps/companion-mac/installer/*) has no telemetry call at all — it never reaches this function; this is a Windows-only integration today. |
| exam-storage | Supabase Storage (exam-submissions bucket) | supabase-js storage.createSignedUploadUrl / createSignedUrl | Provider-agnostic signed URL broker — the only storage-specific surface (adapter seam for a future S3 swap) |
| All Edge Functions | Postgres (this module's data store, see backend-data) | supabase-js service-role client (bypasses RLS) | Every mutation/read that isn't done by the client directly against RLS-guarded tables |
| File | Function / export | What it does | Why it matters |
|---|---|---|---|
stripe-webhook/index.ts | Deno.serve handler | Verifies Stripe signature via constructEventAsync + SubtleCryptoProvider (Deno has no Node crypto, so the sync constructEvent would throw), dispatches on event.type | The entire paid-tier grant path; a bug here silently under- or over-entitles paying customers |
| ↳ | setTierByEmail(email, tier, customerId?) | Escapes %/_/\ before an ILIKE match on profiles.email | security Without escaping, a checkout email like a_%@x.com is a LIKE pattern that can match and upgrade other accounts — this was a fixed real issue (see the v2.0.1 comment in the Razorpay twin) |
| ↳ | downgradeToFree(customerId) | Tries stripe_customer_id match first, falls back to stripe.customers.retrieve → email match | Handles the case where a tier was flipped manually (README "manual fallback") and never got a stored customer id |
razorpay-webhook/index.ts | verifySignature(rawBody, sigHex) | Hand-rolled HMAC-SHA256 verification via Web Crypto (crypto.subtle.verify, constant-time) — no Razorpay SDK dependency | Same tier-flip contract as Stripe but for a not-yet-live provider; written and unit-shaped but untested until a live webhook secret exists |
admin-ops/index.ts | Deno.serve handler + isSuperadminJwt(jwt) | Two independent guards: constant-time OWNER_METRICS_TOKEN bearer check, then supabase.auth.getUser(jwt) validated against the live Auth server for a session belonging to SUPERADMIN_EMAIL | security The single write path to tiers/features/coupons/cohorts/pricing/user status outside raw SQL — a stolen owner token alone is insufficient (returns 401 otp_required) |
| ↳ | dataReset(b) | Requires the exact string "RESET DATA", re-checked again inside admin_data_reset() in SQL | Third guard on a destructive pre-launch wipe RPC — belt-and-braces beyond the two request-level guards |
| ↳ | referralStats() | Aggregates referrals + profiles in JS (not a SQL rollup) to compute conversion and top referrers | Marked ponytail: — intentionally simple for pre-launch scale; returns referrer emails only, never a referee list (privacy) |
metrics/index.ts | rpc<T>(fn, args, fallback) | Wraps every metrics_* RPC call so a single failing section returns its fallback instead of 500ing the whole payload | The dashboard must render partial data on a bad section rather than going blank — deliberate fail-soft design |
| ↳ | readRevenue(from) | Computes estimated MRR/ARR from active paid profiles × tiers.monthly_price (+ seat-based enterprise math from pricing_config) — there is no Stripe billing ledger read | Always returns estimated:true, basis:'active_plans_pre_stripe' — never presented as booked revenue; this is the only revenue signal until Stripe usage is wired into metrics |
exam-storage/index.ts | StorageAdapter interface + supabaseStorage(client) | The only provider-specific surface for exam-file storage; two methods (signedUploadUrl, signedDownloadUrl) | Swapping Supabase Storage for S3 later is a new adapter behind the same two-method contract, no caller changes — mirrors the SDD's cloud-agnostic mandate |
| ↳ | uploadUrl(uid, b) / reviewUrl(uid, b) | Enforce roster membership (isOrgMember) for upload, owner/admin org role (orgRole) for review, before issuing any signed URL | Authorization happens in the function, not just RLS — the bucket itself has no public policy |
send-invite/index.ts | Deno.serve handler | Validates the caller is a real signed-in user via a per-request client carrying the forwarded JWT, then POSTs to Resend | Prevents the function being an open email-relay; fail-soft (200 {sent:false}) because the invite row already exists in SQL regardless of email delivery |
verify-domain/index.ts | Deno.serve handler | Owner-only real DNS TXT lookup at _excelrewind.<domain> via Deno.resolveDns, compares against orgs.domain_token-derived expected value | The one function that talks to the public DNS system directly, not just Supabase; NXDOMAIN/resolver errors return verified:false (200), not a 500 |
uninstall-feedback/index.ts not deployed | cap(v, n) + Deno.serve handler | Whitelists and length-caps every field from an unauthenticated installer POST; forwards caller IP for a DB-side trigger throttle | Deliberately the most defensive function against hostile input — the installer has no session at all, and the insert runs under the service role which bypasses RLS entirely (the rate limit is a Postgres trigger, not RLS, precisely because RLS can't see the service role). Source header: not yet deployed — the installer's POST 404s until supabase functions deploy uninstall-feedback --no-verify-jwt is run; the call is try/catch-wrapped so this is harmless today |
| File | Function / export | What it does | Why it matters |
|---|---|---|---|
apps/addin-excel/src/auth.ts | sendOtp / verifyOtp | Thin wrappers over supabase.auth.signInWithOtp / verifyOtp; attaches a sanitised referral code into signup metadata | The default sign-in path — works entirely inside the WebView2 pane because it never navigates |
| ↳ | oauthSignInUrl(nonce, provider) / pollOAuthHandoff(nonce, signal) | Builds the viewer-origin URL opened in the system browser; polls claim_handoff every 2.5s for up to 5 minutes | The entire workaround for Google refusing OAuth in embedded webviews — the viewer origin's deliberate absence from the manifest's AppDomains is what forces an external browser |
| ↳ | checkAccess() | Full launch-time gate: session → app_config (maintenance) + profiles (status/tier) → device-limit touch_device; falls back to a cached last-good check on network failure | Runs on every pane load; combines licence enforcement, a global kill switch, and per-account device limiting (max 2) in one function |
| ↳ | fetchAuthProviders() | Probes /auth/v1/settings once to hide OAuth buttons for providers not enabled on the Supabase project | Fails closed (hides buttons) so customers never hit a raw "provider not enabled" error |
apps/viewer/src/google-signin.ts | main() | Runs the PKCE OAuth round-trip in the system browser, then calls stash_handoff | The other half of the handoff; detectSessionInUrl:false makes the exchange→stash order deterministic instead of relying on supabase-js's implicit URL parsing |
| Object | Defined in | Used by |
|---|---|---|
public.auth_handoff, stash_handoff(), claim_handoff() | supabase/schema.sql | viewer google-signin.ts (stash), addin auth.ts (claim) — no Edge Function involved, this leg is pure client + Postgres |
public.uninstall_feedback_ratelimit() trigger | supabase/migrations/20260715_uninstall_feedback_ratelimit.sql | uninstall-feedback inserts; trigger enforces 20/IP/hour even under the service-role insert (RLS-based limiting would be a no-op) |
public.handle_new_user() trigger on auth.users | supabase/schema.sql | Fires on every GoTrue signup regardless of provider (OTP or OAuth) — creates the profiles row the rest of this module reads/writes |
tier_list, tier_create, tier_set, tier_delete, features_get, feature_set, feature_reset, cohort_*, user_delete, admin_data_reset | various supabase/migrations/*.sql | admin-ops thin wrappers — all validation/authorization for these lives in SQL; admin-ops mostly maps HTTP → RPC and maps known SQL error strings to 400 via a leading ! marker |
exam-storage only brokers a
signed URL — the actual .rewind bytes go client → Supabase Storage directly, bypassing
this function entirely (protocol-level enforcement of the SDD's "session events + audio only, never full
spreadsheet content" constraint, at least for the exam submission path).
createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) from a Deno env var — it is a platform
secret set via supabase secrets set, never a VITE_-prefixed variable, so it can
never end up in a client bundle. profiles has no update policy at all in RLS —
the only way to change tier/status is through a service-role write (a webhook, or
admin-ops) or a security definer RPC.
--no-verify-jwt is not "no auth." Five of eight functions disable Supabase's
platform-level JWT check, but every one of them substitutes its own guard: webhook signature verification
(Stripe/Razorpay), a static owner bearer token (admin-ops, metrics), or a self-performed
supabase.auth.getUser(jwt) round-trip against the live Auth server (exam-storage) — never a local
JWT decode, which would accept an expired or forged token. send-invite and
verify-domain keep the default JWT verification and layer their own getUser()
check on top.
profiles.email case-insensitively with Postgres ILIKE, and both escape
%/_/\ in the payer's email before the match — documented inline as a
"v2.0.1 lesson." Any future code that matches user-supplied strings against profiles.email (or
any column) with ILIKE must repeat this escaping or it re-opens the same cross-account-upgrade
hole.
metrics and
send-invite fail soft (return 200 with an empty/false payload) because a dashboard
section or an email send is not worth blocking the user's flow. admin-ops,
exam-storage's authorization checks, and fetchAuthProviders() in the client fail
closed (401/403, or hidden UI) because they gate money, PII, or account takeover. When adding a new
action, match the existing failure mode of its category rather than defaulting to one style everywhere.
AppDomains, a random
192-bit nonce, a 10-minute-expiring one-time Postgres row) exists purely to route around this; it is the kind
of "hard-won Office.js platform knowledge" CLAUDE.md and finding.md call out as
something that must not be relearned. Any redesign of sign-in must preserve the handoff shape or reintroduce
the same platform failure.
metrics, MRR/ARR is computed purely from
active profiles × tiers.monthly_price and marked estimated:true,
basis:'active_plans_pre_stripe' in the response. This mirrors the project's broader rule (see
rewind-encryption-gap practice) of never letting a doc or dashboard claim more certainty than the
code actually delivers.
stripe-webhook must use
constructEventAsync + Stripe.createSubtleCryptoProvider() instead of the Stripe
SDK's default synchronous constructEvent (which throws on Edge). razorpay-webhook
hand-rolls HMAC-SHA256 verification with Web Crypto (crypto.subtle) rather than pulling in a
Razorpay SDK, specifically to stay Deno-native. Any new webhook integration on this runtime should assume the
same constraint.
stripe-webhook and razorpay-webhook both flip profiles.tier — a user
can in principle be linked to both a stripe_customer_id and a razorpay_customer_id
depending on which region's Payment Link they used. There is no cross-provider reconciliation; the last
webhook to fire wins. This is acceptable today because Razorpay is not yet live in production.
razorpay-webhook is real code, not a stub — but genuinely untested. The
README is explicit: "the function is written but untested until a webhook secret arrives." Treat any
Razorpay-specific claim about behavior as unverified until the Indian entity + KYC exists and a live webhook
has actually been exercised.