Skip to main content

Invite Process Completion Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the member/co-parent invite feature work end-to-end in the production cloud (RLS) topology — a real cross-account person can be invited, receive the code, accept it (email-bound), and be linked + active in the household — with hash-stored tokens, an atomic SECURITY DEFINER accept RPC, a durable event trail, and the load-bearing two-identity coverage that would have caught the original break.

Architecture: Two layers. Layer 1 (invite core) makes the invite work via manual code entry: a Postgres accept_invite SECURITY DEFINER RPC + invite_token_hash column + invite_events trail + hygiene backfill, an SDK that hash-stores tokens / returns the raw once / routes accept through the RPC in cloud (with a local parity twin), and the app surfaces the co-parent code + a Join-with-code surface + typed accept errors. Layer 1 is independently shippable and ends with a live two-identity accept smoke. Layer 2 (delivery) adds email + Android App Links + web ?invite= deep-linking on top; manual code entry remains the always-available fallback.

Tech Stack: Dart 3.9 / Flutter 3.44 (FVM-pinned), flutter_bloc, auto_route ^10, crypto (new), Supabase Postgres + PostgREST + Edge Functions (Deno), pgcrypto. Single Dart pub workspace (one lockfile).

Global Constraints

Every task's requirements implicitly include this section. These are binding rules copied from the spec:

  • One data path: Bloc → Repository → Client (facade) → Service → Adapter. Domain rules live in the SDK Service (or its SECURITY DEFINER cloud twin); repositories are thin delegates.
  • Dual gate: every invariant enforced in the RPC/service AND in RLS/schema.
  • viewingAs is presentation-only — never an authorization principal; the actor is always the authenticated member, never a lens/heuristic.
  • Email-bound accept: redeeming a code requires auth.email() == invite.email (case-insensitive).
  • Hash-store token: the raw 192-bit token is shown to the inviter once and never persisted; only its SHA-256 hash is stored (invite_token_hash).
  • Actor = authenticated member, never a lens/heuristic.
  • SECURITY DEFINER house pattern: pinned search_path='public', revoke execute ... from public, anon; grant only what the caller needs. Trigger/guard functions are revoked from all roles; the accept_invite RPC is granted to authenticated.
  • Anon/publishable key only. Supabase project bgedvvmihygwxhjxlvfu. Service-role key never ships in the app.
  • Migrations are file-only — the controller applies them live after review (never mcp apply_migration from a task). Each migration carries LIVE SMOKE PROBES in a trailing comment block (house style).
  • Children are never invitable — the COPPA isAdult guard (MemberKind.isAdult) is the single seam; unchanged.
  • Explicit git add <paths> — never git add -A/.; never stage graphify-out/, .superpowers/, or .claude/.
  • FVM: run fvm flutter ... / fvm dart ... (not bare flutter/dart).
  • graphify-first: for any codebase question run graphify query "<q>" before grepping/reading raw source. After modifying code run graphify update . (do NOT stage graphify-out/).
  • Suite baselines must not drop: app 545 tests, SDK 1037 tests (re-derive by reading the All tests passed / N passed summary — these are narrative counts from .superpowers/sdd/progress.md, not asserted by a script). New tasks only ADD tests.

Layer ordering & shippability

LAYER 1 (L1-T1 … L1-T10) is independently shippable. After L1-T10 the invite works cross-account via manual code entry: inviter sees the code, invitee signs in with the invited email and pastes the code on Setup or in More → Join-with-code, the accept_invite RPC links them atomically under RLS, verified by a live two-identity smoke. LAYER 2 (L2-T1 … L2-T5) builds email delivery + deep links on top and does not block Layer 1.

Migration apply order (by filename — the controller applies in this order at L1-T10):

  1. 20260711000100_member_invite_token_hash.sql (L1-T2)
  2. 20260711000200_invite_events.sql (L1-T3)
  3. 20260711000300_invite_hygiene_backfill.sql (L1-T4)
  4. 20260711000400_accept_invite_rpc.sql (L1-T1) — references the hash column + invite_events, so it applies last.

Task authoring order below is pedagogical (RPC first); file timestamps guarantee correct apply order regardless.


LAYER 1 — Invite core (independently shippable)

Task L1-T1: accept_invite SECURITY DEFINER RPC (migration file)

Files:

  • Create: infra/supabase/migrations/20260711000400_accept_invite_rpc.sql

Interfaces:

  • Consumes: the invite_token_hash column (L1-T2), the invite_events table (L1-T3), RLS helpers member_household_ids() / parental_household_ids() (20260612000001_households.sql).
  • Produces: Postgres function public.accept_invite(p_token text) returns jsonb. Returns {"ok": true, "household_id": "<uuid>"} on success, or {"ok": false, "reason": "<reason>"} where reason ∈ invalid | expired | already_linked | email_mismatch | already_member. The SDK (L1-T6) reads this jsonb. It does NOT raise for domain-expected failures — the house MappingPort collapses every raise (SQLSTATE P0001) to a generic StorageFailure, so distinct reasons must be RETURNED, not raised.

This task ships a migration FILE only (no local DB to run against). Its verification is (a) the embedded LIVE SMOKE PROBES the controller runs after apply, and (b) the load-bearing live two-identity test in L1-T7. There is no local red/green cycle for the SQL itself.

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260711000400_accept_invite_rpc.sql:

-- Invite acceptance — the cloud-executable accept leg (spec: Layer 1, G1).
-- acceptInvite was a caller-household read-modify-write; under RLS a not-yet-
-- member invitee can neither read the invited row nor perform the linking
-- update. This SECURITY DEFINER RPC is the definer-eyes atomic accept: hash the
-- presented token, look the invited member up by hash, verify (found, unexpired,
-- unlinked, email-bound, not already a member), then atomically link+activate+
-- clear and append an `accepted` invite_events row. Returns the household id.
--
-- House pattern: pinned search_path='public', revoke execute from public/anon.
-- BUT unlike the consent/financial guard TRIGGERS (revoked from all), this is an
-- RPC the client calls, so it is GRANTED to `authenticated`.
--
-- DISTINCT TYPED REASONS: the SDK MappingPort translates PostgrestException by
-- CODE only (a plain `raise` is P0001 -> generic StorageFailure) and never
-- surfaces message text. So domain-expected failures are RETURNED as jsonb
-- {ok:false, reason:...}, NOT raised — the SDK reads `reason` and maps it to a
-- typed InviteAcceptException the UI turns into specific copy.
--
-- sha256 lives in pgcrypto (`digest`), installed in the `extensions` schema on
-- Supabase; with search_path pinned to 'public' we fully-qualify extensions.digest.
-- ADDITIVE ONLY: one new function + grant/revoke. Not applied here — the
-- controller applies + smoke-tests after review.

create extension if not exists pgcrypto with schema extensions;

create or replace function public.accept_invite(p_token text)
returns jsonb
language plpgsql security definer set search_path = 'public' as $$
declare
v_uid uuid := auth.uid();
v_email text := lower(coalesce(auth.email(), ''));
v_hash text;
m record;
begin
-- Unauthenticated callers cannot accept (and revoke-from-anon backs this up).
if v_uid is null then
return jsonb_build_object('ok', false, 'reason', 'invalid');
end if;

v_hash := encode(extensions.digest(p_token, 'sha256'), 'hex');

select id, household_id, email, auth_user_id, expires_at
into m
from public.household_members
where invite_token_hash = v_hash
limit 1;

-- (1) found
if not found then
return jsonb_build_object('ok', false, 'reason', 'invalid');
end if;

-- (2) unexpired
if m.expires_at is not null and m.expires_at <= now() then
return jsonb_build_object('ok', false, 'reason', 'expired');
end if;

-- (3) target still unlinked (single-use)
if m.auth_user_id is not null then
return jsonb_build_object('ok', false, 'reason', 'already_linked');
end if;

-- (4) email-bound: the accepting account's email must match the invite's.
if v_email = '' or lower(coalesce(m.email, '')) <> v_email then
return jsonb_build_object('ok', false, 'reason', 'email_mismatch');
end if;

-- (5) the accepting account must not already be a member of this household.
if exists (
select 1 from public.household_members hm
where hm.household_id = m.household_id and hm.auth_user_id = v_uid
) then
return jsonb_build_object('ok', false, 'reason', 'already_member');
end if;

-- Success: atomic link + activate + clear (single statement).
update public.household_members
set auth_user_id = v_uid,
status = 'active',
invite_token_hash = null,
expires_at = null
where id = m.id;

insert into public.invite_events
(household_id, member_ref, email, kind, actor_auth_user_id, metadata)
values (m.household_id, m.id, m.email, 'accepted', v_uid, '{}'::jsonb);

return jsonb_build_object('ok', true, 'household_id', m.household_id);
end $$;

revoke execute on function public.accept_invite(text) from public, anon;
grant execute on function public.accept_invite(text) to authenticated;

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style per progress.md)
--
-- Fixture: user A (parent, owner of household H, auth-linked). A inserts a
-- co-parent invited member IR for household H with email = B's login email,
-- invite_token_hash = encode(extensions.digest('probe-raw','sha256'),'hex'),
-- status='invited', expires_at = now()+interval '14 days'. User B is a SECOND
-- authenticated account whose login email == IR.email. Run each probe as the
-- named identity (PostgREST rpc call, authenticated JWT). Use a unique run token
-- so repeated runs don't collide.
--
-- 1. as B: select public.accept_invite('probe-raw')
-- -> EXPECT {"ok":true,"household_id":"<H>"}. Then IR is auth_user_id=B,
-- status='active', invite_token_hash IS NULL; one invite_events row
-- kind='accepted', actor_auth_user_id=B.
-- 2. as B again: select public.accept_invite('probe-raw')
-- -> EXPECT {"ok":false,"reason":"invalid"} (hash cleared — single-use).
-- 3. WRONG EMAIL: re-seed a fresh IR2 (email = someone-else@…), as B call
-- accept_invite(IR2 raw) -> EXPECT {"ok":false,"reason":"email_mismatch"}.
-- 4. EXPIRED: re-seed IR3 (email=B, expires_at = now()-interval '1 day'), as B
-- -> EXPECT {"ok":false,"reason":"expired"}.
-- 5. ALREADY LINKED: re-seed IR4 (email=B) then service-role set its
-- auth_user_id to a random uuid; as B -> EXPECT
-- {"ok":false,"reason":"already_linked"}.
-- 6. ALREADY MEMBER: with B now a member of H (from probe 1), re-seed IR5
-- (email=B, unlinked) in H; as B -> EXPECT
-- {"ok":false,"reason":"already_member"}.
-- 7. UNKNOWN: as B call accept_invite('no-such-token')
-- -> EXPECT {"ok":false,"reason":"invalid"}.
-- 8. RLS twin: as B, select * from public.household_members where id = IR.id
-- BEFORE probe 1 -> EXPECT 0 rows (B could not read the invited row; the
-- definer RPC is the only path). Confirms the break this fixes.
-- 9. advisors sweep: security + performance advisors show no NEW findings
-- (function has pinned search_path + revoked-from-anon + granted-to-auth).
-- 10. cleanup (service-role): delete probe members IR..IR5 + their invite_events.
  • Step 2: Commit
git add infra/supabase/migrations/20260711000400_accept_invite_rpc.sql
git commit -m "feat(schema): accept_invite SECURITY DEFINER RPC (email-bound atomic accept)"

Task L1-T2: invite_token_hash column + partial-unique index (migration file)

Files:

  • Create: infra/supabase/migrations/20260711000100_member_invite_token_hash.sql

