Skip to main content

Invite Process Completion — Design Spec

Date: 2026-07-11 Status: Approved (brainstorming) — pending spec review → writing-plans Supersedes/extends: docs/decisions/2026-07-04-role-governance.md (invite model, decisions 4-5), which established the two-shape / code-entry invite model and deferred delivery. This spec closes the gaps the deep-dive found and promotes delivery + deep links into scope. Source analysis: .superpowers/sdd/invite-process-deep-dive.md (gaps G1–G14, recommendations R1–R8) and docs/decisions/2026-07-11-invite-process-cloud-accept-gap.md.

Goal

Make the member/co-parent invite feature actually work end-to-end in the production (cloud) topology — a real cross-account person can be invited, receive the invite, accept it, and be linked into the household — with the security, hygiene, and durability the feature needs for launch.

Why (the break)

The invite feature is a well-built inviter-side domain model with a broken acceptance leg in cloud. acceptInvite (household_service.dart:535) is a caller-household read-modify-write; under RLS a not-yet-member invitee can neither read the household/invited row nor perform the linking update, and there is no SECURITY DEFINER accept RPC / Edge Function. Production runs DataMode.cloud. Additionally the co-parent invite code is never surfaced (discarded in members_bloc._onSaved), so the flagship "invite Mom" flow dead-ends, and there is no delivery channel. CI missed all of this because every invite test runs against in-memory storage with no RLS.

Decisions (locked in brainstorming)

  1. Acceptance is EMAIL-BOUND. Redeeming a code requires the accepting account's email to match the invite's email (auth.email() == invite.email). The code alone is insufficient — a leaked/forwarded code cannot be redeemed by the wrong person. Right posture for a family-data app; pairs naturally with email delivery.
  2. Invite token is HASH-STORED. The raw 192-bit token is shown to the inviter once at creation/resend and never persisted; only a hash is stored (invite_token_hash). Removes the plaintext-token leak via roster reads (G3) on top of email-binding.
  3. Durable invite EVENT TRAIL. An append-only invite_events log records issue/resend/revoke/accept/expire/delete, so a lost invite is discoverable and re-issuable — the Mom-incident durability fix (G4). We keep the member-row-as-invite model; the event trail gives durability without the larger first-class-invites-table restructure (post-MVP).
  4. JOIN-WITH-CODE for existing accounts is in scope (G5): a "Join a household with a code" surface in Settings, plus the existing Setup-page entry for brand-new accounts. Joining makes the joined household the active one (single-household, newest-first memberForAccount), gated behind a confirmation; a full multi-household switcher stays post-MVP (G12).
  5. DELIVERY is in scope (reverses the prior deferral): the invitee receives the invite by email containing an actionable deep link.
  6. Deep links = Android App Links (production target is an Android app) + web ?invite= param (current web/UAT deployment), unified behind one in-app incoming-link handler. Manual code entry remains the always-available fallback.
  7. Android-release-gated pieces are sequenced, not blocking. No signed Android build/release exists yet (a Google Play account does). The app-side deep-link plumbing + email + web-link work is built now; the domain assetlinks.json (needs the prod signing-cert SHA-256, available after first Play upload / Play App Signing) and the Play Store fallback URL are completed when the Android release lands.

Architecture — two layers, one feature

The invite core (Layer 1) is independent of the delivery/transport (Layer 2). Layer 1 makes the invite work (via code entry); Layer 2 makes it seamless (email + tap-a-link). The plan phases Layer 1 first (unblocks the feature) then Layer 2.

Layer 1 — Invite core

Schema / Postgres (infra/supabase/migrations/):

  • accept_invite(p_token text) — SECURITY DEFINER, pinned search_path='public', revoke execute from public, anon, grant to authenticated. Atomically:
    1. hash p_token, look up the member row by invite_token_hash (unique/partial-unique index);
    2. verify: found · expires_at > now() · target auth_user_id is null (unlinked) · email-bound lower(invite.email) = lower(auth.email()) · the accepting auth.uid() does not already have a member row in that household (already-member guard, G7);
    3. on success: set auth_user_id = auth.uid(), status='active', clear invite_token_hash + expires_at; insert an invite_events accepted row; return the household id;
    4. each failure returns a distinct typed reason (invalid / expired / email_mismatch / already_member / already_linked) mapped by the SDK to a typed exception → specific UI copy (replaces the generic "invalid code", G11).
  • invite_token_hash text column on household_members (replaces plaintext invite_token); migrate existing pending rows (hash or clear) — the only recent live pending invite (Mom) was already deleted, so the back-population set is expected empty/small; verify before applying.
  • invite_events append-only table: id, household_id, member_ref (nullable), email, kind (issued|resent|revoked|accepted|expired|deleted), actor_auth_user_id, created_at, metadata jsonb. RLS: SELECT for members of the household; INSERT only via the service/RPC (definer) path.
  • Legacy {admin} backfill (G9): update household_members set roles='{member}' where status='invited' and roles && array['admin','helper'].
  • Pending-email dedupe (G6): a guard and/or partial unique index (household_id, lower(email)) where status='invited' so a second co-parent invite to the same email resends rather than minting a duplicate placeholder.
  • Server-side expiry enforced inside the RPC (G10); lazy/scheduled cleanup of long-expired placeholders is a small non-blocking follow-up.

