Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
[
"@sentry/react-native/expo",
{
url: "https://sentry.io/",
url: "https://de.sentry.io/",
project: "mapvault",
organization: "patrick-alvarez",
},
Expand Down
20 changes: 19 additions & 1 deletion docs/database.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Database Schema

9 tables in the `public` schema, all with RLS enabled. Supabase Auth manages `auth.users`; everything else lives here.
9 application tables in the `public` schema plus 1 internal infrastructure table (`drift_check_runs`), all with RLS enabled. Supabase Auth manages `auth.users`; everything else lives here.

## Entity Relationships

Expand Down Expand Up @@ -171,6 +171,22 @@ Invite tokens for sharing maps with other users.

**RLS:** Map members can SELECT invites for their maps. Owners can INSERT (enforced via `create-invite` Edge Function which checks premium entitlement).

## Infrastructure Tables

These tables back internal machinery, not user-facing data. They are intentionally excluded from the entity-relationship diagram above.

### drift_check_runs

Mutex row for the `rc-entitlement-drift-check` Edge Function so overlapping cron fires don't double-report drift. See `docs/payments.md` → "Drift Health Check".

| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| job_name | text | PK | Stable string `'rc-entitlement-drift-check'` |
| started_at | timestamptz | NOT NULL, default `now()` | Set on every acquire |
| finished_at | timestamptz | nullable | NULL = run in flight; set by `release_drift_check_lock` |

**RLS:** Enabled with **no policies** (default-deny). The `SECURITY DEFINER` helpers `try_acquire_drift_check_lock(text, interval)` and `release_drift_check_lock(text)` run as table owner and bypass RLS; `anon` and `authenticated` cannot read or write via PostgREST.

## Triggers

### on_auth_user_created → `handle_new_user()`
Expand Down Expand Up @@ -217,3 +233,5 @@ SECURITY DEFINER function that checks if the current user is a member of a map.
| `20260223000001_cleanup_orphaned_places_on_delete.sql` | Add orphaned places cleanup to deletion trigger |
| `20260304000001_freemium_roles_redesign.sql` | Rename `editor` → `contributor`, add `member` role, restrict RLS to owner/contributor writes, add CHECK constraints |
| `20260305000001_fix_map_place_tags_cross_map.sql` | Fix cross-map tag validation in map_place_tags INSERT and DELETE RLS policies |
| `20260513000001_enable_pg_cron_and_drift_check_lock.sql` | Enable `pg_cron`; create `drift_check_runs` + `try_acquire_drift_check_lock` / `release_drift_check_lock` RPCs |
| `20260513000002_schedule_rc_entitlement_drift_check.sql` | Schedule `rc-entitlement-drift-check` cron job (every 6h at :17 UTC) with bearer from `vault.decrypted_secrets` |
8 changes: 5 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ Deploy individual functions after changes:
supabase functions deploy <function-name> --no-verify-jwt
```

Deploy all 5 at once:
Deploy all 7 at once:

```bash
for fn in create-map add-place accept-invite revenuecat-webhook delete-account; do
for fn in create-map add-place accept-invite create-invite revenuecat-webhook rc-entitlement-drift-check delete-account; do
supabase functions deploy "$fn" --no-verify-jwt
done
```
Expand All @@ -51,7 +51,9 @@ Server-side secrets (not in `.env`):
| Secret | Purpose |
|--------|---------|
| `REVENUECAT_WEBHOOK_SECRET` | Authenticates RevenueCat webhook requests |
| `REVENUECAT_SECRET_API_KEY` | Admin API key for subscriber deletion |
| `REVENUECAT_SECRET_API_KEY` | Admin API key (used by `delete-account` and `rc-entitlement-drift-check`) |
| `REVENUECAT_PROJECT_ID` | RC project id (used by `rc-entitlement-drift-check`) |
| `RC_DRIFT_CHECK_INVOKE_SECRET` | Bearer for pg_cron → `rc-entitlement-drift-check`; must mirror `vault.secrets.rc_drift_check_invoke_secret`. See `docs/payments.md` → "Drift Health Check" for the first-time setup and rotation runbook. |

`SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` are auto-injected — no manual setup needed.

Expand Down
55 changes: 54 additions & 1 deletion docs/edge-functions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Edge Functions Reference

6 Supabase Edge Functions that enforce business rules that can't be trusted to the client. All deployed with `--no-verify-jwt` and validate auth internally — most use `auth.getUser()` with a user Bearer token, except `revenuecat-webhook` which validates a shared webhook secret.
7 Supabase Edge Functions that enforce business rules that can't be trusted to the client. All deployed with `--no-verify-jwt` and validate auth internally — most use `auth.getUser()` with a user Bearer token, except `revenuecat-webhook` and `rc-entitlement-drift-check` which validate a shared bearer secret.

## Overview

Expand All @@ -11,6 +11,7 @@
| `accept-invite` | Accept an invite token and join a map | Validates expiry, max uses, duplicates |
| `create-invite` | Create an invite link for a map | Premium owners only |
| `revenuecat-webhook` | Sync purchase events to entitlement | Maps RC events → `profiles.entitlement` |
| `rc-entitlement-drift-check` | Scheduled reconciliation of RC active entitlements vs `profiles.entitlement` | Every 6h at :17 UTC via `pg_cron`; Sentry alert on drift > 0 |
| `delete-account` | Delete user and all associated data | RC cleanup (best-effort) + auth deletion |

---
Expand Down Expand Up @@ -308,3 +309,55 @@ No request body required.
`auth.users` (delete) → cascading cleanup via trigger handles all public schema tables

See `docs/account-deletion.md` for the full deletion pipeline and what gets preserved vs deleted.

---

## rc-entitlement-drift-check

Out-of-band health check that walks every RevenueCat customer and reconciles their active entitlements against `profiles.entitlement`. Drift > 0 fires a Sentry event with a stable fingerprint so consecutive runs collapse into one issue; drift = 0 produces a JSON heartbeat log only. Invoked by `pg_cron` every 6 hours at `:17` past the hour (UTC).

**Auth:** Invoke-secret bearer (NOT a user token)

### Request

```
POST /functions/v1/rc-entitlement-drift-check
Authorization: Bearer <RC_DRIFT_CHECK_INVOKE_SECRET>
```

No request body. The cron migration (`20260513000002_schedule_rc_entitlement_drift_check.sql`) pulls the bearer live from `vault.decrypted_secrets` on each fire.

### Responses

| Status | Body | When |
|--------|------|------|
| 200 | `{ "drift_count": N }` | Run completed; `N == 0` is the healthy heartbeat (logs only). `N > 0` fires a single Sentry event with stable fingerprint, still 200 — drift is not an HTTP-level failure. |
| 200 | `{ "message": "Concurrent run skipped" }` | Another run was in flight (table-row mutex) |
| 401 | `{ "error": "Unauthorized" }` | Wrong/missing invoke secret; Sentry `rc_drift_check_auth_fail` fires |
| 500 | `{ "error": "Internal server error" }` | RC API failure, cursor parse failure, or missing env vars; Sentry exception fires |

### Drift Categories

| Tag | Meaning | Sentry level |
|---|---|---|
| `count_missing` (`drift_premium_missing`) | RC says active premium, Supabase says `free` — the 2026-05-12 outage class | `error` |
| `count_stale` (`drift_premium_stale`) | Supabase says `premium`, RC has no active premium | `warning` |
| `count_orphan` (`drift_orphan`) | RC active premium but no Supabase profile matches | `warning` |

Each Sentry event includes up to 50 ids per category in `extra`; full totals are in the tag `count_*` values.

### Concurrency

A table-row mutex on `public.drift_check_runs` (default-deny RLS) prevents overlapping runs. Stale rows (`started_at` > 10 minutes ago with `finished_at IS NULL`) are replaced on the next acquire attempt — there is no background sweeper, but the next 6-hourly cron fire is the heal trigger, so a crashed run can't block the cron for more than one cycle.

### Secrets Required

- `RC_DRIFT_CHECK_INVOKE_SECRET` — bearer that pg_cron uses to invoke this function; must mirror `vault.secrets.rc_drift_check_invoke_secret`
- `REVENUECAT_SECRET_API_KEY` — RC v2 admin key (shared with `delete-account`)
- `REVENUECAT_PROJECT_ID` — RC project id (`proj18594bd9`)

### Tables Written

`drift_check_runs` (mutex only; never touches `profiles`)

See `docs/payments.md` → "Drift Health Check" for the operator runbook, secret rotation, and first-time setup.
105 changes: 105 additions & 0 deletions docs/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,104 @@ Both keys are empty in development builds (`APP_VARIANT=development`), disabling

The webhook is platform-agnostic — RevenueCat normalizes events from both Apple and Google into the same format.

### Drift Health Check

The webhook is the realtime path. The drift health check is the out-of-band backstop for deliveries that never arrive — RC retry-queue expiry, network blip, deploy mid-delivery, or in-band Sentry outages. It runs every 6 hours at `:17` past the hour (UTC), via a `pg_cron` job that POSTs to the `rc-entitlement-drift-check` Edge Function.

**What it does:**

1. Lists every RevenueCat customer for the project (paginated via `starting_after`, 100 per page).
2. Reads `id, entitlement` from every `profiles` row.
3. Classifies each user into one of three drift categories. Healthy users are not reported.
4. If drift > 0, emits **one** Sentry event with a stable fingerprint so consecutive runs collapse into a single issue.

**Drift categories:**

| Category | Meaning | Sentry level |
|---|---|---|
| `drift_premium_missing` | RC says active premium, Supabase says `free`. **This is the 2026-05-12 outage class** — user paid but is locked out. | `error` |
| `drift_premium_stale` | Supabase says `premium`, RC has no active premium (refund/expiration didn't propagate, or a manual grant has no RC backing). | `warning` |
| `drift_orphan` | RC has active premium but no Supabase profile matches. Usually a deleted account whose RC record wasn't cleaned up, but worth eyeballing. | `warning` |

The Sentry event's `extra` payload includes the first 50 affected ids per category. Use the `count_*` tags for full totals.

**Reading the function logs:**

Every run prints one heartbeat to the Edge Function logs regardless of outcome:

```json
{"event":"drift_check_complete","drift_count":0,"count_missing":0,"count_stale":0,"count_orphan":0,"rc_customer_count":63,"supabase_profile_count":189,"run_at":"..."}
```

`mcp__supabase__get_logs --service edge-function` is the fastest way to find it. A missing heartbeat means the cron job didn't run, which is itself a signal worth investigating.

**Operator runbook — drift event fires:**

1. Open the Sentry issue. Look at `tags.count_missing` first; that's the urgent class.
2. Cross-check one affected id with `mcp__revenuecat__get-customer` and `select entitlement from profiles where id = '<id>'`. If they disagree as the event claims, the webhook is the prime suspect — same diagnostic chain as the 2026-05-12 incident.
3. Fix the underlying webhook problem (secret drift, dead-letter, missing event). For acute relief on a specific user, replay the RC event from the dashboard or manually `update profiles set entitlement = 'premium' where id = '<id>'`.
4. Once the next scheduled run logs `drift_count: 0`, **manually resolve the Sentry issue**. The fingerprint is stable, so the issue does not auto-resolve.

**No allowlist policy:** there is intentionally no mechanism to mute a known-drifted user. If a user is drifted, it's a bug. If a beta tester ever needs grandfathered premium without RC backing, fix it by issuing them an RC entitlement (RC supports manual grants); do not add a Supabase-side exception.

**Fate-sharing trade-off:** the drift check runs on Supabase, so a Supabase outage will take down the check at the same time as the webhook it backstops. The alternative — running the check as a GitHub Action — was rejected as too much new infrastructure for a small marginal robustness gain. Revisit if a future incident takes out Supabase scheduling specifically.

**Secret-rotation runbook (`rc_drift_check_invoke_secret`):**

The bearer that pg_cron uses to invoke the Edge Function lives in two places. **Both must change in lockstep** — the 2026-05-12 outage was caused by exactly this kind of multi-location single-secret drift.

```bash
NEW_SECRET=$(openssl rand -hex 32)
supabase secrets set RC_DRIFT_CHECK_INVOKE_SECRET="$NEW_SECRET"
```

Then in the Supabase SQL editor:

```sql
select vault.update_secret(
(select id from vault.secrets where name = 'rc_drift_check_invoke_secret'),
'<NEW_SECRET>'
);

-- VERIFY the vault row actually changed. Vault function signatures have
-- varied across versions, and a silent no-op is exactly the failure mode
-- that motivated this whole feature.
select decrypted_secret = '<NEW_SECRET>' as rotated_ok
from vault.decrypted_secrets
where name = 'rc_drift_check_invoke_secret';
```

If `rotated_ok` is not `true`, the function-env and vault values are now out of sync. Stop, investigate, and re-rotate before the next cron fire.

After both have changed and the vault row is verified, manually fire one run to confirm: `select cron.run_job((select jobid from cron.job where jobname = 'rc-entitlement-drift-check'));` and watch for a 200 + heartbeat in the function logs.

**First-time setup (only once per environment):**

```bash
# 1. Generate and set the function env var
INVOKE_SECRET=$(openssl rand -hex 32)
supabase secrets set RC_DRIFT_CHECK_INVOKE_SECRET="$INVOKE_SECRET"
supabase secrets set REVENUECAT_PROJECT_ID="proj18594bd9"
# REVENUECAT_SECRET_API_KEY is already set (used by delete-account)

# 2. Deploy the function
supabase functions deploy rc-entitlement-drift-check --no-verify-jwt
```

Then in the Supabase SQL editor (Vault is not exposed via the supabase CLI):

```sql
select vault.create_secret(
'<INVOKE_SECRET from step 1>',
'rc_drift_check_invoke_secret',
'Bearer for the rc-entitlement-drift-check Edge Function'
);
```

**Order matters:** apply the migrations **after** the vault secret is created — `supabase db push`. If migrations land first, the cron job schedules immediately and the next `:17` fire concatenates a NULL bearer (the vault subquery returns NULL when the secret row doesn't exist; `'Bearer ' || NULL = NULL` in SQL). The Edge Function then sees either a missing or null Authorization header and returns 401, producing a spurious `rc_drift_check_auth_fail` Sentry event with `reason: missing_secret` until the vault row is created.

The cron job will start firing at the next `:17 mod 6h` UTC mark.

### Paywall

- **Annual-only** subscription at €9.99/year
Expand Down Expand Up @@ -107,13 +205,17 @@ The webhook is platform-agnostic — RevenueCat normalizes events from both Appl

- **Edge Function secrets** (set via dashboard or CLI):
- `REVENUECAT_WEBHOOK_SECRET` — must match the Bearer token configured in RevenueCat webhook settings
- `REVENUECAT_SECRET_API_KEY` — RC v2 REST API key, used by `delete-account` and `rc-entitlement-drift-check`
- `REVENUECAT_PROJECT_ID` — RC project id, used by `rc-entitlement-drift-check`
- `RC_DRIFT_CHECK_INVOKE_SECRET` — Bearer for pg_cron → `rc-entitlement-drift-check`; must mirror `vault.secrets.rc_drift_check_invoke_secret`
- `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` — auto-injected, no manual setup needed
- **Deploy edge functions:**
```bash
supabase functions deploy revenuecat-webhook
supabase functions deploy create-map
supabase functions deploy add-place
supabase functions deploy create-invite
supabase functions deploy rc-entitlement-drift-check
```

### Environment Variables
Expand All @@ -123,6 +225,9 @@ The webhook is platform-agnostic — RevenueCat normalizes events from both Appl
| `EXPO_PUBLIC_REVENUECAT_API_KEY` | `.env` + EAS secrets | RevenueCat Apple API key, read at build time |
| `EXPO_PUBLIC_REVENUECAT_GOOGLE_API_KEY` | `.env` + EAS secrets | RevenueCat Google API key, read at build time |
| `REVENUECAT_WEBHOOK_SECRET` | Supabase Edge Function secrets | Webhook auth, server-side only |
| `REVENUECAT_SECRET_API_KEY` | Supabase Edge Function secrets | RC v2 REST API admin key (`delete-account`, `rc-entitlement-drift-check`) |
| `REVENUECAT_PROJECT_ID` | Supabase Edge Function secrets | RC project id, used by `rc-entitlement-drift-check` |
| `RC_DRIFT_CHECK_INVOKE_SECRET` | Supabase Edge Function secrets **and** Supabase Vault | Bearer that pg_cron uses to invoke `rc-entitlement-drift-check`; rotate in both places together |

---

Expand Down
20 changes: 16 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,22 @@ Freemium limits are enforced server-side in Edge Functions, so there's no client
**Symptom:** Purchase succeeds in RevenueCat dashboard but `profiles.entitlement` stays `free`.

**Check:**
1. Is the webhook URL correct in RevenueCat dashboard? (`https://<ref>.supabase.co/functions/v1/revenuecat-webhook`)
2. Does the Bearer token in RevenueCat match `REVENUECAT_WEBHOOK_SECRET` in Supabase secrets?
3. Is the Edge Function deployed? Check with: `curl -s -o /dev/null -w "%{http_code}" https://<ref>.supabase.co/functions/v1/revenuecat-webhook`
4. Check Supabase Edge Function logs for errors
1. **Sentry first** — search for an open `rc_entitlement_drift` or `revenuecat_webhook_auth_fail` issue. The scheduled drift check (`rc-entitlement-drift-check`, every 6h) is the fastest signal of in-band webhook failure. See `docs/payments.md` → "Drift Health Check" for the operator runbook.
2. Is the webhook URL correct in RevenueCat dashboard? (`https://<ref>.supabase.co/functions/v1/revenuecat-webhook`)
3. Does the Bearer token in RevenueCat match `REVENUECAT_WEBHOOK_SECRET` in Supabase secrets?
4. Is the Edge Function deployed? Check with: `curl -s -o /dev/null -w "%{http_code}" https://<ref>.supabase.co/functions/v1/revenuecat-webhook`
5. Check Supabase Edge Function logs for errors

### Drift Check Alerts Firing

**Symptom:** Sentry shows an open `rc_entitlement_drift` issue tagged `function: rc-entitlement-drift-check`.

**Read the event:** `extra.drift_premium_missing` (highest priority — paid users locked out, same class as the 2026-05-12 outage), `extra.drift_premium_stale` (refund/expiration didn't propagate), and `extra.drift_orphan` (RC has a customer but no Supabase profile matches) list the affected `app_user_id`s. The `count_*` tags carry the full totals; `extra` arrays are capped at 50 ids each.

**Check:**
- Cross-check one id from `drift_premium_missing` against `mcp__revenuecat__get-customer` and `select entitlement from profiles where id = '<id>'`. If they disagree as the event claims, the webhook is the prime suspect — same diagnostic chain as "Webhook Not Updating Entitlement" above.
- For acute relief: replay the RC event from the dashboard, or `update profiles set entitlement = 'premium' where id = '<id>'`.
- The Sentry issue uses a stable fingerprint and **does not auto-resolve**. Once the next 6h run logs `drift_count: 0`, manually resolve the issue.

## Google Places API

Expand Down
Loading