Interfaces:

  • Produces: column public.household_members.invite_token_hash text (replaces plaintext invite_token), unique partial index household_members_invite_token_hash_uidx. The SDK cloud codec + Drift column rename that PAIR with this migration are in L1-T5 — both must land before the migration is applied live (L1-T10).

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260711000100_member_invite_token_hash.sql:

-- Hash-store the invite token (spec decision 2, G3). The raw 192-bit token is
-- shown to the inviter ONCE and never persisted; only its sha256 hash is stored,
-- so a household-wide roster read (RLS household_members_select) no longer leaks
-- a redeemable token. Replaces the plaintext `invite_token` column added in
-- 20260612000007 with `invite_token_hash`.
--
-- Back-population: the pre-existing `invite_token` values ARE the raw tokens, so
-- hash them in place; the only recent live pending invite (Mom) was already
-- deleted, so the set is expected empty/tiny — VERIFY before applying.
-- ADDITIVE-then-DROP: add the hash column + backfill, then drop the plaintext
-- column and its unique index and add the hash's partial-unique index.
-- Not applied here — the controller applies + smoke-tests after review.

create extension if not exists pgcrypto with schema extensions;

alter table public.household_members add column invite_token_hash text;

-- Existing plaintext tokens are raw tokens -> hash them so any live pending
-- invite still resolves through accept_invite (which hashes the presented token).
update public.household_members
set invite_token_hash = encode(extensions.digest(invite_token, 'sha256'), 'hex')
where invite_token is not null;

drop index if exists household_members_invite_token_uidx;
alter table public.household_members drop column invite_token;

-- Single-use uniqueness on the hash (only where present).
create unique index household_members_invite_token_hash_uidx
on public.household_members (invite_token_hash)
where invite_token_hash is not null;

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style)
-- 1. pre-apply: select count(*) from household_members where invite_token is not
-- null -> record N (expected 0 or tiny). If N>0, snapshot those rows first.
-- 2. post-apply: \d household_members -> EXPECT column invite_token_hash text,
-- NO column invite_token; index household_members_invite_token_hash_uidx
-- present, household_members_invite_token_uidx gone.
-- 3. backfill parity: for each pre-apply pending row, invite_token_hash equals
-- encode(extensions.digest(<its old raw token>,'sha256'),'hex').
-- 4. uniqueness: insert two rows with the same invite_token_hash -> EXPECT
-- unique-violation on the second (then rollback).
-- 5. advisors sweep: no NEW findings.
  • Step 2: Commit
git add infra/supabase/migrations/20260711000100_member_invite_token_hash.sql
git commit -m "feat(schema): replace plaintext invite_token with invite_token_hash"

Task L1-T3: invite_events append-only table + RLS (migration file)

Files:

  • Create: infra/supabase/migrations/20260711000200_invite_events.sql

Interfaces:

  • Produces: table public.invite_events (id, household_id, member_ref, email, kind, actor_auth_user_id, created_at, metadata); RLS: SELECT for members of the household; INSERT for parental members of the household. The accepted row is inserted by accept_invite (SECURITY DEFINER bypasses RLS). The SDK reads/writes it via insertInviteEvent/getInviteEvents (L1-T5).

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260711000200_invite_events.sql:

-- Durable invite EVENT TRAIL (spec decision 3, G4 — the Mom-incident fix). A
-- pending invite is only a member row today, so its lifetime is owned by the
-- household row (on delete cascade) — a duplicate-cleanup silently destroyed
-- Mom's invite with no audit or re-issue path. This append-only log records
-- issue/resend/revoke/accept/expire/delete so a lost invite stays discoverable.
--
-- DURABILITY CHOICE: household_id is `on delete SET NULL` (NOT cascade) and
-- nullable, so the audit row SURVIVES household deletion — the record persists
-- even after the household (and its member rows) are gone. Clients only see rows
-- for households they belong to; orphaned rows are service-role/operator-visible.
-- ADDITIVE ONLY. Not applied here — the controller applies + smoke-tests.

create table public.invite_events (
id uuid primary key default gen_random_uuid(),
household_id uuid references public.households (id) on delete set null,
member_ref uuid,
email text,
kind text not null check (kind in
('issued', 'resent', 'revoked', 'accepted', 'expired', 'deleted')),
actor_auth_user_id uuid references auth.users (id),
created_at timestamptz not null default now(),
metadata jsonb not null default '{}'::jsonb
);

create index invite_events_household_id_idx on public.invite_events (household_id);

alter table public.invite_events enable row level security;

-- Members of the household can read its trail.
create policy invite_events_select on public.invite_events
for select to authenticated
using (household_id in (select public.member_household_ids()));

-- Parental members log lifecycle events (issued/resent/revoked/deleted) for
-- their OWN household. The `accepted` row is written by accept_invite
-- (SECURITY DEFINER, bypasses RLS), so the not-yet-member invitee never needs
-- INSERT here.
create policy invite_events_insert on public.invite_events
for insert to authenticated
with check (household_id in (select public.parental_household_ids()));

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style)
-- 1. as A (parent of H): insert invite_events (H, member_ref, 'a@x', 'issued',
-- auth.uid(), '{}') -> EXPECT ok; select it back -> EXPECT visible to A.
-- 2. as B (member of a DIFFERENT household): select from invite_events where
-- household_id = H -> EXPECT 0 rows (household-scoped SELECT).
-- 3. as B (not parental in H): insert a row for H -> EXPECT RLS denial (42501).
-- 4. durability: service-role delete household H -> EXPECT the probe-1 row
-- still present with household_id NULL (on delete set null).
-- 5. advisors sweep: no NEW findings (RLS enabled + both policies present).
-- 6. cleanup: service-role delete probe rows.
  • Step 2: Commit
git add infra/supabase/migrations/20260711000200_invite_events.sql
git commit -m "feat(schema): append-only invite_events trail with household-scoped RLS"

Task L1-T4: legacy {admin} backfill + pending-email dedupe index (migration file)

Files:

  • Create: infra/supabase/migrations/20260711000300_invite_hygiene_backfill.sql

Interfaces:

  • Produces: a data backfill demoting legacy invited rows to {member} (G9), and a partial unique index household_members_pending_email_uidx on (household_id, lower(email)) where status='invited' (G6). The SDK-side dedupe guard in inviteCoParent is the service twin (L1-T5).

  • Step 1: Write the migration file

Create infra/supabase/migrations/20260711000300_invite_hygiene_backfill.sql:

-- Invite hygiene: legacy-admin backfill (G9) + pending-email dedupe (G6).
--
-- G9: RG-S1 changed the invite default to {member}, but no migration demoted
-- pre-existing invited rows. A stale legacy invite carrying {admin}/{helper}
-- would grant elevated roles the instant it is accepted (privilege escalation via
-- old data). Demote every still-pending invite to {member}.
--
-- G6: a household may hold at most ONE pending invite per email. A partial
-- unique index forces a second co-parent invite to the same address to RESEND
-- (update) rather than mint a duplicate placeholder (the class behind bugs 1a/1b).
-- The SDK inviteCoParent guard (L1-T5) is the service twin of this index.
-- ADDITIVE ONLY (data update + index). Not applied here — controller applies.

update public.household_members
set roles = '{member}'
where status = 'invited'
and roles && array['admin', 'helper'];

create unique index household_members_pending_email_uidx
on public.household_members (household_id, lower(email))
where status = 'invited' and email is not null;

-- ─────────────────────────────────────────────────────────────────────────────
-- LIVE SMOKE PROBES (controller runs after apply — house style)
-- 1. pre-apply: select id, roles from household_members where status='invited'
-- and roles && array['admin','helper'] -> snapshot (expected empty/tiny).
-- 2. post-apply: same query -> EXPECT 0 rows; the snapshotted ids now roles
-- '{member}'.
-- 3. dedupe: as A, insert two invited rows for the same (household_id, email)
-- -> EXPECT unique-violation on the second (then rollback).
-- 4. active-not-affected: an ACTIVE member sharing that email is unaffected
-- (index is partial where status='invited').
-- 5. advisors sweep: no NEW findings.
  • Step 2: Commit
git add infra/supabase/migrations/20260711000300_invite_hygiene_backfill.sql
git commit -m "feat(schema): demote legacy admin invites + pending-email dedupe index"

Task L1-T5: SDK — hash-store tokens, return raw once, write the event trail

Files:

  • Modify: packages/client_sdk/pubspec.yaml (add crypto)
  • Modify: packages/client_sdk/lib/src/services/id_generator.dart (add hashInviteToken)
  • Create: packages/client_sdk/lib/src/models/invite_event.dart
  • Modify: packages/client_sdk/lib/src/models/exceptions.dart (add InviteRejectionReason + InviteAcceptException)
  • Modify: packages/client_sdk/lib/client_sdk.dart (export the new model — VERIFY barrel exports models; add if the pattern matches)
  • Modify: packages/client_sdk/lib/src/adapters/adapter.dart (StoragePort: add acceptInviteRemote, insertInviteEvent, getInviteEvents)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart (PostgrestPort: add rpc)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart (rpc passthrough)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart (_SupabaseRestPort.rpc)
  • Modify: packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart (codec invite_tokeninvite_token_hash; add acceptInviteRemote, insertInviteEvent, getInviteEvents)
  • Modify: packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart (event list + stubs)
  • Modify: packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart (forwarders)
  • Modify: packages/client_sdk/lib/src/adapters/local/local_database.dart + local_storage_adapter.dart (Drift inviteTokeninviteTokenHash, schema bump, event no-ops)
  • Modify: packages/client_sdk/lib/src/services/household_service.dart (inviteCoParent/inviteMember/resendInvite/revokeInvite hash-store + events + dedupe guard)
  • Modify: packages/client_sdk/test/support/fake_port.dart (event list + acceptInviteRemote stub)
  • Modify: packages/client_sdk/test/household_service_test.dart (update token assertions to hash; add event-trail + dedupe tests)
  • Test: packages/client_sdk/test/household_service_test.dart (existing invite groups)

Interfaces:

  • Consumes: nothing from earlier SDK tasks (schema tasks are file-only).

  • Produces (later tasks rely on these exact signatures):

    • String hashInviteToken(String rawToken) — lowercase hex SHA-256 of the UTF-8 bytes.
    • enum InviteEventKind { issued, resent, revoked, accepted, expired, deleted } with String get wireName and static InviteEventKind fromWireName(String).
    • class InviteEvent { final String id; final String householdId; final String? memberRef; final String? email; final InviteEventKind kind; final String? actorAuthUserId; final DateTime createdAt; final Map<String, dynamic> metadata; const InviteEvent({...}); }
    • enum InviteRejectionReason { invalid, expired, emailMismatch, alreadyMember, alreadyLinked }
    • class InviteAcceptException extends DomainRuleException { const InviteAcceptException(super.message, {required this.reason}); final InviteRejectionReason reason; }
    • StoragePort.acceptInviteRemote({required String token}) → Future<String> (household id), StoragePort.insertInviteEvent(InviteEvent) → Future<void>, StoragePort.getInviteEvents(String householdId) → Future<List<InviteEvent>>.
    • PostgrestPort.rpc(String fn, Map<String, dynamic> params) → Future<Map<String, dynamic>>.
    • inviteCoParent/inviteMember/resendInvite continue to return HouseholdMember, but the returned member's inviteToken now carries the raw token (one-time display); any member READ from storage carries the hash in inviteToken.
  • Step 1: Add the crypto dependency and a hashing helper — write the failing test

Add to packages/client_sdk/test/household_service_test.dart imports (top of file, with the other imports):

import 'package:crypto/crypto.dart' show sha256;
import 'dart:convert' show utf8;

Add this test inside void main() { ... } (e.g. right after the _SeqTokenGenerator class, as a top-level group):