SDK (packages/client_sdk):

  • inviteCoParent / inviteMember / resendInvite: generate the raw token, store only invite_token_hash, return the raw token to the caller, write an issued/resent invite_events row. revokeInvite writes revoked (co-parent shape deletes the placeholder row per existing rule; account shape clears invite fields).
  • acceptInvite(rawToken): in cloud mode routes through the accept_invite RPC (pass the raw token; RPC hashes + checks); local/in-memory keeps the direct path but adds the same email-bound + already-member checks for parity. Map RPC typed reasons → typed exceptions.
  • Acting-member gating on the co-parent invite verbs (inviteCoParent/resendInvite/revokeInvite) for dual-gate symmetry (G8, partial — full admin-scoped RLS for invite-column mutations is a fast-follow if the RPC-only mutation path doesn't already cover it).
  • Expose an invite_events read (minimal — supports a future history/re-issue UI; the durability value is the persisted trail; UI can be thin now).

App UI (app/lib/inside):

  • Surface the co-parent code (G2): after inviteCoParent and after resend, show the existing _CodeView share sheet (copy + regenerate + "you won't see this again"). Stop discarding the returned member in members_bloc._onSaved / _onInviteResent.
  • Join-with-code (G5): a "Join a household with a code" entry in Settings/More that calls acceptInvite; keep the Setup-page entry for new accounts. On success route to the joined household; confirm that this becomes the active household (single-household + newest-first).
  • Typed accept errors (G11): map the RPC reasons to specific copy on both surfaces.
  • send-invite Edge Function (infra/supabase/functions/send-invite/, service-role): on invite, email the invitee a link (https://rewhaven.com/invite?token=<raw>) + the code as fallback text. MVP uses Supabase's built-in invite/magic-link email (auth.admin.inviteUserByEmail with redirectTo, or a templated send); a branded mail provider (Resend/SendGrid) is an easy later swap — the Edge Function is the seam.
  • In-app incoming-link handler: the app_links package receives the Android App Link intent (cold-start + warm) and the web ?invite= query param; a single handler extracts the token → routes to the accept flow → auto-submits acceptInvite. Works on web now (UAT) and native on release.
  • AndroidManifest intent-filter for the rewhaven.com/invite path (autoVerify=true).
  • Marketsite repo (rytedesigns/rewhaven-marketsite, Cloudflare Worker): host /.well-known/assetlinks.json and an /invite landing page (app installed → App Link opens the app; not installed → "Get the app" → Play Store, and display the code for manual entry).
  • Android-release-gated (flagged dependency, not blocking Layer 1): publish assetlinks.json with the production signing-cert SHA-256 (from Play App Signing after first upload); the Play Store fallback URL; on-device App Link verification.

Data flow (happy path)

Inviter (admin) → inviteCoParent(email) → raw code shown once + hash stored + issued event → send-invite emails the invitee a rewhaven.com/invite?token=… link → invitee taps → (Android App Link opens the app, or the web app reads ?invite=, or not-installed → Play Store → install → link) → invitee signs in with the invited email → app auto-submits acceptInvite(token)accept_invite RPC (email-bound, atomic) links + activates + clears + writes accepted event + returns household id → app routes into the household.

Error handling

  • Typed reasons end-to-end (RPC reason → SDK typed exception → specific UI copy).
  • Atomic RPC (no partial link); single-use (hash cleared on accept); expiry server-enforced; already-member + email-mismatch rejected with distinct copy.
  • Offline/local mode: direct path with parity checks; delivery/deep-link is a cloud concern.

Security

  • Email-bound acceptance (decision 1). Hash-stored token, not roster-readable (decision 2). SECURITY DEFINER RPC with pinned search_path + revoke execute from anon (house pattern). Legacy {admin} invited rows demoted (G9) so a stale invite can't grant admin. Acting-member gating on invite verbs (G8). Children remain non-invitable (COPPA isAdult guard is the single seam, G13 — unchanged).

Testing

  • The gap that hid the break: a two-auth-identity cloud test — inviter and invitee are different Supabase users under RLS — proving accept works across identities (and is denied for the wrong email / expired / already-member). Load-bearing new coverage.
  • SDK unit: invite/resend/revoke/accept, email-bound accept, hygiene guards, event-trail writes, typed reasons.
  • App flow tests: surface-the-code, join-with-code, typed accept errors, incoming-link handler routes to accept.
  • Live smoke: two-identity accept on the deployed RPC (house style).
  • Edge Function + deep-link handler: unit/integration where feasible; on-device App Link verification is part of the Android-release step.

Out of scope / deferred

  • First-class invites table restructure (the event trail covers durability for now) — post-MVP structural.
  • Full multi-household switcher UX (newest-first + join confirmation stands, G12).
  • Branded mail-provider email (start on Supabase built-in) — easy swap.
  • Native App Link verification (assetlinks with prod cert + Play fallback) — sequenced with the Android release.
  • Child/kid user accounts (COPPA hard gates; isAdult guard is the seam).

Dependencies / coordination

  • Supabase project bgedvvmihygwxhjxlvfu: the accept RPC + invite_events + hash column + backfill migrations (applied live after review, house guard-RPC pattern); the send-invite Edge Function (service-role — deployed, not client-called).
  • Marketsite repo rytedesigns/rewhaven-marketsite: assetlinks.json + /invite landing (separate repo, separate deploy).
  • Android release (Play account exists; build/signing/listing not yet): required to finalize App Link domain verification + store fallback. Layer 1 + Layer 2 app-side/email do not block on it.

Success criteria

A person invited by email can, from a different account/device, receive the invite, accept it (email-bound), and be linked + active in the household — verified by a two-identity cloud test and a live smoke — with the code+manual-entry path working immediately and the tap-a-link path working on web now and on Android once released.