feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# Standalone backend migration
Sauci's standalone Node API, PostgreSQL data plane, worker, and private filesystem media
store are implemented; production cutover is still pending. Hosted Supabase remains only
as the identity provider: the mobile and admin applications obtain a Supabase access token,
and the API verifies that token against the project's public JWKS. Product data, files,
realtime events, webhooks, scheduled work, admin operations, and MCP tools do not use the
Supabase data plane.
## Target runtime
- `api`: Node 20, Hono, Drizzle, PostgreSQL 17
- `worker`: Node 20 process sharing the API's domain modules and PostgreSQL
- `storage`: private filesystem-backed Docker volume with authenticated uploads and
API-issued, expiring HMAC download URLs; the metadata/deletion queue lives in PostgreSQL
- `realtime`: authenticated polling, with optimistic concurrency for collaborative state
- `auth`: hosted Supabase Auth only. A server-only service-role credential is used solely
for the Auth Admin account-deletion endpoint, never for database, Storage, Realtime,
or Functions access.
The API derives every user identifier from the verified bearer token. Mobile-supplied
user IDs are never an authorization boundary.
## Delivery slices
| Slice | Backend | Mobile proof | Status |
|---|---|---|---|
| Identity | profiles, feature interests | bootstrap and interest hook tests; native launch | implemented |
| Couples | create/join/read/leave relationship | pairing and profile state | implemented |
| Catalogue | categories, packs, enabled packs, progress | pack list and toggling | implemented |
| Answers | questions, responses, matches, streaks | swipe/answer/match flows | implemented |
| Chat | messages, reports, deletions, typing/polling | message and typing store tests | implemented |
| Media | avatars, response media, chat media, feedback screenshots, cleanup | upload/view/expiry | implemented |
| Account | settings, notifications, feedback, deletion | settings and account lifecycle | implemented |
| Billing | subscriptions and RevenueCat webhook idempotency | entitlement refresh | implemented |
| Operations | reminders, digests, scheduled releases, moderation and cleanup | worker/integration tests | implemented |
| Admin/MCP | role-scoped management API and audited automation | admin and MCP contract tests | implemented |
## Gate for every slice
1. Add an idempotent standalone PostgreSQL migration and domain repository tests against
a temporary real schema.
2. Add API authorization/validation tests, including a second-user isolation test where
the domain is user-scoped.
3. Move the mobile domain to `apiClient`; hosted Supabase remains reachable only through
the dedicated Auth client.
4. Run focused mobile tests, the complete mobile Jest suite, API tests/typecheck/build,
and a native simulator flow when the slice is user-visible.
5. Do not remove the legacy source or cut production traffic until a stopped-source final
sync, row/file parity checks, and application-level staging flows have passed.
Authenticated native acceptance requires an isolated hosted **non-production** Supabase
Auth project plus an onboarding-complete test user whose Auth subject has a matching profile
in the staging PostgreSQL database. Supply its public URL/anon key to the mobile build and
`E2E_EMAIL`/`E2E_PASSWORD` to Maestro at execution time. Never use a production project,
subscriber, service-role key, or production data-plane credential for local or E2E work.
The credential-free `e2e/maestro/login-screen-smoke.yaml` remains the safe build/login-shell
check when that fixture is unavailable; it is not proof of authenticated API behaviour.
## Deployment sequence
Dokploy staging receives separate persistent PostgreSQL and private-media volumes. The
API migration job must complete before the API starts. Cutover is deliberately later:
1. Export a first production snapshot into staging and compare table counts plus sampled
relational invariants.
2. Exercise the mobile app against staging while it still uses hosted Supabase Auth.
3. Stop product-data writes to the old backend, perform the final delta sync, and repeat
parity and app-level tests.
4. Switch the mobile/API endpoint. Retain the Supabase data plane read-only until rollback
is no longer required; only then reduce the hosted project to the Auth-only footprint.
The executable import, parity, final-sync, and rollback procedure is maintained in
[`docs/operations/backend-cutover.md`](../operations/backend-cutover.md).
+25 -12
View File
@@ -7,29 +7,42 @@ Sauci is an npm workspace coordinated by Turborepo.
| Mobile | Expo / React Native / Expo Router | primary customer product |
| Admin | Vite / React | content and administrative operations |
| Web | Next.js App Router | marketing, policies, redemption |
| Backend | Supabase Postgres/Auth/Realtime/Edge Functions | identity, data, workflows |
| API | Node.js / Hono / PostgreSQL | customer data, authorization, and workflows |
| Worker | Node.js / PostgreSQL-backed jobs | scheduled and asynchronous work |
| Auth | Hosted Supabase Auth | identity issuance and account recovery only |
| MCP | Hono / MCP SDK | authenticated internal administrative tools |
| Shared | TypeScript types | cross-application contracts only |
## Trust boundaries
- Mobile and web use public Supabase credentials and depend on RLS.
- Mobile and web use public Supabase credentials only for Auth. Application data
is available exclusively through the versioned Sauci API.
- The API verifies Supabase access tokens against the hosted public JWKS and
derives user, couple, and administrative authority server-side.
- PostgreSQL and object storage are private infrastructure and are never exposed
directly to clients.
- Administrative access requires both an authenticated identity and role checks.
- Service-role edge functions authenticate and authorize callers before user-data
operations.
- MCP has privileged access and requires `SAUCI_MCP_API_KEY`; absent credentials
must never make a non-local deployment public.
- Local verification uses local Supabase, never a remote project.
- The hosted Auth service-role credential is confined to the API's Auth Admin
account-deletion and identity-enrichment calls; it is not a product-data
credential and is not provided to the worker, MCP, or clients.
- MCP authenticates callers with `SAUCI_MCP_API_KEY`, then calls the standalone
admin API using `SAUCI_ADMIN_API_URL` and a scoped `SAUCI_ADMIN_API_TOKEN`.
It has no database, Supabase data-plane, or storage credentials. The API maps
the service token to an active admin identity and owns authorization and audit
evidence for every mutation.
- Local product-data verification uses only loopback PostgreSQL/API services.
Authenticated native acceptance may use only the designated hosted
non-production Auth project and disposable fixture identities, never production.
## Product flow
Two profiles join a couple, answer questions independently, and the
`submit-response` edge function creates a match when answers are compatible.
Realtime subscriptions surface matches and chat messages to the partner.
Two profiles join a couple, answer questions independently, and an API
transaction creates a match when answers are compatible. Authenticated domain
events surface matches and chat messages to the partner.
## Change boundaries
- Database shape: migration plus generated/shared types plus affected clients.
- Edge function contract: function tests plus calling UI/store behavior.
- Database shape: API-owned migration plus shared contracts and affected clients.
- API contract: integration tests plus calling UI/store behavior.
- Shared contract: update every consumer in the same change.
- UI flow: component/unit coverage and observable E2E evidence.
+55 -14
View File
@@ -1,30 +1,71 @@
# Local full-stack development
Use `scripts/dev-local.sh`; do not start a mixture of local and production-backed
services manually.
Use `scripts/dev-local.sh`; do not start a mixture of local and production data
services manually. The default stack uses conventional PostgreSQL and the Sauci
Node API. It does **not** start local Supabase. Supabase remains the hosted
identity provider, so local application processes need the public Auth settings
described by their environment examples.
| Service | Owner | Port |
|---|---|---:|
| Supabase API / functions | Supabase CLI | 54321 |
| Supabase database | Supabase CLI | 54322 |
| Supabase Studio | Supabase CLI | 54323 |
| PostgreSQL | Docker Compose | 54320 |
| Sauci API | Node / Hono | 3003 |
| Operations worker | Node / Hono domain modules | none |
| Web | Next.js | 3000 |
| Admin | Vite | 3001 |
| MCP | Hono | 3002 |
| Expo Metro | Expo | 8081 |
PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup,
and stores data in a named Docker volume. The local connection string is:
```text
postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
```
Those credentials are for local development only. Deployed environments must
inject unique secrets; they must never consume `infra/backend/.env.example`.
## First run
1. Use Node from `.nvmrc` and run `npm ci`.
2. Install Docker Desktop or Rancher Desktop, tmux, and the Supabase CLI.
3. Copy committed environment examples. Local helpers derive local Supabase
values; they never print or overwrite production credentials.
4. Run `scripts/dev-local.sh reset` to apply migrations and create E2E fixtures.
2. Install Docker Desktop or Rancher Desktop and tmux.
3. Configure the hosted Supabase Auth public URL/key for the mobile application
and the hosted Auth issuer/JWKS settings for the API. Do not configure any
local command with production credentials. Authenticated mobile E2E requires
the designated hosted non-production Auth project and a disposable,
onboarding-complete user whose subject has a matching local profile. Pass
its credentials as `E2E_EMAIL` and `E2E_PASSWORD` only to the Maestro process;
do not store them in an Expo environment file.
To exercise MCP tools, also set a local `ADMIN_API_SERVICE_TOKEN`, its
fixture `ADMIN_API_SERVICE_USER_ID`, and a separate `SAUCI_MCP_API_KEY`.
`scripts/dev-local.sh` passes the service token to MCP as
`SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
credential.
4. Run `scripts/dev-local.sh reset` to recreate only the dedicated local Docker
volume, apply API migrations, and create configured fixtures.
5. Run `scripts/dev-local.sh up`.
`up` is idempotent. `down` stops tmux services and leaves Supabase available;
`down --all` also stops Supabase. Use `status`, `logs <service>`, `restart
<service>`, and `attach` for diagnosis.
`up` is idempotent. `down` stops tmux application processes and leaves the
database available. `down --all` also stops PostgreSQL but retains its volume.
Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis.
`logs postgres` reads Compose logs; other names read their tmux panes.
The `worker` pane owns scheduled jobs, notifications, moderation classification,
and filesystem cleanup; local development disables external AI classification.
The Expo window defaults to Metro. Use `scripts/dev-local.sh ios` or `android` to
launch the native app against the same backend.
`reset` is destructive but fails closed unless the database is the dedicated
loopback instance on port 54320. It removes only the `sauci-local` Compose
volume, starts a fresh healthy database, then runs the API's `db:migrate` and
`db:seed` scripts. `seed` retains the volume and reapplies those two scripts.
The API seed is a no-op unless its documented local fixture identity is set.
The Expo window defaults to Metro with
`EXPO_PUBLIC_API_URL=http://127.0.0.1:3003`. Use `scripts/dev-local.sh ios` or
`android` to launch the native application against the same API. Physical
devices require a reachable host URL rather than loopback.
`e2e/maestro/login-screen-smoke.yaml` verifies the native build and Auth shell
without credentials. `e2e/maestro/login-smoke.yaml` is the authenticated
standalone-API flow and must fail closed unless the non-production fixture above
is explicitly supplied. Neither local nor E2E runs may use the production Auth
project, production subscriber, or production application database.
+133
View File
@@ -0,0 +1,133 @@
# Hosted data-plane cutover
This runbook moves product data and private files from hosted Supabase to the
standalone PostgreSQL and filesystem backend. Supabase Auth remains hosted. The
tool never discovers credentials from project files: operators provide explicit
environment variables for each invocation.
## Safety contract
- Use Node 20.10/npm 10.2 and a freshly migrated target database.
- `SOURCE_DATABASE_URL` and `TARGET_DATABASE_URL` are mandatory and must name
different physical databases. Query-string/search-path differences do not
make one database safe as both source and target.
- Local target hosts are allowed by default. Every remote target hostname must
appear exactly in `MIGRATION_TARGET_HOST_ALLOWLIST` (comma-separated).
- Storage credentials are required only with `--storage` and are never written
to checkpoints or reports. Use a short-lived source service-role credential.
- `--prune` is accepted only with a non-dry-run `--final-sync`, after legacy
product-data writes have been stopped. It deletes target rows absent at source.
- Checkpoint and report files contain IDs/counts but no connection strings. Keep
`.migration-state/` out of source control.
## Tool behavior
`npm run migration:cutover -w @sauci/api -- ...` imports tables in foreign-key
order, preserves common IDs/timestamps, maps legacy-to-target schema differences
by explicit target-column intersection, and upserts on stable primary/business
keys. An initial interrupted run resumes from its atomic JSON checkpoint. A
`--final-sync` always replays every table, ignoring initial-run table checkpoints.
The manifest includes the complete supported legacy product and administrative
set: `couples`, `profiles`, `admin_users`, `master_keys`, `categories`,
`question_packs`, `topics`, `pack_topics`, `questions`, `app_config`,
`ai_config`, `dare_packs`, `dares`, `sent_dares`, `dare_messages`,
`couple_packs`, `responses`, `matches`, `couple_streaks`, `messages`,
`match_archives`, `message_deletions`, `message_reports`,
`notification_preferences`, `feedback`, `subscriptions`,
`revenuecat_webhook_events`, `redemption_codes`, `code_redemptions`,
`feature_interests`, `live_draw_sessions`, `catchup_reminder_tracking`, and
`audit_logs`. A missing source or target table is reported as skipped rather
than silently treated as copied.
Supported storage buckets are `avatars`, `response-media`, `chat-media`, and
`feedback-screenshots`. Objects are downloaded to `MEDIA_ROOT/<bucket>/<name>`
through temporary files and atomic renames. Unsafe paths are rejected. Imported
objects are registered in `media_objects`; object ID, owner, creation time, MIME
type, and byte size are retained. The checkpoint uses object update time and size
so retries do not redownload completed objects.
After each copied **or checkpoint-resumed** object, the importer rewrites legacy
object names, bucket paths, and Supabase public/signed URLs to the canonical
`media:<uuid>` reference. This covers `profiles.avatar_url`,
`messages.media_path`, `feedback.screenshot_url`, and
`responses.response_data.media_path`. Treat any remaining legacy storage URL in
those fields as a failed migration even when file counts match.
Every run writes a JSON report with source/target row counts, copied/skipped/file
counts, target files missing or size-mismatched on disk, and relational checks for
orphaned profiles, responses, matches, messages, and couple mismatches. A final
sync with failed parity exits with status 2.
## Rehearsal and initial copy
1. Create a disposable target from migrations `0000` onward. Back up both target
PostgreSQL and its media volume before every rehearsal.
2. Export credentials into the process environment without echoing or sourcing a
broad application `.env`:
```sh
export SOURCE_DATABASE_URL='postgresql://.../source'
export TARGET_DATABASE_URL='postgresql://.../target'
export MIGRATION_TARGET_HOST_ALLOWLIST='staging-db.internal'
export MIGRATION_CHECKPOINT_FILE='/secure/state/sauci-initial.json'
export MIGRATION_REPORT_FILE='/secure/state/sauci-initial-report.json'
```
3. Inspect the read-only plan:
```sh
npm run migration:cutover -w @sauci/api -- --dry-run
```
4. Run the database copy. To include storage, additionally set `MEDIA_ROOT`,
`SOURCE_STORAGE_URL`, and `SOURCE_STORAGE_SERVICE_ROLE_KEY`, then add
`--storage`:
```sh
npm run migration:cutover -w @sauci/api -- --storage
```
5. Review the report. Differences are permitted during the initial live copy but
every relational check must be zero. Check the four media-reference fields for
legacy URLs/paths. Exercise authenticated mobile flows against staging:
profile/couple, packs, swipe/response/match/streak, chat and media
upload/download, settings, feedback, billing refresh, and deletion. Use only
an isolated hosted non-production Auth project and a disposable test identity
mapped to a staging profile; never use the production subscriber as an E2E
fixture.
## Stopped-source final sync and cutover
1. Announce maintenance and stop every legacy product-data writer: mobile/API
traffic, Supabase Edge Functions, webhooks, cron jobs, and administrative
writes. Supabase Auth may stay available. Record the stop time.
2. Confirm the source is quiescent by taking row/object counts twice several
minutes apart. Do not proceed if they change.
3. Snapshot the standalone database and media volume. Retain the last successful
initial report and checkpoint.
4. Use a new final checkpoint/report path and run exact reconciliation:
```sh
npm run migration:cutover -w @sauci/api -- --final-sync --prune --storage
```
5. Require `parity: true`, identical row/file counts, zero failed/missing files,
zero legacy media references, and zero relational violations. Independently
query high-value counts and test two-user couple isolation and application
behavior.
6. Switch the API/mobile data endpoint, keeping the source data plane read-only.
Monitor API errors, authentication, database saturation, media reads, queues,
webhooks, and the first real subscriber flow.
## Rollback
Rollback is routing, not reverse synchronization. If post-cutover acceptance
fails, stop writes to the standalone backend, capture its database/media snapshot
and logs, route clients back to the still-retained legacy data plane, and re-enable
legacy writers in their dependency order. Data created only after cutover must be
reconciled explicitly before reopening writes; never blindly import it backwards.
Do not delete hosted tables/buckets or downgrade Supabase until the agreed
observation window has passed, backups have been restore-tested, and rollback is
formally closed. Only then remove product-data functions/storage and return the
hosted project to its Auth-only footprint.
@@ -0,0 +1,658 @@
# Google Play content compliance implementation plan
Status: proposed implementation plan; no production changes have been made.
## 1. Outcome and non-negotiable invariants
Sauci keeps its core compatibility mechanic: each partner privately answers the
same preference or boundary prompt and Sauci reveals agreement. The Google Play
build must not supply explicit sexual acts, sexual instructions, fetish content,
or other developer-authored material intended for sexual gratification.
The implementation is complete only when all of these are true:
1. Google Play clients can receive only content explicitly reviewed as allowed.
2. `unreviewed` is treated exactly like `blocked` for Google Play.
3. A pack, its category, and the individual question must all be allowed before
the question can be returned.
4. `is_explicit`, intensity, premium access, and `hide_nsfw` remain product
controls; none of them can override the Google Play boundary.
5. Missing, invalid, old, or unknown client-distribution metadata resolves to
the Google Play-safe policy.
6. Filtering applies to catalogue browsing, swipe selection, direct links,
pending questions, answers, matches, chat headers, progress, search, counts,
realtime payloads, notification destinations, and write endpoints.
7. Historical responses and matches are retained. They are not disclosed in a
Google Play response when their source content is blocked.
8. New developer-authored content defaults to `unreviewed` and cannot reach the
Google Play build until a recorded review allows it.
9. iOS may retain the broader catalogue through the API; legacy direct database
access is always restricted to the safe catalogue.
10. Couple isolation, premium rules, role checks, and RLS remain enforced.
This is a distribution policy, not an age gate. An 18+ confirmation or content
toggle does not make otherwise prohibited developer-authored content eligible.
## 2. Content model
### 2.1 Classification used by engineering
Each category, question pack, question, dare pack, and dare has a Google Play
review status:
| Status | Google Play behaviour | Meaning |
|---|---|---|
| `unreviewed` | hidden and non-actionable | no affirmative decision exists |
| `allowed` | eligible, subject to parent visibility and access rules | manually reviewed against the Play policy |
| `blocked` | hidden and non-actionable | explicit, sexual-gratification, fetish, instructional, or otherwise unsuitable |
An allowed question should describe a non-graphic preference, relationship
dynamic, comfort level, romantic interest, or boundary. It must not name or
instruct a sexual act, explicit anatomy, sex toy use, nudity as stimulation,
public sexual activity, or coercive/predatory scenario.
Examples of the intended retained mechanic:
- "Would you enjoy taking the lead more often?"
- "Would you like your partner to surprise you with a private date idea?"
- "Are you curious about exploring a new dynamic together?"
- "Would you be comfortable sharing a fantasy in your own words?"
The last example lets the users supply the final detail. The app must present it
as private couple communication, not solicit a named sexual act or provide an
explicit example. User-authored fields still need reporting/blocking, terms,
rate limits, and safe notification previews.
### 2.2 Initial catalogue decision
The first production review is ID-based, not keyword-based. Keyword scans can
queue content for review but must never auto-allow it.
Immediately block the exact questions shown in the rejection evidence and all
developer-authored explicit acts/instructions. Use a conservative first pass:
- Block whole packs pending line review: All Eyes On Us, Boundary Discovery,
Dare to Play, Deep in Character, Dream Scenarios, Fantasy Scenarios, Intimate
Games, Kink Discovery, Know Your Limits, Leveling Up Together, Pleasure
Experiments, Position Exploration, Private Line, Pushing Limits, Role Play
Ideas, Secret Desires, Sensory Play, Sensual Touch, Starting Out, Tease &
Tension, The Deep End, and Total Control.
- Line-review the apparently safer relationship packs before allowing them;
benign pack names do not prove every contained question is safe.
- Line-review mixed packs individually: Missing You, Testing the Waters,
Romantic Gestures, First Impressions, Body Mapping, Love Notes, Sensual
Massage, and Date Night Sparks.
- Treat every dare and dare pack as `unreviewed` until the Android dare feature
is separately reviewed. The current coming-soon copy must also be rewritten
to neutral relationship language.
Do not delete or rewrite the iOS catalogue in place. Classification separates
distribution while preserving subscriptions, IDs, responses, and match history.
## 3. Database changes
Create one canonical Supabase migration with:
```sh
npm run db:migration:new -- add_google_play_content_policy
```
The migration lives only in `apps/supabase/migrations/`. The untracked Drizzle
work under `apps/api/drizzle/` must not become an independent schema authority;
update `apps/api/src/db/schema.ts` to mirror the canonical schema after the
Supabase migration exists.
There is a repository-level sequencing conflict to resolve in the implementation
PR: the root `AGENTS.md` makes Supabase migrations canonical, while the current
uncommitted `docs/architecture/backend-migration.md` describes API-owned
standalone PostgreSQL migrations. This plan follows the root instruction. Update
the architecture decision before changing that ownership rule; do not quietly
create the compliance schema twice with divergent migrations.
The compliance fields must exist in whichever database is authoritative at the
time of release. If the standalone-backend migration has not cut over, enforce
them in production Supabase first and carry the values through the controlled
data migration. If the standalone backend cuts over first, import and verify the
same statuses there and leave legacy Supabase fail-closed/read-only. Never let
old clients write Supabase while new clients write a standalone database; that
would split responses and matches between two sources of truth.
### 3.1 Types and columns
Add:
```sql
create type google_play_review_status as enum
('unreviewed', 'allowed', 'blocked');
alter table categories add column google_play_status
google_play_review_status not null default 'unreviewed';
alter table question_packs add column google_play_status
google_play_review_status not null default 'unreviewed';
alter table questions add column google_play_status
google_play_review_status not null default 'unreviewed';
alter table dare_packs add column google_play_status
google_play_review_status not null default 'unreviewed';
alter table dares add column google_play_status
google_play_review_status not null default 'unreviewed';
```
Add the same review metadata to all five tables:
- `google_play_review_reason text`
- `google_play_reviewed_at timestamptz`
- `google_play_reviewed_by uuid references auth.users(id) on delete set null`
Add `catalog_policy_revision integer not null default 1` and
`force_safe_catalog boolean not null default true` to the singleton
`app_config`. Increment the revision whenever classifications or eligibility
rules change. `force_safe_catalog` is the emergency kill switch; when true all
channels receive the Play-safe catalogue.
Add indexes used by all disclosure paths:
```sql
create index categories_google_play_catalog_idx
on categories (google_play_status, is_public, sort_order);
create index question_packs_google_play_catalog_idx
on question_packs (google_play_status, is_public, category_id, sort_order);
create index questions_google_play_catalog_idx
on questions (pack_id, google_play_status, intensity)
where deleted_at is null;
create index dare_packs_google_play_catalog_idx
on dare_packs (google_play_status, is_public, category_id, sort_order);
create index dares_google_play_catalog_idx
on dares (pack_id, google_play_status, intensity);
```
### 3.2 Durable review audit
Add an append-only `content_distribution_reviews` table:
- `id uuid primary key default gen_random_uuid()`
- `entity_type text` checked to the five supported table types
- `entity_id uuid not null`
- `distribution text not null default 'google_play'`
- `previous_status google_play_review_status not null`
- `new_status google_play_review_status not null`
- `reason text not null`
- `reviewed_by uuid references auth.users(id) on delete set null`
- `created_at timestamptz not null default now()`
Only super admins can read it. Clients cannot insert, update, or delete it.
Use database triggers on the five content tables to:
- reject a status change without a non-blank reason;
- set `reviewed_at` and `reviewed_by` from database time and `auth.uid()`;
- append the old/new decision to `content_distribution_reviews`;
- increment `app_config.catalog_policy_revision` after a real status change.
This replaces reliance on the admin's current best-effort, client-side audit
call for compliance decisions. Existing `audit_logs` remain useful for normal
CRUD history.
### 3.3 Eligibility helpers
Add stable SQL helpers used consistently by RPCs and tests:
- `is_google_play_category_allowed(category_id uuid)`
- `is_google_play_pack_allowed(pack_id uuid)`
- `is_google_play_question_allowed(question_id uuid)`
- dare equivalents
A question is eligible only when it is active, its own status is `allowed`, its
pack is public and `allowed`, and its optional category is public and `allowed`.
Premium and couple-pack rules are applied in addition to this predicate.
### 3.4 Legacy-client fail-closed boundary
Old builds query the public Supabase tables and RPCs directly and send no trusted
distribution context. Change public/authenticated SELECT policies so non-admin
direct access exposes only Google Play-allowed rows. Preserve the existing admin
policy as a separate permissive policy.
Specifically drop every overlapping non-admin visibility policy (including the
current public-pack and premium-pack policies) and replace the broad policies
for `categories`, `question_packs`, `questions`, `dare_packs`, and `dares`. The
question policy must include
`deleted_at is null`, question status, parent pack status/public access, category
status/public access, premium access, and existing gender rules where relevant.
Recreate every SECURITY DEFINER content RPC with the same fail-closed predicate,
including at minimum:
- `get_recommended_questions`
- `get_answer_gap_status`
- `get_daily_response_limit_status` if its counts are presented as catalogue progress
- pack statistics/progress functions
- dare catalogue and statistics functions
`get_recommended_questions(target_pack_id)` must reject a blocked/unreviewed
explicit target pack rather than trusting the supplied UUID. It must also filter
the individual question and exclude soft-deleted questions.
Because existing iOS builds also omit distribution metadata, this cutover will
temporarily give old iOS builds the safe catalogue. That is unavoidable without
leaving old Android builds able to retrieve blocked content. Updated iOS builds
restore the broader catalogue through the private API.
### 3.5 Write enforcement
Reads alone are insufficient. A user who retained an old question UUID must not
be able to submit or change a response to it from the Google Play/default-safe
channel.
Move submission/update into the API and enforce the resolved distribution before
writing. During the legacy window, update `submit-response` and
`update-response` edge functions so missing/unknown channel means Play-safe and
blocked IDs return `404 content_not_available` (not text or policy details).
The same rule applies to:
- enabling a pack in `couple_packs`;
- creating a sent dare from a developer-authored dare;
- retrieving or mutating a historical response;
- opening a match/chat tied to blocked developer content.
Keep existing rows. Do not cascade-delete responses, matches, messages, or media.
### 3.6 Device records for notification safety
Replace the single `profiles.push_token` as the long-term delivery model with
`user_devices`:
- `id uuid primary key`
- `user_id uuid not null references profiles(id) on delete cascade`
- `expo_push_token text not null unique`
- `platform text` checked to `ios` or `android`
- `distribution text` checked to `apple_app_store`, `google_play`, or `unknown`
- `app_version text`, `build_number integer`
- `catalog_policy_revision integer not null`
- `last_seen_at`, `created_at`, `updated_at`
- `disabled_at timestamptz`
RLS permits a user to manage only their own device rows. Backend workers read
them privately. Unknown devices get safe counts and safe destinations. Keep
`profiles.push_token` during one release as a compatibility fallback, then stop
writing it and remove it in a later migration after delivery metrics confirm the
cutover.
## 4. API changes
The new API is the only long-term product-data boundary. The current pack-domain
work is incomplete and not registered by `apps/api/src/app.ts`; extend that work
rather than creating another parallel path.
### 4.1 Request context
Update `apps/mobile/src/lib/apiClient.ts` to send:
- `X-Sauci-Platform: ios|android`
- `X-Sauci-Distribution: apple_app_store|google_play`
- `X-Sauci-App-Version`
- `X-Sauci-Build-Number`
- `X-Sauci-Catalog-Policy-Revision`
Use Expo Constants and `Platform.OS`, not environment-specific user choices.
Add a typed API middleware/request context in `apps/api/src/app.ts` that parses
these headers once. Invalid, contradictory, absent, or future values resolve to
`google_play`; they never resolve to the broader catalogue. The distribution
header is a policy routing signal, not an authentication or authorization
credential. Add App Attest/Play Integrity later if resistance to platform
spoofing becomes a product requirement.
When `force_safe_catalog` is true, the resolved policy is Google Play-safe for
every request. Include the current policy revision in every catalogue response.
### 4.2 Required API surface
Implement and register authenticated routes for every product-content path:
| Route | Required behaviour |
|---|---|
| `GET /v1/catalog` | eligible categories and packs; question counts count only eligible questions |
| `GET /v1/packs/:id` | eligible metadata and preview questions; blocked IDs return 404 |
| `GET /v1/me/enabled-packs` | omit blocked pack IDs without deleting couple settings |
| `PUT /v1/me/enabled-packs/:id` | reject an ineligible pack |
| `GET /v1/me/pack-progress` | denominator and answered count use only eligible questions |
| `GET /v1/questions/recommended` | preserve partner/gender/intensity/premium logic plus policy filter |
| `GET /v1/questions/pending` | filter both partner response and joined question |
| `POST /v1/responses` | validate eligibility in the same transaction as the write/match calculation |
| `PATCH /v1/responses/:id` | validate ownership and current eligibility |
| `GET /v1/me/responses` | omit blocked history and its question/pack text |
| `GET /v1/matches` | omit blocked matches and exclude them from badges/counts |
| `GET /v1/matches/:id` | blocked IDs return 404 before returning question, summary, messages, or media URLs |
| dare routes | same parent/item/read/write enforcement; do not expose until reviewed |
| `PUT/DELETE /v1/me/devices/:token` | register/disable device metadata |
All repository queries take a required `ContentPolicy` argument. Do not accept a
boolean such as `showAdult`; use a closed server type such as
`'google_play_safe' | 'full_catalog'`. Centralize the SQL predicate so route
authors cannot forget it. Repository tests must fail if no policy is supplied.
Do not return hidden text in error bodies, logs, analytics, or tracing tags.
### 4.3 Transaction and isolation rules
- Derive `user_id` from the verified JWT, never request JSON.
- Derive `couple_id` from the authenticated profile.
- On submission, lock/read the eligible question and existing responses inside
one transaction, then upsert the response and match.
- Every match/message query checks the authenticated user's current couple.
- Pack toggles use the authenticated couple and an eligible pack in one query.
- Preserve current premium, answer-gap, daily-limit, gender-targeting, inverse,
and question-type behaviour while adding the distribution predicate.
## 5. Mobile changes
### 5.1 Remove content reads from `legacyDataClient`
Migrate these exact surfaces to typed API methods:
- `src/store/packsStore.ts`: catalogue, enabled packs, toggles, progress.
- `src/features/swipe/services/swipeService.ts`: pack context, recommended and
pending questions, status checks, and response submission.
- `app/pack/[id].tsx`: pack/question preview and direct-link handling.
- `src/store/matchStore.ts`: all match, pending, their-turn, response, and nudge
context queries.
- `src/store/responsesStore.ts`: My Answers, pagination, counts, edits.
- `src/features/chat/ChatScreen.tsx`: match/question header and initial access.
- `app/(app)/_layout.tsx`: realtime match hydration and toast copy.
- future dare stores/screens and all content search surfaces.
Auth may continue to use `authClient`. Realtime can temporarily use Supabase only
as an invalidation signal containing IDs; the app must refetch the visible DTO
from the API before rendering it. Never render embedded realtime question text.
### 5.2 Typed DTOs and safe UI states
Add shared types and Zod schemas in `packages/shared`, exported through
`src/index.ts`, for request context, catalogue, questions, responses, matches,
device registration, `content_not_available`, and policy revision.
Blocked/stale deep links should navigate to a neutral screen: "This item is no
longer available in this version of Sauci." Do not display the old title or
question. The same neutral state is used when an item becomes blocked while the
app is open.
On policy revision change or app resume:
- cancel in-flight catalogue requests;
- clear packs, swipe queue, pending, matches, response-history, and chat-header
stores;
- clear React Query content keys;
- discard any current card before refetching;
- retain only IDs needed for locally skipped questions; no question text is
persisted.
Add a response interceptor for `404 content_not_available` and policy revision
mismatch so every screen gets the same clearing behaviour.
### 5.3 Copy and settings
- Remove the Android-visible promise of explicit content from onboarding,
settings, subscription paywalls, empty states, analytics screen names, and the
dares coming-soon page.
- Keep `hide_nsfw`/intensity for iOS product preferences, but do not show a
control on Android that implies blocked adult content can be unlocked.
- Describe the Android value as private compatibility, curiosity, boundaries,
relationship dynamics, and user-led conversation.
- Never seed an explicit example or placeholder into a free-text field.
### 5.4 Private user-authored content
If Sauci adds "write your own" prompts to preserve the final jump:
- it is a separate question type and database record, visibly labelled as
written by the partner rather than Sauci;
- content is private to the couple and not discoverable, searchable, or reused;
- notifications say only that a partner shared a private prompt;
- provide report, delete, block-partner/leave-couple, and safety help actions;
- rate-limit creation and validate length/media type;
- do not generate, complete, recommend, categorize, or provide explicit starter
text;
- include UGC moderation and enforcement terms before enabling it on Android.
This feature should be a later submission, not mixed into the remediation build.
## 6. Admin changes
Update Categories, Packs, Questions, Dare Packs, and Dares pages/services to add:
- Google Play status badge and reason;
- filters for unreviewed/allowed/blocked and "parent blocks child";
- single and bulk review actions available only to `super_admin`;
- required reason and confirmation showing affected question counts;
- a queue sorted with rejection-evidence items first;
- a read-only review history panel from `content_distribution_reviews`;
- dashboard counts and a hard warning when any public item is unreviewed;
- CSV export/import by immutable ID for offline line review, with dry-run diff;
- publish/scheduled-release validation that does not imply Play eligibility.
Review inverse question pairs together. A transaction-backed admin API/RPC must
change both sides atomically and reject mismatched pair status. Do not add a hard
constraint until the existing inverse data has been audited and repaired, since
legacy pairs may not currently be reciprocal.
Creating or materially editing text resets that item to `unreviewed`, clears its
review metadata, and increments the policy revision. Editing pack/category text
also resets that parent. Reordering, icon/color changes, and pricing changes do
not reset status unless visible copy changes.
Update `AUDITED_TABLES` to include dare tables and the new review table, while
keeping the database review-event trigger as the authoritative compliance log.
## 7. Notifications, jobs, and cached disclosure
Update all notification workers and scheduled functions:
- `send-notification`
- `send-message-notification`
- `send-match-notification-digest`
- `send-partner-activity-notification`
- `send-catchup-reminder`
- `send-weekly-summary`
- future dare notification functions
Counts for a Google Play/unknown device include only eligible source questions.
Never include question, pack, dare, response, or message text in a push preview.
A tap may carry an opaque match ID, but the target route must re-authorize and
re-evaluate eligibility. Suppress a push when its only destination would be a
blocked item.
Realtime payloads are invalidations, not renderable content. Clear current
content on sign-out, relationship deletion, policy revision, and distribution
change. Verify SecureStore/AsyncStorage migrations do not contain prompt text.
## 8. Migration and release sequence
### Phase A - content freeze and evidence
1. Freeze developer-authored pack/question/dare publishing.
2. Export immutable IDs, visible copy, pack/category, intensity, inverse ID,
public/premium flags, and response/match counts from production.
3. Save the exact rejection screenshots and map every shown card to its question
ID. Do not store subscriber answers or message content in the review file.
4. Produce an allow/block CSV reviewed by a human; unresolved rows remain
`unreviewed`.
5. Capture before-state counts by status, pack, responses, and matches.
### Phase B - schema and API locally
1. Create the canonical migration and seed the reviewed ID decisions in the same
migration or a separate explicit data migration. Never classify by mutable
pack name in production SQL.
2. Update generated Supabase types and the Drizzle schema mirror.
3. Implement API request context, repositories, routes, device registration,
and policy-aware notification queries.
4. Update edge functions and legacy RPCs for fail-closed compatibility.
5. Reset local Supabase and run SQL/RLS/function tests.
### Phase C - mobile and admin
1. Move all listed mobile content paths to the API.
2. Add revision-driven cache clearing and neutral unavailable states.
3. Build admin review queue, atomic bulk review, audit view, and release guards.
4. Rewrite Android-visible sexualized marketing/UI copy.
5. Complete content review; no public Play item remains `unreviewed`.
### Phase D - controlled production cutover
1. Declare and record the one authoritative product database for this release.
If the standalone migration is incomplete, do not mix its writes with live
Supabase writes; perform the compliance cutover on the current source first.
2. Deploy additive schema and API with `force_safe_catalog=true`.
3. Read back schema, seeded decisions, RLS definitions, status counts, and a
sample of each endpoint. Do not expose text in deployment logs.
4. Ship the API-based iOS update first and verify the full-catalogue path. Old
iOS builds may remain safe-only after the RLS cutover.
5. Enable the legacy RLS/RPC safe boundary so every old Android build becomes
safe, then independently test production with a legacy token/build.
6. Deploy Android version code greater than 45 to internal testing. Verify a new
user and an existing production-like user with historical adult responses.
7. Keep the emergency kill switch on for the first Play review. A later,
separately verified change may allow the full iOS policy while Android stays
safe.
### Phase E - Play submission
1. Deactivate superseded non-compliant bundles from every active testing track
that is included in review.
2. Complete the content-rating questionnaire from the remediated binary and
catalogue, not the previous app. Do not under-declare remaining content.
3. Provide working reviewer credentials and deterministic navigation notes.
4. In the submission note, state that the cited Fantasy Scenarios, Sensual
Touch, and Pushing Limits content is unavailable in the Android build and
that new Android content is manually allowlisted.
5. Attach concise evidence from the exhaustive Android crawl. Do not argue that
competitor apps prove compliance.
6. Submit one clean remediation build; do not repeatedly test policy boundaries
with incremental explicit variants.
## 9. Verification matrix
### Database
- Fresh local reset applies every migration.
- New content defaults to `unreviewed`.
- Non-admin direct SELECT cannot see unreviewed/blocked child or parent rows.
- Admin can see all rows without weakening couple/user RLS.
- Direct target-pack RPC calls cannot retrieve a blocked pack.
- Soft-deleted questions never appear.
- Status changes require a reason, record actor/time, append one audit event,
and increment policy revision.
- Content-text edits reset status; metadata-only edits behave as specified.
- Existing response/match/message row counts are unchanged by classification.
### API
- Android, missing headers, invalid headers, and unknown future builds all get
exactly the safe allowlist.
- iOS gets the intended policy only when the kill switch permits it.
- Every catalogue count equals its filtered children.
- A known blocked UUID returns 404 on pack, question, response, match, chat,
toggle, and dare endpoints.
- Pending, answer gap, daily limit, badges, and progress exclude blocked items.
- Couple A cannot infer IDs, counts, responses, matches, or devices for Couple B.
- Premium cannot bypass distribution status.
- Logs/errors never contain blocked text.
### Mobile Android
- Fresh install, upgrade from version 45, existing subscriber, non-subscriber,
paired/unpaired, and user with historical adult matches.
- Browse every category/pack/card; test direct links and notification taps.
- Test Matches tabs, My Answers, chat header, realtime toasts, search, empty
states, settings, onboarding, subscription screens, and dare interest page.
- Change policy revision while a blocked card is on screen; it disappears before
any further render/action.
- Attempt traffic replay with a blocked UUID; UI shows only the neutral state.
- Automated screenshot/text crawl contains none of the rejection evidence or
disallowed developer-authored acts.
### iOS regression
- The API-based iOS build retains the intended broader catalogue, subscriptions,
answers, matches, chats, inverse text, and premium behaviour.
- Android classifications do not delete or rewrite iOS data.
Run the repository gates in order: focused unit/integration tests,
`npm run verify:fast`, local database reset/RLS tests, API E2E, native Android
verification, then `npm run verify:full`. A missing emulator, database, or
credential is reported as an environment limitation, not a pass.
## 10. Observability and rollback
Emit metrics without content text:
- catalogue responses by resolved policy/build;
- count of allowed/blocked/unreviewed entities;
- blocked-ID read/write attempts by route;
- policy revision adoption by active device;
- notification suppressions by reason;
- legacy direct-client usage and API migration percentage.
Add a nightly invariant check that fails if any Google Play API response could
join a non-allowed entity, if an allowed child has a blocked parent, or if an
allowed inverse pair is inconsistent. Alert rather than auto-classifying.
Rollback is configuration-first: set `force_safe_catalog=true`, invalidate
caches by incrementing the policy revision, and disable affected API routes. Do
not roll back the additive schema or restore broad direct-table RLS. Content
decisions can be reverted only through a new audited review event.
## 11. Concrete file checklist
Database and functions:
- new `apps/supabase/migrations/*_add_google_play_content_policy.sql`
- `apps/supabase/functions/submit-response/index.ts`
- `apps/supabase/functions/update-response/index.ts`
- all notification/catch-up/summary functions listed above
- regenerated `packages/shared/src/types/supabase.ts`
- regenerated `apps/mobile/src/types/supabase.ts` if it remains during migration
API and shared contracts:
- `apps/api/src/app.ts`
- new request-context/content-policy modules
- `apps/api/src/db/schema.ts`
- `apps/api/src/domains/packs/{repository,routes,schema}.ts`
- new questions, responses, matches, devices, notifications, and dares domains
- route/repository/integration tests for every domain
- `packages/shared/src/types/index.ts` plus domain-specific schema/type modules
Mobile:
- `apps/mobile/src/lib/apiClient.ts`
- stores/services/screens listed in section 5.1
- `apps/mobile/src/lib/notifications.ts`
- `apps/mobile/app/_layout.tsx` and `apps/mobile/app/(app)/_layout.tsx`
- new content-policy/cache reset module and tests
- Android-visible onboarding/settings/paywall/dare copy
Admin:
- content pages and services for categories, packs, questions, dare packs, dares
- `apps/admin/src/hooks/useAuditedSupabase.ts`
- new review queue/history/bulk-confirmation components and tests
- dashboard compliance counts
Documentation:
- `docs/architecture/system.md` for the enforced API boundary
- `docs/question-selection.md` for distribution-aware selection
- `docs/match-notifications.md` for device-aware safe delivery
- `docs/schema.md` for review/device tables
- `docs/releasing.md` for the Android compliance gate
- `docs/testing/strategy.md` for the distribution matrix
- `docs/index.md` linking this maintained plan while remediation is active
## 12. Definition of done
The remediation is not done when the three cited packs disappear from the pack
screen. It is done when a version-45 client, the new Android binary, direct
Supabase requests, guessed IDs, history, matches, chat, realtime, and push paths
all fail closed; the audited allowlist is complete; iOS data is preserved; and
the end-to-end Android crawl proves no disallowed developer-authored content can
be reached.
+4 -4
View File
@@ -4,9 +4,9 @@ Promotional codes are redeemed only through `apps/web/app/redeem/page.tsx`.
Do not add redemption UI or a redemption call to the mobile app: mobile premium
access is governed by the platform purchase/RevenueCat flow.
The public web form calls `redeem-code`, which delegates the atomic validation and
grant to `redeem_code_by_email`. The database boundary validates active state,
expiry, remaining uses, and prior redemption before changing premium status.
The public web form calls `POST /public/v1/redemptions` on the standalone API.
The Node/Postgres boundary validates active state, expiry, remaining uses, and
prior redemption in one transaction before changing premium status.
Changes require web E2E coverage for validation and success/error states plus a
Supabase integration assertion for the resulting profile and redemption record.
Postgres integration assertion for the resulting profile and redemption record.