group('hashInviteToken', () {
test('is the lowercase hex sha256 of the utf8 bytes', () {
expect(hashInviteToken('tok-1'),
sha256.convert(utf8.encode('tok-1')).toString());
});

test('is stable and 64 hex chars', () {
final h = hashInviteToken('tok-1');
expect(h, hashInviteToken('tok-1'));
expect(h, matches(RegExp(r'^[0-9a-f]{64}$')));
});
});

Add the import for the helper at the top of the test file (it already imports id_generator.dart):

import 'package:client_sdk/src/services/id_generator.dart';
  • Step 2: Run it to confirm it fails

Run: cd packages/client_sdk && fvm flutter test test/household_service_test.dart -p vm --plain-name "hashInviteToken" Expected: FAIL — The method 'hashInviteToken' isn't defined / Undefined name 'sha256' (crypto not yet a dep).

  • Step 3: Add crypto to the SDK pubspec

In packages/client_sdk/pubspec.yaml, under dependencies: (alphabetical, next to the existing entries), add:

crypto: ^3.0.6

Then run: cd packages/client_sdk && fvm flutter pub get

  • Step 4: Add the hashing helper

Append to packages/client_sdk/lib/src/services/id_generator.dart (add the imports at the top of that file if absent):

import 'dart:convert';
import 'package:crypto/crypto.dart';

/// Hashes a raw invite token for storage (spec decision 2). Returns the
/// lowercase hex SHA-256 of the token's UTF-8 bytes — byte-identical to the
/// Postgres `encode(extensions.digest(p_token,'sha256'),'hex')` the
/// `accept_invite` RPC computes, so a token hashed here resolves there.
String hashInviteToken(String rawToken) =>
sha256.convert(utf8.encode(rawToken)).toString();
  • Step 5: Run the hashing test — it passes

Run: cd packages/client_sdk && fvm flutter test test/household_service_test.dart -p vm --plain-name "hashInviteToken" Expected: PASS (2 tests).

  • Step 6: Create the InviteEvent model + kind enum

Create packages/client_sdk/lib/src/models/invite_event.dart:

/// One entry in the durable invite event trail (spec decision 3, G4). Append-
/// only; the cloud twin is the `invite_events` table. The trail makes a lost
/// invite discoverable + re-issuable independently of the member row's lifetime.
enum InviteEventKind {
issued,
resent,
revoked,
accepted,
expired,
deleted;

String get wireName => switch (this) {
InviteEventKind.issued => 'issued',
InviteEventKind.resent => 'resent',
InviteEventKind.revoked => 'revoked',
InviteEventKind.accepted => 'accepted',
InviteEventKind.expired => 'expired',
InviteEventKind.deleted => 'deleted',
};

static InviteEventKind fromWireName(String name) => switch (name) {
'issued' => InviteEventKind.issued,
'resent' => InviteEventKind.resent,
'revoked' => InviteEventKind.revoked,
'accepted' => InviteEventKind.accepted,
'expired' => InviteEventKind.expired,
'deleted' => InviteEventKind.deleted,
_ => throw ArgumentError('Unknown InviteEventKind: $name'),
};
}

/// An immutable invite lifecycle event. [householdId] may outlive its household
/// (the cloud row is `on delete set null`), so treat it as the household the
/// event was recorded against, not a live foreign key.
class InviteEvent {
const InviteEvent({
required this.id,
required this.householdId,
required this.kind,
required this.createdAt,
this.memberRef,
this.email,
this.actorAuthUserId,
this.metadata = const {},
});

final String id;
final String householdId;
final String? memberRef;
final String? email;
final InviteEventKind kind;
final String? actorAuthUserId;
final DateTime createdAt;
final Map<String, dynamic> metadata;
}

Add the export to the barrel packages/client_sdk/lib/client_sdk.dart (follow the existing export 'src/models/...'; block — VERIFY the pattern first with graphify query "client_sdk barrel exports models" then add):

export 'src/models/invite_event.dart';
  • Step 7: Add the invite exceptions

Append to packages/client_sdk/lib/src/models/exceptions.dart:

/// Why an [InviteAcceptException] was raised — lets the accept UI pick specific
/// copy without inspecting the message. Mirrors the `reason` the `accept_invite`
/// RPC returns (invalid/expired/email_mismatch/already_member/already_linked) and
/// the local-parity checks in HouseholdService.acceptInvite.
enum InviteRejectionReason {
/// No invite matches the presented code (unknown/cleared/single-use spent).
invalid,

/// The invite matched but its window has passed (`expires_at <= now`).
expired,

/// The accepting account's email does not match the invite's email
/// (email-bound acceptance, spec decision 1).
emailMismatch,

/// The accepting account already has a member row in this household.
alreadyMember,

/// The invited row is already linked to some account (already consumed).
alreadyLinked,
}

/// Thrown when accepting an invite is rejected for a domain reason. [reason]
/// carries the machine-readable cause so the UI shows specific copy instead of a
/// generic "invalid code" (G11). Extends [DomainRuleException] so existing
/// `on Exception`/`on DomainRuleException` catches still work.
class InviteAcceptException extends DomainRuleException {
const InviteAcceptException(super.message, {required this.reason});

final InviteRejectionReason reason;

@override
String toString() => 'InviteAcceptException($reason): $message';
}
  • Step 8: Extend StoragePort and PostgrestPort

In packages/client_sdk/lib/src/adapters/adapter.dart, add the import at the top:

import '../models/invite_event.dart';

and add to abstract class StoragePort, in the Members section (after deleteMember):

/// Cloud-only atomic accept (spec Layer 1, G1). Presents the RAW [token] to the
/// `accept_invite` SECURITY DEFINER RPC, which hashes + verifies + links under
/// definer rights, and returns the joined household id. Throws an
/// [InviteAcceptException] carrying the RPC's typed reason on rejection. Local
/// adapters do NOT implement this — local/in-memory mode uses
/// HouseholdService.acceptInvite's direct parity path instead (they throw
/// [UnimplementedError] here; it is never called when useRemoteInviteAccept is
/// false).
Future<String> acceptInviteRemote({required String token});

/// Appends one row to the durable invite event trail (spec decision 3, G4).
Future<void> insertInviteEvent(InviteEvent event);

/// Reads the invite event trail for [householdId] (newest-first). Minimal
/// support for a future history/re-issue UI; the durability value is the
/// persisted trail.
Future<List<InviteEvent>> getInviteEvents(String householdId);

In packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart, add to abstract class PostgrestPort (after deleteEq):

/// Calls a Postgres function via PostgREST RPC and returns its decoded jsonb
/// object result. The first (and currently only) RPC caller is the invite
/// accept path (`accept_invite`).
Future<Map<String, dynamic>> rpc(String fn, Map<String, dynamic> params);
  • Step 9: Implement rpc in the production port + MappingPort

In packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart, add to class _SupabaseRestPort:

@override
Future<Map<String, dynamic>> rpc(String fn, Map<String, dynamic> params) async {
final result = await _c.rpc(fn, params: params);
return (result as Map).cast<String, dynamic>();
}

In packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart, add to class MappingPort (after deleteEq):

@override
Future<Map<String, dynamic>> rpc(String t, Map<String, dynamic> params) =>
_w(() => _inner.rpc(t, params));
  • Step 10: Implement the cloud codec rename + the three new methods (Households mixin)

In packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart:

(a) rename the codec column both ways: in _member(...) change inviteToken: r['invite_token'] as String?, to inviteToken: r['invite_token_hash'] as String?,; in _memberValues(...) change 'invite_token': m.inviteToken, to 'invite_token_hash': m.inviteToken,.

(b) add imports at the top:

import '../../models/exceptions.dart';
import '../../models/invite_event.dart';

(c) add the three methods inside mixin Households (after deleteMember):

Future<String> acceptInviteRemote({required String token}) async {
final result = await db.rpc('accept_invite', {'p_token': token});
if (result['ok'] == true) {
return result['household_id'] as String;
}
throw _inviteRejection(result['reason'] as String?);
}

InviteAcceptException _inviteRejection(String? reason) => switch (reason) {
'expired' => const InviteAcceptException('This invite has expired.',
reason: InviteRejectionReason.expired),
'email_mismatch' => const InviteAcceptException(
'This invite was sent to a different email address.',
reason: InviteRejectionReason.emailMismatch),
'already_member' => const InviteAcceptException(
'You are already a member of this household.',
reason: InviteRejectionReason.alreadyMember),
'already_linked' => const InviteAcceptException(
'This invite has already been used.',
reason: InviteRejectionReason.alreadyLinked),
_ => const InviteAcceptException('That invite code is not valid.',
reason: InviteRejectionReason.invalid),
};

Future<void> insertInviteEvent(InviteEvent event) => db.insert('invite_events', {
'household_id': event.householdId,
'member_ref': event.memberRef,
'email': event.email,
'kind': event.kind.wireName,
'actor_auth_user_id': event.actorAuthUserId,
'metadata': event.metadata,
});

Future<List<InviteEvent>> getInviteEvents(String householdId) async {
final rows = await db.selectEq(
'invite_events',
{'household_id': householdId},
orderBy: 'created_at',
ascending: false,
);
return rows.map(_inviteEvent).toList();
}

InviteEvent _inviteEvent(Map<String, dynamic> r) => InviteEvent(
id: r['id'] as String,
householdId: r['household_id'] as String,
memberRef: r['member_ref'] as String?,
email: r['email'] as String?,
kind: InviteEventKind.fromWireName(r['kind'] as String),
actorAuthUserId: r['actor_auth_user_id'] as String?,
createdAt: dtN(r['created_at'])!,
metadata: (r['metadata'] as Map?)?.cast<String, dynamic>() ?? const {},
);

Note: SupabaseStorageAdapter already implements StoragePort via the Households mixin, so these satisfy the new interface members for the cloud adapter.

  • Step 11: Implement the new methods on the in-memory, cached, and Drift adapters

In packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart — add the import import '../../models/invite_event.dart';, a store field near the other maps: final List<InviteEvent> _inviteEvents = [];, and:

@override
Future<String> acceptInviteRemote({required String token}) =>
throw UnimplementedError(
'acceptInviteRemote is cloud-only; local mode uses '
'HouseholdService.acceptInvite');

@override
Future<void> insertInviteEvent(InviteEvent event) async {
_inviteEvents.add(event);
}

@override
Future<List<InviteEvent>> getInviteEvents(String householdId) async =>
_inviteEvents
.where((e) => e.householdId == householdId)
.toList(growable: false)
.reversed
.toList(growable: false);

In packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart — add import '../../models/invite_event.dart'; and forwarders that go to _durable (events are not cached; accept must hydrate the joined household so the follow-up member read hits a warm cache):

@override
Future<String> acceptInviteRemote({required String token}) async {
await _ensureHydrated();
final householdId = await _durable.acceptInviteRemote(token: token);
// The invitee is now a member of householdId server-side; hydrate that
// household into the cache so the subsequent memberByAuthUserId read
// (served from cache) sees the freshly-linked member.
await _hydrate(householdId: householdId);
return householdId;
}

@override
Future<void> insertInviteEvent(InviteEvent event) =>
_durable.insertInviteEvent(event);

@override
Future<List<InviteEvent>> getInviteEvents(String householdId) =>
_durable.getInviteEvents(householdId);

In packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart — add import '../../models/invite_event.dart'; and Drift-tier implementations (local durability of the trail is deferred; accept is cloud-only):

@override
Future<String> acceptInviteRemote({required String token}) =>
throw UnimplementedError(
'acceptInviteRemote is cloud-only; local mode uses '
'HouseholdService.acceptInvite');

@override
Future<void> insertInviteEvent(InviteEvent event) async {
// Local single-device tier has no cross-account invite delivery; the event
// trail is a cloud durability feature. Intentionally a no-op here.
}

