Backend: services

Supabase Edge Functions (Deno) + Supabase Auth (GoTrue) + Stripe/Razorpay billing — the stateless glue that never holds spreadsheet content.

Contents
  1. Overview
  2. Architecture diagram
  3. Data-flow diagrams
  4. Process / sequence diagrams
  5. Interconnections
  6. Key functions & files
  7. Invariants & gotchas

1. Overview

"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.

2. Architecture diagram

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

3. Data-flow diagrams

Two views: money flowing in (billing) and everything-else flowing through the owner-gated endpoints.

3a. Billing data flow

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).

3b. admin-ops / metrics data flow

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

4. Process / sequence diagrams

4a. Email OTP sign-in (the common path)

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

4b. Google OAuth via system-browser handoff

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

4c. Stripe checkout → tier flip

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)

4d. Kiosk/exam submission upload (exam-storage)

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

5. Interconnections

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"]
External and cross-module edges
FromToProtocol / payloadPurpose
apps/addin-excel (auth.ts)Supabase Auth /auth/v1HTTPS, supabase-js (signInWithOtp, verifyOtp, signInWithOAuth via redirect, setSession)Email OTP + adopting a system-browser-completed OAuth session
apps/addin-excelPostgres 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-excelexam-storage Edge FunctionHTTPS POST, JSON, Authorization: Bearer <user access_token>Signed PUT URL for exam submission upload
apps/viewer (google-signin.ts)Supabase Auth + stash_handoff RPCHTTPS, 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-webhookStripe APIHTTPS, Stripe SDK (fetch client) — stripe.subscriptions.retrieve, stripe.customers.retrieveResolve price id → tier; resolve customer email on downgrade fallback
Stripestripe-webhookHTTPS POST, JSON event + stripe-signature headercheckout.session.completed, customer.subscription.deleted, invoice.payment_failed
Razorpayrazorpay-webhook not deployedHTTPS POST, JSON event + x-razorpay-signature (raw HMAC-SHA256 hex)payment_link.paid, subscription.cancelled, payment.failed
admin consoleadmin-opsHTTPS POST, JSON {action,...}, Authorization: Bearer OWNER_METRICS_TOKEN + x-superadmin-jwtAll superadmin mutations: tiers, features, coupons, cohorts, promo/pricing config, user/org ops, destructive data reset
admin consolemetricsHTTPS GET ?days=N, Authorization: Bearer OWNER_METRICS_TOKENRead-only telemetry/analytics/revenue-estimate dashboard payload
team consolesend-inviteHTTPS POST, JSON, forwarded user JWTSends the team-invite email via Resend after org_invite() succeeds in SQL
send-inviteResend APIHTTPS POST api.resend.com/emails, Authorization: Bearer RESEND_API_KEYTransactional invite email; fail-soft (200 with sent:false on error)
team consoleverify-domainHTTPS POST, JSON, forwarded user JWTOwner-only enterprise domain verification trigger
verify-domainPublic DNSDeno.resolveDns(name, 'TXT')Looks up _excelrewind.<domain> TXT record to confirm domain ownership
Windows installer (apps/installer/ExcelRewind-Setup.nsi)uninstall-feedback not deployedHTTPS 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-storageSupabase Storage (exam-submissions bucket)supabase-js storage.createSignedUploadUrl / createSignedUrlProvider-agnostic signed URL broker — the only storage-specific surface (adapter seam for a future S3 swap)
All Edge FunctionsPostgres (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

6. Key functions & files

supabase/functions/*
FileFunction / exportWhat it doesWhy it matters
stripe-webhook/index.tsDeno.serve handlerVerifies Stripe signature via constructEventAsync + SubtleCryptoProvider (Deno has no Node crypto, so the sync constructEvent would throw), dispatches on event.typeThe 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.emailsecurity 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 matchHandles the case where a tier was flipped manually (README "manual fallback") and never got a stored customer id
razorpay-webhook/index.tsverifySignature(rawBody, sigHex)Hand-rolled HMAC-SHA256 verification via Web Crypto (crypto.subtle.verify, constant-time) — no Razorpay SDK dependencySame 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.tsDeno.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_EMAILsecurity 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 SQLThird 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 referrersMarked ponytail: — intentionally simple for pre-launch scale; returns referrer emails only, never a referee list (privacy)
metrics/index.tsrpc<T>(fn, args, fallback)Wraps every metrics_* RPC call so a single failing section returns its fallback instead of 500ing the whole payloadThe 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 readAlways 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.tsStorageAdapter 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 URLAuthorization happens in the function, not just RLS — the bucket itself has no public policy
send-invite/index.tsDeno.serve handlerValidates the caller is a real signed-in user via a per-request client carrying the forwarded JWT, then POSTs to ResendPrevents 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.tsDeno.serve handlerOwner-only real DNS TXT lookup at _excelrewind.<domain> via Deno.resolveDns, compares against orgs.domain_token-derived expected valueThe 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 deployedcap(v, n) + Deno.serve handlerWhitelists and length-caps every field from an unauthenticated installer POST; forwards caller IP for a DB-side trigger throttleDeliberately 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
Client-side auth story (drives the Edge Functions / Auth server above)
FileFunction / exportWhat it doesWhy it matters
apps/addin-excel/src/auth.tssendOtp / verifyOtpThin wrappers over supabase.auth.signInWithOtp / verifyOtp; attaches a sanitised referral code into signup metadataThe 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 minutesThe 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 failureRuns 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 projectFails closed (hides buttons) so customers never hit a raw "provider not enabled" error
apps/viewer/src/google-signin.tsmain()Runs the PKCE OAuth round-trip in the system browser, then calls stash_handoffThe other half of the handoff; detectSessionInUrl:false makes the exchange→stash order deterministic instead of relying on supabase-js's implicit URL parsing
SQL surface the Edge Functions depend on (see the sibling backend-data module for full detail)
ObjectDefined inUsed by
public.auth_handoff, stash_handoff(), claim_handoff()supabase/schema.sqlviewer google-signin.ts (stash), addin auth.ts (claim) — no Edge Function involved, this leg is pure client + Postgres
public.uninstall_feedback_ratelimit() triggersupabase/migrations/20260715_uninstall_feedback_ratelimit.sqluninstall-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.userssupabase/schema.sqlFires 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_resetvarious supabase/migrations/*.sqladmin-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

7. Invariants & gotchas

Never full spreadsheet content over the wire. No Edge Function in this module accepts, stores, or forwards cell values, formulas, or workbook content. 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).
Service-role key discipline. Every function that needs privileged DB access constructs its own 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.
ILIKE injection via billing email match. Both billing webhooks match 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.
Fail-soft vs. fail-closed is deliberate, not uniform. 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.
Google/Apple OAuth cannot run inside the Excel task pane — by platform policy, not choice. The system-browser handoff (viewer origin excluded from the manifest's 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.
Estimated revenue is clearly labelled, never presented as ground truth. Until Stripe/Razorpay usage data is wired into 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.
Deno has no Node crypto. 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.
Both billing providers write the same column, concurrently, by design. 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.