@override
Future<List<InviteEvent>> getInviteEvents(String householdId) async => const [];

Also rename the Drift column. In packages/client_sdk/lib/src/adapters/local/local_database.dart: change TextColumn get inviteToken => text().nullable()(); to TextColumn get inviteTokenHash => text().nullable()();; bump int get schemaVersion by 1; add a migration step in the onUpgrade/MigrationStrategy renaming the column (Drift m.alterTable / m.renameColumn(householdMembers, 'invite_token', householdMembers.inviteTokenHash) — VERIFY the exact Drift migration API with graphify query "local_database MigrationStrategy schemaVersion" and follow the existing step at the documented add-column line). In local_storage_adapter.dart update _memberFromRow (inviteToken: row.inviteTokenHash) and _memberCompanion (inviteTokenHash: Value(member.inviteToken)). Regenerate Drift codegen: cd packages/client_sdk && fvm dart run build_runner build --delete-conflicting-outputs --build-filter "lib/src/adapters/local/local_database.g.dart".

Also add the stub to the SDK unit-test fake packages/client_sdk/test/support/fake_port.dart — add import '../../lib/src/models/invite_event.dart'; is not needed (barrel exports it); it already imports package:client_sdk/client_sdk.dart. Add a field final List<InviteEvent> inviteEvents = []; and:

@override
Future<String> acceptInviteRemote({required String token}) =>
throw UnimplementedError('acceptInviteRemote is cloud-only in tests');

@override
Future<void> insertInviteEvent(InviteEvent event) async {
inviteEvents.add(event);
}

@override
Future<List<InviteEvent>> getInviteEvents(String householdId) async =>
inviteEvents.where((e) => e.householdId == householdId).toList();

Also add the same three overrides to MockClient/any StoragePort fake in packages/client_sdk_testing if one exists (run graphify query "client_sdk_testing StoragePort fake in-memory adapter" — if client_sdk_testing re-exports InMemoryStorageAdapter the overrides are inherited; if it declares its own StoragePort, add the three overrides there too).

  • Step 12: Write the failing SDK behaviour tests (hash-store + events + dedupe)

In packages/client_sdk/test/household_service_test.dart, UPDATE the existing token assertions and ADD new tests. Replace the existing inviteCoParent creates ... assertion expect(invite.inviteToken, 'tok-1'); with these two lines (returned raw + persisted hash):

expect(invite.inviteToken, 'tok-1'); // returned RAW token (one-time)
expect(port.members[invite.id]!.inviteToken, hashInviteToken('tok-1'));

Replace the resendInvite regenerates ... assertion expect(resent.inviteToken, 'tok-2'); with:

expect(resent.inviteToken, 'tok-2'); // returned RAW
expect(port.members[resent.id]!.inviteToken, hashInviteToken('tok-2'));

Add these tests inside the co-parent invite group (inviteService/port in scope):

test('inviteCoParent writes an issued invite_event', () async {
final invite =
await inviteService.inviteCoParent(email: '[email protected]');
final events = port.inviteEvents;
expect(events, hasLength(1));
expect(events.single.kind, InviteEventKind.issued);
expect(events.single.email, '[email protected]');
expect(events.single.memberRef, invite.id);
});

test('resendInvite writes a resent invite_event', () async {
final invite =
await inviteService.inviteCoParent(email: '[email protected]');
await inviteService.resendInvite(invite.id);
expect(port.inviteEvents.last.kind, InviteEventKind.resent);
});

test('revokeInvite writes a revoked invite_event', () async {
final invite =
await inviteService.inviteCoParent(email: '[email protected]');
await inviteService.revokeInvite(invite.id);
expect(port.inviteEvents.last.kind, InviteEventKind.revoked);
});

test('a second pending invite to the same email is rejected (dedupe)',
() async {
await inviteService.inviteCoParent(email: '[email protected]');
expect(
() => inviteService.inviteCoParent(email: '[email protected]'),
throwsA(isA<DomainRuleException>()),
);
});
  • Step 13: Run them to confirm they fail

Run: cd packages/client_sdk && fvm flutter test test/household_service_test.dart -p vm Expected: FAIL — the hash assertions fail (port.members[...].inviteToken is still 'tok-1'), port.inviteEvents is empty, and the dedupe test does not throw.

  • Step 14: Implement the service changes

In packages/client_sdk/lib/src/services/household_service.dart, add the import import 'id_generator.dart'; is already present. Rewrite the four verbs:

inviteCoParent — add the pending-email dedupe guard, hash-store, event, return raw:

Future<HouseholdMember> inviteCoParent({
required String email,
String? note,
Duration validFor = defaultInviteValidity,
}) async {
final household = await _requireHousehold();
final trimmedEmail = email.trim();
if (trimmedEmail.isEmpty) {
throw const ValidationException('Invite email must not be empty.');
}
final members = await _storage.getMembers(household.id);
final lowered = trimmedEmail.toLowerCase();
if (members.any((m) =>
m.status == MemberStatus.active &&
(m.email ?? '').toLowerCase() == lowered)) {
throw const DomainRuleException(
'A member with that email is already active in this household.',
);
}
// G6 dedupe: at most one pending invite per email (service twin of the
// household_members_pending_email_uidx index).
if (members.any((m) =>
m.status == MemberStatus.invited &&
(m.email ?? '').toLowerCase() == lowered)) {
throw const DomainRuleException(
'There is already a pending invite for that email. Resend it instead.',
);
}
final now = _now();
final rawToken = _tokens.next();
final stored = await _storage.insertMember(
HouseholdMember(
id: _ids.next(),
householdId: household.id,
displayName: trimmedEmail,
kind: MemberKind.coParent,
roles: const {MemberRole.member},
status: MemberStatus.invited,
email: trimmedEmail,
inviteToken: hashInviteToken(rawToken),
invitedAt: now,
inviteNote: note,
expiresAt: now.add(validFor),
createdAt: now,
),
);
await _storage.insertInviteEvent(InviteEvent(
id: _ids.next(),
householdId: household.id,
memberRef: stored.id,
email: trimmedEmail,
kind: InviteEventKind.issued,
createdAt: now,
));
return stored.copyWith(setInviteToken: () => rawToken);
}

Add the import for the model at the top of household_service.dart: import '../models/invite_event.dart';.

resendInvite:

Future<HouseholdMember> resendInvite(
String memberId, {
Duration validFor = defaultInviteValidity,
}) async {
final member = await _requirePendingInvite(memberId);
final now = _now();
final rawToken = _tokens.next();
final updated = await _storage.updateMember(
member.copyWith(
setInviteToken: () => hashInviteToken(rawToken),
setInvitedAt: () => now,
setExpiresAt: () => now.add(validFor),
),
);
await _storage.insertInviteEvent(InviteEvent(
id: _ids.next(),
householdId: member.householdId,
memberRef: member.id,
email: member.email,
kind: InviteEventKind.resent,
createdAt: now,
));
return updated.copyWith(setInviteToken: () => rawToken);
}

revokeInvite — write the event before mutating:

Future<void> revokeInvite(String memberId) async {
final member = await _requirePendingInvite(memberId);
await _storage.insertInviteEvent(InviteEvent(
id: _ids.next(),
householdId: member.householdId,
memberRef: member.id,
email: member.email,
kind: InviteEventKind.revoked,
createdAt: _now(),
));
if (member.status == MemberStatus.invited) {
await _storage.deleteMember(memberId);
return;
}
await _storage.updateMember(
member.copyWith(
setInviteToken: () => null,
setInvitedAt: () => null,
setInviteNote: () => null,
setExpiresAt: () => null,
),
);
}

inviteMember — hash-store + issued event + return raw (keep the existing guards L595-613 unchanged, replace the final write):

final now = _now();
final rawToken = _tokens.next();
final updated = await _storage.updateMember(
target.copyWith(
setEmail: () => trimmedEmail,
setInviteToken: () => hashInviteToken(rawToken),
setInvitedAt: () => now,
setInviteNote: () => note,
setExpiresAt: () => now.add(validFor),
),
);
await _storage.insertInviteEvent(InviteEvent(
id: _ids.next(),
householdId: household.id,
memberRef: updated.id,
email: trimmedEmail,
kind: InviteEventKind.issued,
createdAt: now,
));
return updated.copyWith(setInviteToken: () => rawToken);
  • Step 15: Run the SDK suite — all green

Run: cd packages/client_sdk && fvm flutter test Expected: PASS. All prior invite tests plus the new hash/event/dedupe tests pass. (acceptInvite tests still pass: they read invite.inviteToken! which is the returned RAW token — the local accept path in L1-T6 hashes it back to match the stored hash; until L1-T6 lands, the existing local acceptInvite still compares raw-to-stored, which now MISMATCHES the stored hash. Therefore, in this step, also update HouseholdService.acceptInvite's local match to hash the incoming token — see the interim shim below so this task stays green on its own.)

Interim shim inside acceptInvite (replace the token match line .where((m) => m.inviteToken != null && m.inviteToken == token) with):

final hash = hashInviteToken(token);
final member = members
.where((m) => m.inviteToken != null && m.inviteToken == hash)
.firstOrNull;

(L1-T6 then replaces acceptInvite wholesale with the mode-branching version; this shim keeps L1-T5 independently green.)

  • Step 16: Update graphify + commit
cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/pubspec.yaml packages/client_sdk/lib/src/services/id_generator.dart packages/client_sdk/lib/src/models/invite_event.dart packages/client_sdk/lib/src/models/exceptions.dart packages/client_sdk/lib/client_sdk.dart packages/client_sdk/lib/src/adapters/adapter.dart packages/client_sdk/lib/src/adapters/cloud/cloud_rows.dart packages/client_sdk/lib/src/adapters/cloud/mapping_port.dart packages/client_sdk/lib/src/adapters/cloud/supabase_storage_adapter.dart packages/client_sdk/lib/src/adapters/cloud/supabase_households.dart packages/client_sdk/lib/src/adapters/memory/in_memory_storage_adapter.dart packages/client_sdk/lib/src/adapters/cached/cached_storage_adapter.dart packages/client_sdk/lib/src/adapters/local/local_database.dart packages/client_sdk/lib/src/adapters/local/local_database.g.dart packages/client_sdk/lib/src/adapters/local/local_storage_adapter.dart packages/client_sdk/lib/src/services/household_service.dart packages/client_sdk/test/support/fake_port.dart packages/client_sdk/test/household_service_test.dart pubspec.lock
git commit -m "feat(sdk): hash-store invite tokens, return raw once, write invite_events trail"

(Also git add the client_sdk_testing fake if you modified it. Never stage graphify-out/.)


Task L1-T6: SDK — route acceptInvite through the RPC in cloud, parity in local

Files:

  • Modify: packages/client_sdk/lib/src/services/household_service.dart (acceptInvite mode-branch + local parity; constructor flag)
  • Modify: packages/client_sdk/lib/src/client/client_impl.dart (clientFromPort threads the flag; ClientImpl.acceptInvite adds email)
  • Modify: packages/client_sdk/lib/src/client/client.dart (abstract acceptInvite adds email)
  • Modify: packages/client_sdk/lib/src/client/create_client.dart (pass useRemoteInviteAccept)
  • Modify: packages/client_sdk/test/cloud/fake_postgrest.dart (programmable rpc)
  • Test: packages/client_sdk/test/household_service_test.dart (local parity) + a new cloud-routing test in packages/client_sdk/test/cloud/

Interfaces:

  • Consumes: InviteAcceptException + InviteRejectionReason + hashInviteToken + StoragePort.acceptInviteRemote + PostgrestPort.rpc (L1-T5).

  • Produces: facade + service acceptInvite({required String token, required String authUserId, required String email}). Cloud mode → accept_invite RPC via acceptInviteRemote, then re-reads the linked member. Local mode → direct parity path with email-bound + already-member + expiry + already-linked checks, throwing InviteAcceptException with the matching reason. Repository/bloc callers (L1-T8/T9) must pass email.

  • Step 1: Add the programmable rpc to the cloud fake

In packages/client_sdk/test/cloud/fake_postgrest.dart, add fields and method:

/// Canned response for the next rpc() call (invite accept routing tests).
Map<String, dynamic>? rpcResponse;

@override
Future<Map<String, dynamic>> rpc(String fn, Map<String, dynamic> params) async {
final r = rpcResponse;
if (r == null) {
throw StateError('FakePostgrest.rpc($fn) called with no rpcResponse set');
}
return r;
}
  • Step 2: Write the failing cloud-routing test

Create packages/client_sdk/test/cloud/accept_invite_routing_test.dart:

import 'package:client_sdk/client_sdk.dart';
import 'package:client_sdk/ports.dart';
import 'package:client_sdk/src/adapters/cloud/supabase_storage_adapter.dart';
import 'package:client_sdk/src/services/household_service.dart';
import 'package:flutter_test/flutter_test.dart';

import 'fake_postgrest.dart';

void main() {
late FakePostgrest fake;
late SupabaseStorageAdapter adapter;
late HouseholdService service;

setUp(() {
fake = FakePostgrest();
adapter = SupabaseStorageAdapter.forTest(fake);
service = HouseholdService(storage: adapter, useRemoteInviteAccept: true);
// The joined household's member is read back after the RPC succeeds.
fake.rows('household_members').add({
'id': 'm-jo',
'household_id': 'h1',
'display_name': 'Jo',
'kind': 'co_parent',
'roles': ['member'],
'status': 'active',
'auth_user_id': 'u-jo',
'email': '[email protected]',
'invite_token_hash': null,
'invited_at': null,
'invite_note': null,
'expires_at': null,
'watch_only': false,
'owner': false,
'consent_state': 'none',
'created_at': '2026-07-11T00:00:00Z',
});
});

test('cloud acceptInvite routes through the RPC and returns the member',
() async {
fake.rpcResponse = {'ok': true, 'household_id': 'h1'};
final member = await service.acceptInvite(
token: 'raw-token',
authUserId: 'u-jo',
);
expect(member.id, 'm-jo');
expect(member.status, MemberStatus.active);
});

test('cloud acceptInvite maps an email_mismatch reason to a typed exception',
() async {
fake.rpcResponse = {'ok': false, 'reason': 'email_mismatch'};
await expectLater(
() => service.acceptInvite(
token: 'raw', authUserId: 'u-jo', email: '[email protected]'),
throwsA(isA<InviteAcceptException>()
.having((e) => e.reason, 'reason', InviteRejectionReason.emailMismatch)),
);
});
}

Note: verify fake_postgrest.dart exposes List<Map<String,dynamic>> rows(String t) (agent-confirmed at fake_postgrest.dart:23). The seeded member's columns must match what _member decodes — cross-check with the codec fields; add any the decoder requires (age, birth_date, traits, home_place_id, can_switch_accounts, consent_ref, consent_tos_version, consent_privacy_version, terms_version_accepted, country, emoji, color_key) as null if _member throws on a missing key.

  • Step 3: Run it to confirm it fails

Run: cd packages/client_sdk && fvm flutter test test/cloud/accept_invite_routing_test.dart -p vm Expected: FAIL — HouseholdService has no useRemoteInviteAccept parameter, and acceptInvite has no email parameter.

  • Step 4: Add the constructor flag + mode-branching acceptInvite

In packages/client_sdk/lib/src/services/household_service.dart, add the flag to the constructor and field:

HouseholdService({
required StoragePort storage,
IdGenerator? idGenerator,
TokenGenerator? tokenGenerator,
DateTime Function()? now,
Authorizer? authorizer,
bool useRemoteInviteAccept = false,
}) : _storage = storage,
_ids = idGenerator ?? const IdGenerator(),
_tokens = tokenGenerator ?? const TokenGenerator(),
_now = now ?? DateTime.now,
_authorizer = authorizer ?? const Authorizer(),
_useRemoteInviteAccept = useRemoteInviteAccept;

final bool _useRemoteInviteAccept;

Replace acceptInvite entirely:

/// Accepts an invite. In CLOUD mode routes the RAW token through the
/// `accept_invite` SECURITY DEFINER RPC (email-bound + atomic under RLS); in
/// LOCAL/in-memory mode runs the direct parity twin (email-bound + already-
/// member + expiry + already-linked). Either path throws an
/// [InviteAcceptException] carrying a typed [InviteRejectionReason] on
/// rejection. [email] is the accepting account's email (used for the local
/// email-bound check; the RPC authoritatively uses auth.email() server-side).
Future<HouseholdMember> acceptInvite({
required String token,
required String authUserId,
required String email,
}) async {
if (token.trim().isEmpty) {
throw const InviteAcceptException('Enter an invite code.',
reason: InviteRejectionReason.invalid);
}
if (_useRemoteInviteAccept) {
final householdId = await _storage.acceptInviteRemote(token: token);
final member = await _storage.memberByAuthUserId(authUserId);
if (member == null || member.householdId != householdId) {
throw const StorageFailure(
'Joined, but could not load your membership. Please reopen the app.');
}
return member;
}

// LOCAL parity twin of accept_invite (dual gate).
final household = await _requireHousehold();
final members = await _storage.getMembers(household.id);
final hash = hashInviteToken(token);
final member = members
.where((m) => m.inviteToken != null && m.inviteToken == hash)
.firstOrNull;
if (member == null) {
throw const InviteAcceptException('That invite code is not valid.',
reason: InviteRejectionReason.invalid);
}
final now = _now();
if (member.expiresAt != null && member.expiresAt!.isBefore(now)) {
throw const InviteAcceptException('This invite has expired.',
reason: InviteRejectionReason.expired);
}
if (member.authUserId != null) {
throw const InviteAcceptException('This invite has already been used.',
reason: InviteRejectionReason.alreadyLinked);
}
final lowered = email.trim().toLowerCase();
if (lowered.isEmpty || (member.email ?? '').toLowerCase() != lowered) {
throw const InviteAcceptException(
'This invite was sent to a different email address.',
reason: InviteRejectionReason.emailMismatch);
}
if (members.any((m) => m.authUserId == authUserId)) {
throw const InviteAcceptException(
'You are already a member of this household.',
reason: InviteRejectionReason.alreadyMember);
}
final accepted = await _storage.updateMember(
member.copyWith(
status: MemberStatus.active,
setAuthUserId: () => authUserId,
setInviteToken: () => null,
setExpiresAt: () => null,
),
);
await _storage.insertInviteEvent(InviteEvent(
id: _ids.next(),
householdId: household.id,
memberRef: accepted.id,
email: member.email ?? email,
kind: InviteEventKind.accepted,
actorAuthUserId: authUserId,
createdAt: now,
));
return accepted;
}
  • Step 5: Thread the flag + email through the facade + factory

In packages/client_sdk/lib/src/client/client_impl.dart, change clientFromPort's signature and the service construction:

Client clientFromPort(
StoragePort storage, {
DateTime Function()? now,
ClientAuth? auth,
bool useRemoteInviteAccept = false,
}) {

and (at the householdService: line):

householdService: HouseholdService(
storage: storage,
now: clock,
useRemoteInviteAccept: useRemoteInviteAccept,
),

and ClientImpl.acceptInvite (add the email param + pass through):

@override
Future<HouseholdMember> acceptInvite({
required String token,
required String authUserId,
required String email,
}) =>
_householdService.acceptInvite(
token: token, authUserId: authUserId, email: email);

In packages/client_sdk/lib/src/client/client.dart, update the abstract declaration:

Future<HouseholdMember> acceptInvite({
required String token,
required String authUserId,
required String email,
});

In packages/client_sdk/lib/src/client/create_client.dart, at the cloud/auth return (the clientFromPort(dataPort, auth: supabaseAuth) call), pass the flag:

return clientFromPort(
dataPort,
auth: supabaseAuth,
useRemoteInviteAccept: config.dataMode == DataMode.cloud,
);

(The free-tier clientFromPort(storage) call keeps the default false.)

  • Step 6: Update the existing local acceptInvite unit tests to pass email

In packages/client_sdk/test/household_service_test.dart, every acceptInvite(token: ..., authUserId: ...) call gains email: matching the invited member's email. For the co-parent group (invited email '[email protected]') add email: '[email protected]'. For the unknown-token test add email: '[email protected]' (the reason is invalid before email is checked). For the expired test add email: '[email protected]'. Change the "unknown token throws" and "past expiresAt throws" expectations from isA<DomainRuleException>() to isA<InviteAcceptException>() (InviteAcceptException extends DomainRuleException, so either passes; prefer the specific type). Add a new email-bound test:

test('acceptInvite with the wrong email is rejected (email-bound)', () async {
final invite =
await inviteService.inviteCoParent(email: '[email protected]');
await expectLater(
() => inviteService.acceptInvite(
token: invite.inviteToken!,
authUserId: 'u-jo',
email: '[email protected]'),
throwsA(isA<InviteAcceptException>().having(
(e) => e.reason, 'reason', InviteRejectionReason.emailMismatch)),
);
});
  • Step 7: Run the SDK suite — all green

Run: cd packages/client_sdk && fvm flutter test Expected: PASS, including accept_invite_routing_test.dart (cloud routing + reason mapping) and the local email-bound test.

  • Step 8: Update graphify + commit
cd packages/client_sdk && fvm flutter analyze
graphify update .
git add packages/client_sdk/lib/src/services/household_service.dart packages/client_sdk/lib/src/client/client_impl.dart packages/client_sdk/lib/src/client/client.dart packages/client_sdk/lib/src/client/create_client.dart packages/client_sdk/test/cloud/fake_postgrest.dart packages/client_sdk/test/cloud/accept_invite_routing_test.dart packages/client_sdk/test/household_service_test.dart
git commit -m "feat(sdk): route acceptInvite through accept_invite RPC in cloud, email-bound local parity"

Task L1-T7: LOAD-BEARING two-auth-identity cloud accept test (live)

Files:

  • Create: packages/client_sdk/test/cloud/accept_invite_live_test.dart

Interfaces:

  • Consumes: the deployed accept_invite RPC + invite_token_hash column + invite_events (applied live at L1-T10; this test is authored now and RUN at L1-T10 after apply). Two SupabaseClients under real RLS.

  • Produces: the coverage that would have caught the original break (inviter ≠ invitee under RLS: accept succeeds; wrong-email / expired / already-member / already-linked each denied). Self-skips without SUPABASE_URL, so the offline suite (app 545 / SDK 1037) stays green.

  • Step 1: Write the live test (guarded, self-skipping)

Create packages/client_sdk/test/cloud/accept_invite_live_test.dart. Model it 1:1 on authz_rls_test.dart (two clients, _ensureSignedIn, @Tags(['live'])). The token/hash the inviter stores MUST match what accept_invite hashes — hash the raw with the same hashInviteToken (imported from the SDK):

@Tags(['live'])
library;

import 'package:client_sdk/src/services/id_generator.dart' show hashInviteToken;
import 'package:flutter_test/flutter_test.dart';
import 'package:supabase/supabase.dart';

const _url = String.fromEnvironment('SUPABASE_URL');
const _anonKey = String.fromEnvironment('SUPABASE_ANON_KEY');
const _runId = String.fromEnvironment('SMOKE_RUN_ID', defaultValue: 'devsmoke');

Future<void> _ensureSignedIn(SupabaseClient c, String email, String pw) async {
try {
await c.auth.signInWithPassword(email: email, password: pw);
} on AuthException {
await c.auth.signUp(email: email, password: pw);
await c.auth.signInWithPassword(email: email, password: pw);
}
}

void main() {
if (_url.isEmpty) {
test('accept_invite live — skipped (no SUPABASE_URL)', () {},
skip: 'pass --dart-define-from-file=app/config/supabase.local.json');
return;
}

late SupabaseClient a; // inviter/parent, owner of household H
late SupabaseClient b; // invitee — email matches the invite
final aEmail = '[email protected]';
final bEmail = '[email protected]';
const pw = 'Test-passw0rd!';
late String householdId;
late String memberId;

setUpAll(() async {
a = SupabaseClient(_url, _anonKey);
b = SupabaseClient(_url, _anonKey);
await _ensureSignedIn(a, aEmail, pw);
await _ensureSignedIn(b, bEmail, pw);

// A bootstraps a household + self as first parent (RLS bootstrap branch).
final h = await a.from('households').insert({
'name': 'Invite Live $_runId',
}).select().single();
householdId = h['id'] as String;
await a.from('household_members').insert({
'household_id': householdId,
'display_name': 'A',
'kind': 'parent',
'roles': ['admin'],
'status': 'active',
'auth_user_id': a.auth.currentUser!.id,
'owner': true,
});
});

tearDownAll(() async {
await a.dispose();
await b.dispose();
});

Future<void> seedInvite(String rawToken, String email,
{Duration validFor = const Duration(days: 14)}) async {
final row = await a.from('household_members').insert({
'household_id': householdId,
'display_name': email,
'kind': 'co_parent',
'roles': ['member'],
'status': 'invited',
'email': email,
'invite_token_hash': hashInviteToken(rawToken),
'expires_at': DateTime.now().toUtc().add(validFor).toIso8601String(),
}).select().single();
memberId = row['id'] as String;
}

test('B cannot read the invited row before accepting (the break this fixes)',
() async {
await seedInvite('valid-$_runId', bEmail);
final visible = await b
.from('household_members')
.select()
.eq('id', memberId);
expect(visible, isEmpty, reason: 'RLS hides the invited row from non-member B');
});

test('B accepts with the matching email → linked + active', () async {
final res = await b.rpc('accept_invite', params: {'p_token': 'valid-$_runId'});
final map = (res as Map).cast<String, dynamic>();
expect(map['ok'], true);
expect(map['household_id'], householdId);
// A can now see B linked + active.
final row = await a
.from('household_members')
.select()
.eq('id', memberId)
.single();
expect(row['auth_user_id'], b.auth.currentUser!.id);
expect(row['status'], 'active');
expect(row['invite_token_hash'], isNull);
});

test('a second accept of the same token is invalid (single-use)', () async {
final res = await b.rpc('accept_invite', params: {'p_token': 'valid-$_runId'});
expect(((res as Map)['reason']), 'invalid');
});

test('wrong email is denied', () async {
await seedInvite('wrong-$_runId', '[email protected]');
final res = await b.rpc('accept_invite', params: {'p_token': 'wrong-$_runId'});
expect(((res as Map)['reason']), 'email_mismatch');
});

test('expired invite is denied', () async {
await seedInvite('exp-$_runId', bEmail, validFor: const Duration(days: -1));
final res = await b.rpc('accept_invite', params: {'p_token': 'exp-$_runId'});
expect(((res as Map)['reason']), 'expired');
});

test('already-member is denied', () async {
// B is already a member (accepted above). A fresh invite to B → already_member.
await seedInvite('again-$_runId', bEmail);
final res = await b.rpc('accept_invite', params: {'p_token': 'again-$_runId'});
expect(((res as Map)['reason']), 'already_member');
});
}
  • Step 2: Confirm it self-skips offline

Run: cd packages/client_sdk && fvm flutter test test/cloud/accept_invite_live_test.dart Expected: PASS with 1 skipped (accept_invite live — skipped (no SUPABASE_URL)). No live calls made. This keeps the SDK baseline green.

  • Step 3: Commit (the live run happens at L1-T10 after apply)
git add packages/client_sdk/test/cloud/accept_invite_live_test.dart
git commit -m "test(sdk): live two-identity accept_invite RLS coverage (self-skipping)"

Task L1-T8: App — surface the co-parent invite code (send + resend)

Files:

  • Create: packages/design_system or app/lib/inside/routes/authenticated/members/invite_code_card.dart (shared code-card widget extracted from _CodeView)
  • Modify: app/lib/inside/routes/authenticated/members/account_invite_sheet.dart (_CodeView delegates to the shared card — keeps existing keys)
  • Create: app/lib/inside/routes/authenticated/members/co_parent_code_sheet.dart (showCoParentCodeSheet)
  • Modify: app/lib/inside/blocs/household/members_bloc.dart (_onSaved invite branch + _onInviteResent capture the returned token → state)
  • Modify: app/lib/inside/blocs/household/members_state.dart (add coParentInviteToken + coParentInviteAttempt)
  • Modify: app/lib/inside/routes/authenticated/members/member_editor_sheet.dart + invite_actions_sheet.dart (open the code sheet on a new token)
  • Modify: app/lib/inside/i18n/strings... (co-parent code strings — VERIFY the Strings location via graphify query "Strings memberAccountInviteCodeLabel i18n")
  • Test: app/test/flows/ — a co-parent invite flow test

Interfaces:

  • Consumes: HouseholdRepository.inviteCoParent / resendInvite returning a HouseholdMember whose .inviteToken is the raw code (L1-T5 semantics unchanged at the repo boundary).

  • Produces: after a co-parent invite or resend, the UI shows the raw code once in a share sheet.

  • Step 1: Write the failing flow test

Create app/test/flows/co_parent_invite_code_test.dart following the setup_test.dart shape (agent-confirmed harness): flowTest<MocksContainer> + createFlowConfig() + testAppBuilder(mocks), stub mocks.householdRepository.inviteCoParent(...) to return a seeded member with inviteToken: 'RAWCODE', drive the member-editor co-parent invite, and assert a widget showing RAWCODE appears. Use seedMember/seedHousehold helpers. Skeleton:

// (imports mirror app/test/flows/setup_test.dart)
void main() {
setUpAll(() {
registerClientSdkFallbacks();
});
final household = seedHousehold(name: 'Casa');
flowTest<MocksContainer>(
'co_parent_invite_shows_code',
config: createFlowConfig(),
descriptions: [
...baseDescriptions,
FTDescription(
descriptionType: 'AC',
directoryName: 'co_parent_invite_shows_code',
description: 'inviting a co-parent surfaces the share code once'),
],
test: (tester) async {
await tester.setUp(warp: warpToHome);
// open members → add → co-parent invite, fill email, tap Send;
// stub inviteCoParent to return the invited member carrying the raw code.
await tester.screenshot(
description: 'co-parent code sheet shows the raw code',
arrangeBeforeActions: (arrange) {
when(() => arrange.mocks.householdRepository.inviteCoParent(
email: any(named: 'email'),
note: any(named: 'note'),
)).thenAnswer((_) async => seedMember(
id: 'inv-1',
householdId: household.id,
displayName: '[email protected]',
kind: MemberKind.coParent,
status: MemberStatus.invited,
inviteToken: 'RAWCODE',
));
},
actions: (actions) async {
// navigate + fill + tap the co-parent invite Send button
// (use the member editor keys confirmed in member_editor_sheet.dart)
},
expectations: (e) {
e.expect(find.text('RAWCODE'), findsOneWidget,
reason: 'the raw co-parent code is surfaced once');
},
);
},
);
}

(Fill the actions navigation using the confirmed keys: open More/members roster → add member → DsSegmented co-parent segment → email DsTextField → Save button MemberEditorSheet save. The executor wires the exact taps against the live widget tree — the assertion find.text('RAWCODE') is the load-bearing check.)

  • Step 2: Run it to confirm it fails

Run: cd app && fvm flutter test test/flows/co_parent_invite_code_test.dart Expected: FAIL — no widget renders RAWCODE (the token is currently discarded in _onSaved).

  • Step 3: Extract the shared code card

Create app/lib/inside/routes/authenticated/members/invite_code_card.dart containing a PUBLIC InviteCodeCard widget with the exact structure of _CodeView (bordered SelectableText code, Copy/Resend/Done DsButtons) but parameterized: {required String code, required bool inFlight, required String title, required String instructions, required String copyLabel, required String resendLabel, required String doneLabel, required Key codeKey, required Key copyKey, required Key resendKey, required Key doneKey, required VoidCallback onCopy, required VoidCallback onResend, required VoidCallback onDone}. Copy the body verbatim from account_invite_sheet.dart _CodeView (L178-260), replacing the hard-wired Strings.memberAccountInvite* + Key('AccountInviteSheet.*') with the parameters.

Then in account_invite_sheet.dart, replace _CodeView's build body with a delegation to InviteCodeCard passing its existing strings + keys (so its existing gallery/flow tests keep passing with identical keys). Keep the _CodeView class + its constructor unchanged.

  • Step 4: Add transient token state + capture it in the bloc

In app/lib/inside/blocs/household/members_state.dart add fields mirroring the account-invite pair (agent-confirmed): final String? coParentInviteToken; (default null) and final int coParentInviteAttempt; (default 0), a setCoParentInviteToken setter-closure in copyWith (mirror setAccountInviteToken at L749-754), and include both in props.

In app/lib/inside/blocs/household/members_bloc.dart add a nonce field int _coParentInviteAttempt = 0; (near _accountInviteAttempt). In _onSaved's invite branch, capture the return and emit the token WITHOUT saveSuccess (which pops the editor):

if (state.isInviteMode) {
try {
final invited = await _householdRepository.inviteCoParent(
email: state.editorEmail.trim(),
note: state.editorInviteNote?.trim().isEmpty ?? true
? null
: state.editorInviteNote!.trim(),
);
emit(state.copyWith(
status: MembersStatus.ready,
coParentInviteAttempt: ++_coParentInviteAttempt,
setCoParentInviteToken: () => invited.inviteToken,
setErrorMessage: () => null,
));
} on Exception catch (e) {
emit(state.copyWith(
status: MembersStatus.saveFailure,
setErrorMessage: () => e.toString(),
));
}
return;
}

In _onInviteResent, capture and emit likewise:

Future<void> _onInviteResent(
MemberInviteResent event,
Emitter<MembersState> emit,
) async {
emit(state.copyWith(status: MembersStatus.saving));
try {
final updated = await _householdRepository.resendInvite(event.memberId);
emit(state.copyWith(
status: MembersStatus.ready,
coParentInviteAttempt: ++_coParentInviteAttempt,
setCoParentInviteToken: () => updated.inviteToken,
setErrorMessage: () => null,
));
} on Exception catch (e) {
emit(state.copyWith(
status: MembersStatus.saveFailure,
setErrorMessage: () => e.toString(),
));
}
}
  • Step 5: Show the code sheet on a new token

Create app/lib/inside/routes/authenticated/members/co_parent_code_sheet.dart with Future<void> showCoParentCodeSheet(BuildContext context, {required String code, required MembersBloc bloc, String? memberId}) that showDsSheets an InviteCodeCard (co-parent strings + keys CoParentCodeSheet.code/copyButton/resendButton/doneButton), wiring Copy → clipboard + snackbar, Resend → bloc.add(MemberInviteResent(memberId!)), Done → pop.

In member_editor_sheet.dart, extend the BlocConsumer.listener (currently pops on saveSuccess at L138-144) to ALSO detect a new co-parent token: when state.coParentInviteToken != null && state.coParentInviteAttempt increased, pop the editor and showCoParentCodeSheet(context, code: state.coParentInviteToken!, bloc: bloc, memberId: <the invited member id>). In invite_actions_sheet.dart, add the same listener so a Resend surfaces the fresh code (the sheet currently only shows status + Resend/Revoke).

Add the co-parent strings next to the account-invite strings (VERIFY the file via graphify query "Strings memberAccountInviteCopyButton"): memberCoParentCodeTitle, memberCoParentCodeInstructions, memberCoParentCopyButton, memberCoParentResendButton, memberCoParentDoneButton.

  • Step 6: Run the flow test + full app suite

Run: cd app && fvm flutter test test/flows/co_parent_invite_code_test.dart Expected: PASS (find.text('RAWCODE') found). Run: cd app && fvm flutter test Expected: PASS, app total ≥ 545 (baseline held + the new flow test).

  • Step 7: Update graphify + commit
cd app && fvm flutter analyze
graphify update .
git add app/lib/inside/routes/authenticated/members/invite_code_card.dart app/lib/inside/routes/authenticated/members/account_invite_sheet.dart app/lib/inside/routes/authenticated/members/co_parent_code_sheet.dart app/lib/inside/blocs/household/members_bloc.dart app/lib/inside/blocs/household/members_state.dart app/lib/inside/routes/authenticated/members/member_editor_sheet.dart app/lib/inside/routes/authenticated/members/invite_actions_sheet.dart app/test/flows/co_parent_invite_code_test.dart
git commit -m "feat(app): surface the co-parent invite code after send + resend"

(Also git add the i18n strings file you edited and any members_state.g.dart regenerated. Never stage graphify-out/.)


Task L1-T9: App — Join-with-code surface + typed accept errors

Files:

  • Create: app/lib/inside/blocs/join_household/cubit.dart + state.dart
  • Create: app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart (showJoinWithCodeSheet)
  • Modify: app/lib/inside/routes/authenticated/more/widgets/more_settings_tiles.dart (add the tile)
  • Modify: app/lib/outside/repositories/household/household_repository.dart (acceptInvite adds email)
  • Modify: app/lib/inside/blocs/setup/bloc.dart (_onInviteCodeSubmitted passes email + typed error copy)
  • Modify: app/lib/inside/i18n/strings... (typed accept-error copy + join strings)
  • Test: app/test/flows/ join-with-code + typed setup-error flow tests

Interfaces:

  • Consumes: Client.acceptInvite({token, authUserId, email}) (L1-T6), InviteAcceptException + InviteRejectionReason (L1-T5), AuthUser.email (auth_repository.dart:23), the More tile pattern (MoreTile ListTile in more_settings_tiles.dart).

  • Produces: a signed-in user can paste a code in More → Join, accept it (email-bound), and be confirmed into the joined household; the Setup accept path shows reason-specific copy.

  • Step 1: Update the repository signature (compile-driven)

In household_repository.dart, change acceptInvite to forward email:

Future<HouseholdMember> acceptInvite({
required String token,
required String authUserId,
required String email,
}) =>
_clientProvider.client
.acceptInvite(token: token, authUserId: authUserId, email: email);
  • Step 2: Write the failing typed-error test for Setup

Add to app/test/flows/setup_test.dart (or a new app/test/flows/setup_invite_error_test.dart) a flow that stubs mocks.householdRepository.acceptInvite(...) to throw InviteAcceptException('...', reason: InviteRejectionReason.expired) and asserts the dialog shows expired-specific copy (Strings.setupInviteCodeExpired), NOT the generic Strings.setupInviteCodeInvalid.

  • Step 3: Run it — fails

Run: cd app && fvm flutter test test/flows/setup_invite_error_test.dart Expected: FAIL — the catch-all (setup/bloc.dart L427-436) still emits the generic invalid copy.

  • Step 4: Pass email + typed error mapping in the setup bloc

In setup/bloc.dart _onInviteCodeSubmitted, resolve the email and replace the catch-all:

final user = _authRepository?.currentUser;
final authUserId = user?.id;
if (authUserId == null) {
emit(state.copyWith(inviteSubmitting: false,
setInviteError: () => Strings.setupInviteCodeNoAccount));
return;
}
try {
await _householdRepository.acceptInvite(
token: code, authUserId: authUserId, email: user!.email);
emit(state.copyWith(inviteSubmitting: false, inviteAccepted: true,
setInviteError: () => null));
} on InviteAcceptException catch (e) {
emit(state.copyWith(inviteSubmitting: false,
setInviteError: () => _inviteErrorCopy(e.reason)));
} on Exception {
emit(state.copyWith(inviteSubmitting: false,
setInviteError: () => Strings.setupInviteCodeInvalid));
}

Add a helper (in the bloc file):

String _inviteErrorCopy(InviteRejectionReason reason) => switch (reason) {
InviteRejectionReason.expired => Strings.setupInviteCodeExpired,
InviteRejectionReason.emailMismatch => Strings.setupInviteCodeWrongEmail,
InviteRejectionReason.alreadyMember => Strings.setupInviteCodeAlreadyMember,
InviteRejectionReason.alreadyLinked => Strings.setupInviteCodeAlreadyUsed,
InviteRejectionReason.invalid => Strings.setupInviteCodeInvalid,
};

Add the five strings to i18n (VERIFY location). Import InviteAcceptException/InviteRejectionReason from package:client_sdk/client_sdk.dart.

  • Step 5: Build the Join-with-code cubit + sheet + tile

Create join_household/cubit.dart + state.dart: a JoinHouseholdCubit holding HouseholdRepository + AuthRepository, with Future<void> submit(String code) that calls acceptInvite(token: code, authUserId: currentUser.id, email: currentUser.email), emitting JoinHouseholdState sealed variants (JoinIdle, JoinSubmitting, JoinSuccess(householdId), JoinFailure(reason)), mapping InviteAcceptException.reason → copy the same way.

Create join_with_code_sheet.dartshowJoinWithCodeSheet(context): a confirmation-first dialog ("Joining a household with a code will make it your active household.") + a DsTextField (key JoinWithCode.field) + submit (key JoinWithCode.submit); on JoinSuccess show a confirmation then re-resolve the session so the joined (newest-first memberByAuthUserId) household becomes active — context.router.replaceAll([const MainShellRoute()]) (VERIFY the authenticated root route name via graphify query "AppRouter routes MainShellRoute home authenticated"; use whatever route the post-login guard lands on so getHousehold() re-reads).

In more_settings_tiles.dart, add a MoreTile (rowKey more.joinWithCode, an appropriate icon, title Strings.moreJoinWithCode, onTap: () => showJoinWithCodeSheet(context)) between existing tiles.

  • Step 6: Write + run the join-with-code flow test

Create app/test/flows/join_with_code_test.dart: warp to More, tap the Join tile, enter a code, stub acceptInvite to return a member, confirm, assert navigation/confirmation. Run:

Run: cd app && fvm flutter test test/flows/join_with_code_test.dart test/flows/setup_invite_error_test.dart Expected: PASS.

  • Step 7: Full app suite + commit

Run: cd app && fvm flutter test Expected: PASS, app total ≥ 545 + new tests.

cd app && fvm flutter analyze
graphify update .
git add app/lib/inside/blocs/join_household/cubit.dart app/lib/inside/blocs/join_household/state.dart app/lib/inside/routes/authenticated/more/join_with_code_sheet.dart app/lib/inside/routes/authenticated/more/widgets/more_settings_tiles.dart app/lib/outside/repositories/household/household_repository.dart app/lib/inside/blocs/setup/bloc.dart app/test/flows/join_with_code_test.dart app/test/flows/setup_invite_error_test.dart
git commit -m "feat(app): join-with-code surface in More + typed accept-error copy"

(Also git add the i18n strings file. Never stage graphify-out/.)


Task L1-T10: Layer-1 deploy + live two-identity accept smoke + suites green

Files: none (deploy + verification checklist). The controller applies migrations + runs the live smoke.

Interfaces:

  • Consumes: all L1 tasks merged. Depends on the deployed migrations (20260711000100..000400) + the app build.

  • Step 1: Rebuild the SDK codegen + confirm offline suites green

Run: cd packages/client_sdk && fvm flutter test → PASS (SDK ≥ 1037, live test self-skipped). Run: cd app && fvm flutter test → PASS (app ≥ 545).

  • Step 2: Controller applies the four migrations live (in filename order)

The controller (not this task) applies, IN ORDER, against project bgedvvmihygwxhjxlvfu, running each file's LIVE SMOKE PROBES after apply: 20260711000100_member_invite_token_hash.sql20260711000200_invite_events.sql20260711000300_invite_hygiene_backfill.sql20260711000400_accept_invite_rpc.sql. Confirm the pre-apply probe (count of plaintext invite_token rows) is empty/tiny before dropping the column.

  • Step 3: Run the live two-identity accept smoke

Ensure the Supabase dashboard has email-confirm OFF (so sign-up yields an immediate session) and no active email rate-limit, then run with a fresh run id:

Run: cd . && fvm flutter test packages/client_sdk/test/cloud/accept_invite_live_test.dart --dart-define-from-file=app/config/supabase.local.json --dart-define=SMOKE_RUN_ID=l1t10-$(date +%s) Expected: PASS — B is denied the invited-row read, then accepts with the matching email (linked + active), single-use re-accept returns invalid, wrong-email → email_mismatch, expired → expired, already-member → already_member.

  • Step 4: Deploy the app (cloud) + manual two-identity smoke

Deploy via the existing path (scripts/deploy-app-cloud.sh — VERIFY). Then manually: account A invites a co-parent (email = account B's), copies the surfaced code; account B signs in with that email, pastes the code in Setup or More → Join; B lands in A's household as an active member. Wrong-email attempt shows the email-mismatch copy.

  • Step 5: Tag the shippable Layer-1 milestone (no push — controller pushes)
git log --oneline -12

Confirm L1-T1..T9 commits are present. Layer 1 is complete and shippable.


LAYER 2 — Delivery + deep links (builds on Layer 1)

Task L2-T1: send-invite Edge Function (service-role email)

Files:

  • Create: infra/supabase/functions/send-invite/index.ts (replaces the .gitkeep stub)
  • Modify: (optional seam) an SDK/app invoke of the function on co-parent invite

Interfaces:

  • Consumes: service-role env SUPABASE_SERVICE_ROLE_KEY (function runtime only — never shipped in the app). Called with { email, token }.
  • Produces: an email to the invitee containing 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).

There is NO existing edge-function template in the repo (both function dirs are .gitkeep). Author from scratch.

  • Step 1: Write the function

Create infra/supabase/functions/send-invite/index.ts:

// send-invite — emails an invitee an actionable invite link + code fallback.
// Service-role only (never client-called with a service key). MVP uses
// Supabase's built-in invite email via auth.admin.inviteUserByEmail with a
// redirectTo carrying the token; a branded provider (Resend) is a later swap.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};

Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
status: 405,
headers: { ...cors, "Content-Type": "application/json" },
});
}
try {
const { email, token } = await req.json();
if (typeof email !== "string" || typeof token !== "string" || !email || !token) {
return new Response(JSON.stringify({ error: "email and token required" }), {
status: 400,
headers: { ...cors, "Content-Type": "application/json" },
});
}
const admin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const redirectTo = `https://rewhaven.com/invite?token=${encodeURIComponent(token)}`;
const { error } = await admin.auth.admin.inviteUserByEmail(email, {
redirectTo,
data: { invite_token: token },
});
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...cors, "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { ...cors, "Content-Type": "application/json" },
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), {
status: 500,
headers: { ...cors, "Content-Type": "application/json" },
});
}
});
  • Step 2: Deploy (controller / with the Supabase CLI, service-role)

Deploy via the Supabase CLI: supabase functions deploy send-invite --project-ref bgedvvmihygwxhjxlvfu (the controller runs this — it needs the linked project + service-role secret set via supabase secrets set). The function is NOT client-called with a service key.

  • Step 3: Smoke the function

Invoke with a test payload (controller): curl -sX POST "$SUPABASE_URL/functions/v1/send-invite" -H "Authorization: Bearer $SERVICE_ROLE" -H "Content-Type: application/json" -d '{"email":"[email protected]","token":"smoke-token"}' → EXPECT {"ok":true} and an invite email arriving with a rewhaven.com/invite?token=smoke-token link.

  • Step 4: Wire the co-parent invite path to invoke it (best-effort, non-blocking)

After inviteCoParent/inviteMember returns the raw token, invoke send-invite so the invitee is emailed. The app already has the raw token (surfaced in L1-T8). Add a thin call from the members bloc's invite success path via a new repository method sendInviteEmail({required String email, required String token}) that routes through a new SDK facade Client.sendInvite(...) → cloud adapter db/functions invoke (VERIFY whether the wrapped SupabaseClient exposes functions.invoke; if not, call the function's HTTP endpoint with the anon key + the user's JWT). Make failures non-fatal (the code is already shown; email is a convenience). Guard behind cloud mode.

  • Step 5: Commit
git add infra/supabase/functions/send-invite/index.ts
git commit -m "feat(edge): send-invite function emails invitee a link + code fallback"

(Commit any SDK/app seam files added in Step 4 with explicit paths.)


Files:

  • Modify: app/pubspec.yaml (add app_links)
  • Create: app/lib/inside/incoming_links/incoming_link_handler.dart
  • Modify: app/lib/app/builder.dart (or the app root) to start the handler
  • Modify: app/lib/inside/routes/router.dart (+ regenerate router.gr.dart) — add an AcceptInviteRoute
  • Create: app/lib/inside/routes/authenticated/accept_invite/page.dart (auto-submits acceptInvite)
  • Test: app/test/flows/ incoming-link → accept

Interfaces:

  • Consumes: JoinHouseholdCubit/acceptInvite (L1-T9). Uri.base.queryParameters['invite'] on web (default hash strategy — the param sits before the hash, so this works).

  • Produces: a single handler that extracts a token from an Android App Link intent (cold + warm) or the web ?invite= param and routes to the accept flow → auto-submits.

  • Step 1: Add app_links

In app/pubspec.yaml under dependencies: add app_links: ^6.3.0; run cd app && fvm flutter pub get.

  • Step 2: Write the handler + a token-extraction unit test

Create incoming_link_handler.dart exposing a pure String? inviteTokenFromUri(Uri uri) (reads uri.queryParameters['invite'] OR a /invite?token= path/query) plus an AppLinks() subscription (cold-start getInitialLink() + uriLinkStream) that, on a token, navigates to AcceptInviteRoute(token: token). Write a unit test for inviteTokenFromUri covering https://rewhaven.com/invite?token=ABCABC, https://app/?invite=ABCABC, and no-token → null.

Run: cd app && fvm flutter test test/unit/incoming_link_handler_test.dart → PASS after implementing the pure function.

  • Step 3: Add the accept route + auto-submit page

Add AutoRoute(path: '/invite', page: AcceptInviteRoute.page) to router.dart (UNGUARDED entry — a signed-out invitee must reach it, then be routed to sign-in preserving the token). Regenerate: cd app && fvm dart run build_runner build --delete-conflicting-outputs. Create accept_invite/page.dart (@RoutePage()) taking token, that on load (if signed in) dispatches JoinHouseholdCubit.submit(token) and shows result copy; if signed out, routes to sign-in and resumes after auth.

  • Step 4: Start the handler at the app root

In app/lib/app/builder.dart start the IncomingLinkHandler once the router + auth are available (VERIFY the composition root via graphify query "app builder appBuilder AppRouter authBloc"). On web this reads Uri.base at startup.

  • Step 5: Flow test + commit

Add a flow test simulating an incoming /invite?token=CODE routing to the accept page and auto-submitting (stub acceptInvite). Run cd app && fvm flutter test → PASS.

cd app && fvm flutter analyze
graphify update .
git add app/pubspec.yaml app/lib/inside/incoming_links/incoming_link_handler.dart app/lib/app/builder.dart app/lib/inside/routes/router.dart app/lib/inside/routes/router.gr.dart app/lib/inside/routes/authenticated/accept_invite/page.dart app/test/unit/incoming_link_handler_test.dart app/test/flows/incoming_invite_link_test.dart pubspec.lock
git commit -m "feat(app): incoming invite-link handler (app_links + web ?invite=) routes to accept"

Files:

  • Modify: app/android/app/src/main/AndroidManifest.xml

Interfaces:

  • Consumes: the /invite route (L2-T2). Package applicationId = com.eldrforge.rewhaven (namespace com.eldrforge.household_app).

  • Produces: an autoVerify App Links intent-filter on MainActivity for https://rewhaven.com/invite.

  • Step 1: Add the intent-filter

Inside the existing <activity android:name=".MainActivity" ...> (alongside the LAUNCHER filter), add:

<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="rewhaven.com"
android:pathPrefix="/invite" />
</intent-filter>
  • Step 2: Build-check + commit

Run: cd app && fvm flutter build apk --debug (or analyze if no Android toolchain) → EXPECT the manifest merges without error. (Domain verification is release-gated — see L2-T5.)

git add app/android/app/src/main/AndroidManifest.xml
git commit -m "feat(android): App Links intent-filter for https://rewhaven.com/invite"

Task L2-T4: Marketsite /invite landing + assetlinks.json scaffold (CROSS-REPO)

CROSS-REPO — this task is executed in the separate repo rytedesigns/rewhaven-marketsite (present locally at C:\Users\ryted\Development\repo\rytedesigns\rewhaven-marketsite, Astro 6 + Cloudflare Worker). The app monorepo cannot build it. Deliverable description only.

Files (in the marketsite repo):

  • Create: src/pages/invite.astro — a landing page that reads ?token=; if the app is installed the Android App Link opens it directly; if not, shows "Get the app" (Play Store) + displays the code for manual entry.
  • Create: public/.well-known/assetlinks.json — Android App Links association file (SHA-256 fingerprint filled at L2-T5, release-gated).

Interfaces:

  • Produces: https://rewhaven.com/invite?token=<raw> resolves to a real page; https://rewhaven.com/.well-known/assetlinks.json is served (Astro serves public/ at the site root; the marketsite Worker already hosts the domain).

  • Step 1 (in marketsite repo): create src/pages/invite.astro reading Astro.url.searchParams.get('token'), rendering the code + a Play Store CTA + copy explaining manual entry in the app.

  • Step 2 (in marketsite repo): create public/.well-known/assetlinks.json with the package name com.eldrforge.rewhaven and a PLACEHOLDER sha256_cert_fingerprints: [] (filled at L2-T5). Confirm the Cloudflare _headers/Worker serves .well-known/* with content-type: application/json.

  • Step 3 (in marketsite repo): deploy the marketsite and confirm both URLs resolve. Commit in the marketsite repo (separate from this monorepo).


ANDROID-RELEASE-GATED — NOT EXECUTABLE NOW. No signed Android build/release exists yet (a Play account exists). This task is a checklist to complete when the first Play upload lands (Play App Signing provides the production signing-cert SHA-256). It creates no app-monorepo code changes now.

  • Obtain the production signing-cert SHA-256 from Play Console → App Signing (after first upload / Play App Signing).
  • In the marketsite repo, fill public/.well-known/assetlinks.json sha256_cert_fingerprints with that SHA-256; redeploy.
  • Set the Play Store fallback URL in the marketsite /invite "Get the app" CTA (final store listing URL).
  • On a release-signed device build, verify App Link auto-verification: adb shell pm get-app-links com.eldrforge.rewhaven shows rewhaven.com: verified; tapping a https://rewhaven.com/invite?token=... link opens the app directly to the accept flow.
  • Confirm cold-start + warm-start both route the token (L2-T2 handler) on the release build.

Self-Review (performed against the spec)

1. Spec coverage — every spec section maps to a task:

  • Decision 1 (email-bound) → L1-T1 (RPC), L1-T6 (local parity), L1-T7 (live proof).
  • Decision 2 (hash-store) → L1-T2 (column), L1-T5 (SDK hash + codec + Drift).
  • Decision 3 (event trail) → L1-T3 (table), L1-T5 (SDK writes issued/resent/revoked), L1-T1 (RPC writes accepted).
  • Decision 4 (join-with-code) → L1-T9.
  • Decision 5 (delivery in scope) → L2-T1.
  • Decision 6 (Android App Links + web ?invite=) → L2-T2, L2-T3, L2-T4.
  • Decision 7 (release-gated sequenced) → L2-T5.
  • Layer-1 schema bullets (RPC, hash column, invite_events, {admin} backfill, pending-email dedupe, server-side expiry) → L1-T1..T4.
  • SDK bullets (hash-store + return raw + events, cloud RPC routing + reason mapping, local parity, events read) → L1-T5, L1-T6 (events read = getInviteEvents).
  • App bullets (surface code, join-with-code, typed errors) → L1-T8, L1-T9.
  • Testing bullet (two-identity load-bearing) → L1-T7; live smoke → L1-T10.
  • Security bullets (email-bound, hash, definer pinned/revoked, {admin} demote, children non-invitable) → L1-T1/T2/T4, Global Constraints.

2. Gaps / divergences flagged for the controller:

  • invite_events RLS INSERT — the spec says "INSERT only via the service/RPC (definer) path." This plan grants INSERT to parental members of the household (issued/resent/revoked/deleted by the trusted inviter) and lets the SECURITY DEFINER accept_invite write the accepted row (definer bypasses RLS). A pure definer-only INSERT would require turning every invite verb into an RPC; the parental-member policy is the minimal dual-gate-consistent reconciliation. Controller: confirm this interpretation.
  • invite_events durability — chosen household_id on delete SET NULL (nullable) so the audit row survives household deletion (the Mom-incident durability goal); the spec listed the column without an on-delete rule. Orphaned rows are service-role-visible only.
  • Model field name kept as inviteToken — the DB columns/codecs rename to invite_token_hash, but the Dart model field stays inviteToken (persisted value = hash; the transient raw is only ever in an invite-verb return value). This minimizes UI ripple. If the controller prefers a field rename to inviteTokenHash, it touches members_bloc L1352/L1386, the JSON FieldRename.snake key, Drift, and the cloud codec.
  • send-invite invoke seam (L2-T1 Step 4) — the wrapped SupabaseClient may not expose functions.invoke through the SDK facade; the executor verifies and either adds a thin SDK seam or calls the function endpoint with the user JWT. Marked to VERIFY.
  • Deep-dive file:line drift confirmed accurate except: _onSaved invite branch is L1159-1183 (deep-dive said discard at L1161-1173 — the discard is L1161; correct); the model access helpers are at L375-394 as cited; acceptInvite was L535-559 as cited. No material divergence found — the deep-dive Appendix index held up against the current tree (feat/mvp1-personas-authz).

3. Type/signature consistency — verified across tasks: hashInviteToken, InviteEvent/InviteEventKind, InviteRejectionReason/InviteAcceptException, StoragePort.acceptInviteRemote/insertInviteEvent/getInviteEvents, PostgrestPort.rpc, HouseholdService(useRemoteInviteAccept:), acceptInvite({token, authUserId, email}), and the RPC reason wire-strings (invalid/expired/email_mismatch/already_member/already_linked) are used identically in the SQL (L1-T1), the cloud adapter mapper (L1-T5), and the local parity path (L1-T6).